feat(studio B1): persist input edits to content elements (render-binding foundation)
Build backend images / build content-svc (push) Failing after 1m3s
Build backend images / build file-svc (push) Failing after 1m5s
Build backend images / build gateway (push) Failing after 1m0s
Build backend images / build identity-svc (push) Failing after 1m8s
Build backend images / build notification-svc (push) Failing after 57s
Build backend images / build render-svc (push) Failing after 1m6s
Build backend images / build studio-svc (push) Failing after 1m3s
Build backend images / build content-svc (push) Failing after 1m3s
Build backend images / build file-svc (push) Failing after 1m5s
Build backend images / build gateway (push) Failing after 1m0s
Build backend images / build identity-svc (push) Failing after 1m8s
Build backend images / build notification-svc (push) Failing after 57s
Build backend images / build render-svc (push) Failing after 1m6s
Build backend images / build studio-svc (push) Failing after 1m3s
Studio edits previously went only to edit_state; the render binds saved_scene_contents,
so edits never reached the MP4. Add studio-svc PATCH /v1/saved-projects/{id}/contents
(update saved_scene_contents.value/value_file_id by content key, ExecuteSqlInterpolated
for null-safe params) + Next /api/projects/[id]/contents route + persistence hook pushes
edited values (from bridged c-<key> layers) alongside the scene_data save. Verified
text persists incl. UTF-8 Persian (9 chars/17 bytes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -230,6 +230,31 @@ public class StudioService(StudioDbContext db)
|
|||||||
return await GetProjectAsync(id, userId);
|
return await GetProjectAsync(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update individual scene-input values by content key (the studio editor's live
|
||||||
|
/// edits). Writes to saved_scene_contents.value so the render binder uses them.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> UpdateSceneContentsAsync(Guid projectId, Guid userId, List<UpdateContentItem> items)
|
||||||
|
{
|
||||||
|
var owns = await db.SavedProjects.AnyAsync(x => x.Id == projectId && x.UserId == userId);
|
||||||
|
if (!owns) throw new KeyNotFoundException($"Project {projectId} not found");
|
||||||
|
|
||||||
|
var updated = 0;
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.Key)) continue;
|
||||||
|
// ExecuteSqlInterpolated maps C# null → typed SQL NULL (raw DBNull params throw).
|
||||||
|
updated += await db.Database.ExecuteSqlInterpolatedAsync($@"
|
||||||
|
UPDATE studio.saved_scene_contents c
|
||||||
|
SET value = {item.Value}, value_file_id = COALESCE({item.ValueFileId}, c.value_file_id), updated_at = now()
|
||||||
|
FROM studio.saved_scenes s
|
||||||
|
WHERE c.saved_scene_id = s.id AND s.saved_project_id = {projectId} AND c.key = {item.Key}");
|
||||||
|
}
|
||||||
|
await db.Database.ExecuteSqlInterpolatedAsync(
|
||||||
|
$"UPDATE studio.saved_projects SET last_edit_date = now(), updated_at = now() WHERE id = {projectId}");
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteProjectAsync(Guid id, Guid userId)
|
public async Task DeleteProjectAsync(Guid id, Guid userId)
|
||||||
{
|
{
|
||||||
var project = await db.SavedProjects
|
var project = await db.SavedProjects
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ public class StudioController(StudioService svc) : ControllerBase
|
|||||||
public async Task<IActionResult> SaveScenes(Guid id, [FromBody] List<SaveSceneRequest> scenes) =>
|
public async Task<IActionResult> SaveScenes(Guid id, [FromBody] List<SaveSceneRequest> scenes) =>
|
||||||
Ok(await svc.SaveScenesAsync(id, UserId, scenes));
|
Ok(await svc.SaveScenesAsync(id, UserId, scenes));
|
||||||
|
|
||||||
|
/// <summary>Update individual scene-input values by content key (live studio edits).</summary>
|
||||||
|
[HttpPatch("{id:guid}/contents")]
|
||||||
|
public async Task<IActionResult> UpdateContents(Guid id, [FromBody] UpdateContentsRequest req) =>
|
||||||
|
Ok(new { updated = await svc.UpdateSceneContentsAsync(id, UserId, req.Items ?? new List<UpdateContentItem>()) });
|
||||||
|
|
||||||
/// <summary>Internal endpoint: get project for render service (no user-ownership check).</summary>
|
/// <summary>Internal endpoint: get project for render service (no user-ownership check).</summary>
|
||||||
[HttpGet("{id:guid}/render-payload")]
|
[HttpGet("{id:guid}/render-payload")]
|
||||||
[Authorize(Roles = "Service")]
|
[Authorize(Roles = "Service")]
|
||||||
|
|||||||
@@ -123,3 +123,8 @@ public record SavedProjectListRequest(
|
|||||||
string? Q = null,
|
string? Q = null,
|
||||||
string? Type = null
|
string? Type = null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>Lightweight update of individual scene-input values — the studio editor
|
||||||
|
/// writes the user's edits here (by content key) so the render binder picks them up.</summary>
|
||||||
|
public record UpdateContentsRequest(List<UpdateContentItem> Items);
|
||||||
|
public record UpdateContentItem(string Key, string? Value, Guid? ValueFileId);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { gatewayFetch } from "@/lib/api/gateway";
|
||||||
|
import { getAccessToken } from "@/lib/auth/session";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the studio editor's per-input values to the saved project's content
|
||||||
|
* elements (studio-svc PATCH /v1/saved-projects/{id}/contents). The render binder
|
||||||
|
* reads saved_scene_contents.value, so this is what makes user edits reach the MP4.
|
||||||
|
* Body: { items: [{ key, value, valueFileId? }] }
|
||||||
|
*/
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
ctx: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const { projectId } = await ctx.params;
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => null)) as {
|
||||||
|
items?: Array<{ key?: string; value?: string | null; valueFileId?: string | null }>;
|
||||||
|
} | null;
|
||||||
|
const items = Array.isArray(body?.items)
|
||||||
|
? body!.items.filter((i) => typeof i?.key === "string" && i.key.length > 0)
|
||||||
|
: [];
|
||||||
|
if (items.length === 0) return NextResponse.json({ updated: 0 });
|
||||||
|
|
||||||
|
const res = await gatewayFetch(`/v1/saved-projects/${projectId}/contents`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => null);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: d?.message ?? "Could not save inputs" },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(await res.json().catch(() => ({ updated: items.length })));
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
saveLocalProject,
|
saveLocalProject,
|
||||||
} from "@/lib/dev-project-storage";
|
} from "@/lib/dev-project-storage";
|
||||||
import {
|
import {
|
||||||
|
contentValuesFromSceneData,
|
||||||
fetchProject,
|
fetchProject,
|
||||||
isProjectNotFoundError,
|
isProjectNotFoundError,
|
||||||
|
patchProjectContents,
|
||||||
patchProjectSceneData,
|
patchProjectSceneData,
|
||||||
} from "@/lib/project-api";
|
} from "@/lib/project-api";
|
||||||
import { isDevProjectId } from "@/lib/project-ids";
|
import { isDevProjectId } from "@/lib/project-ids";
|
||||||
@@ -151,6 +153,12 @@ export function useStudioProjectPersistence(
|
|||||||
try {
|
try {
|
||||||
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
|
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||||
await patchProjectSceneData(projectId, scene_data);
|
await patchProjectSceneData(projectId, scene_data);
|
||||||
|
// Persist the per-input values to content elements so the render binder uses
|
||||||
|
// them (best-effort — never block the main save on it).
|
||||||
|
void patchProjectContents(
|
||||||
|
projectId,
|
||||||
|
contentValuesFromSceneData(scene_data)
|
||||||
|
).catch(() => {});
|
||||||
setUsingLocalStorage(false);
|
setUsingLocalStorage(false);
|
||||||
markSaved(payloadJson, false);
|
markSaved(payloadJson, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -72,3 +72,57 @@ export async function patchProjectSceneData(
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContentValueUpdate {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
valueFileId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the user's per-input values to the saved project's content elements so the
|
||||||
|
* render binder uses them. Best-effort (callers may ignore failures).
|
||||||
|
*/
|
||||||
|
export async function patchProjectContents(
|
||||||
|
projectId: string,
|
||||||
|
items: ContentValueUpdate[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/contents`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ProjectApiError("Failed to save inputs", response.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract editable input values from studio scene_data (bridged `c-<key>` layers). */
|
||||||
|
export function contentValuesFromSceneData(
|
||||||
|
sceneData: Record<string, unknown>
|
||||||
|
): ContentValueUpdate[] {
|
||||||
|
const scenes = Array.isArray(sceneData.scenes) ? sceneData.scenes : [];
|
||||||
|
const items: ContentValueUpdate[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const s of scenes) {
|
||||||
|
const layers = s && typeof s === "object" && Array.isArray((s as Record<string, unknown>).layers)
|
||||||
|
? ((s as Record<string, unknown>).layers as unknown[])
|
||||||
|
: [];
|
||||||
|
for (const l of layers) {
|
||||||
|
if (!l || typeof l !== "object") continue;
|
||||||
|
const layer = l as Record<string, unknown>;
|
||||||
|
if (typeof layer.id !== "string" || !layer.id.startsWith("c-")) continue;
|
||||||
|
const key = layer.id.slice(2);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
const props = (layer.props && typeof layer.props === "object" ? layer.props : {}) as Record<string, unknown>;
|
||||||
|
const value =
|
||||||
|
typeof props.text === "string" ? props.text
|
||||||
|
: typeof props.src === "string" ? props.src
|
||||||
|
: "";
|
||||||
|
items.push({ key, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user