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

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:
soroush.asadi
2026-06-07 00:53:17 +03:30
parent d4b1fbd9e6
commit a69bc62724
6 changed files with 140 additions and 0 deletions
@@ -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 })));
}
+8
View File
@@ -9,8 +9,10 @@ import {
saveLocalProject,
} from "@/lib/dev-project-storage";
import {
contentValuesFromSceneData,
fetchProject,
isProjectNotFoundError,
patchProjectContents,
patchProjectSceneData,
} from "@/lib/project-api";
import { isDevProjectId } from "@/lib/project-ids";
@@ -151,6 +153,12 @@ export function useStudioProjectPersistence(
try {
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
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);
markSaved(payloadJson, false);
} catch (error) {
+54
View File
@@ -72,3 +72,57 @@ export async function patchProjectSceneData(
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;
}