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; +}