diff --git a/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs b/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs
index 608225b..2330422 100644
--- a/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs
+++ b/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs
@@ -230,6 +230,31 @@ public class StudioService(StudioDbContext db)
return await GetProjectAsync(id, userId);
}
+ ///
+ /// 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.
+ ///
+ public async Task UpdateSceneContentsAsync(Guid projectId, Guid userId, List 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)
{
var project = await db.SavedProjects
diff --git a/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs b/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs
index 22eef4c..84bfd68 100644
--- a/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs
+++ b/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs
@@ -55,6 +55,11 @@ public class StudioController(StudioService svc) : ControllerBase
public async Task SaveScenes(Guid id, [FromBody] List scenes) =>
Ok(await svc.SaveScenesAsync(id, UserId, scenes));
+ /// Update individual scene-input values by content key (live studio edits).
+ [HttpPatch("{id:guid}/contents")]
+ public async Task UpdateContents(Guid id, [FromBody] UpdateContentsRequest req) =>
+ Ok(new { updated = await svc.UpdateSceneContentsAsync(id, UserId, req.Items ?? new List()) });
+
/// Internal endpoint: get project for render service (no user-ownership check).
[HttpGet("{id:guid}/render-payload")]
[Authorize(Roles = "Service")]
diff --git a/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs b/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs
index 3fe03c4..bcd86eb 100644
--- a/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs
+++ b/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs
@@ -123,3 +123,8 @@ public record SavedProjectListRequest(
string? Q = null,
string? Type = null
);
+
+/// 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.
+public record UpdateContentsRequest(List Items);
+public record UpdateContentItem(string Key, string? Value, Guid? ValueFileId);
diff --git a/src/app/api/projects/[projectId]/contents/route.ts b/src/app/api/projects/[projectId]/contents/route.ts
new file mode 100644
index 0000000..b168e50
--- /dev/null
+++ b/src/app/api/projects/[projectId]/contents/route.ts
@@ -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 })));
+}
diff --git a/src/hooks/useStudioProjectPersistence.ts b/src/hooks/useStudioProjectPersistence.ts
index 51ee871..6260ea4 100644
--- a/src/hooks/useStudioProjectPersistence.ts
+++ b/src/hooks/useStudioProjectPersistence.ts
@@ -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;
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) {
diff --git a/src/lib/project-api.ts b/src/lib/project-api.ts
index 1dbe0a8..662eb2a 100644
--- a/src/lib/project-api.ts
+++ b/src/lib/project-api.ts
@@ -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 {
+ 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-` layers). */
+export function contentValuesFromSceneData(
+ sceneData: Record
+): ContentValueUpdate[] {
+ const scenes = Array.isArray(sceneData.scenes) ? sceneData.scenes : [];
+ const items: ContentValueUpdate[] = [];
+ const seen = new Set();
+ for (const s of scenes) {
+ const layers = s && typeof s === "object" && Array.isArray((s as Record).layers)
+ ? ((s as Record).layers as unknown[])
+ : [];
+ for (const l of layers) {
+ if (!l || typeof l !== "object") continue;
+ const layer = l as Record;
+ 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;
+ const value =
+ typeof props.text === "string" ? props.text
+ : typeof props.src === "string" ? props.src
+ : "";
+ items.push({ key, value });
+ }
+ }
+ return items;
+}