diff --git a/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs b/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs index 62c0cc2..a9839af 100644 --- a/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs +++ b/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs @@ -81,11 +81,84 @@ public class StudioService(StudioDbContext db) ProjectDurationSec = 0, }; + await using var tx = await db.Database.BeginTransactionAsync(); db.SavedProjects.Add(project); await db.SaveChangesAsync(); + + // Deep-copy the template's scene graph (scenes + content/colour elements) from + // the content schema so the new project opens editable in the studio. Same DB, + // so this is one atomic cross-schema copy. + if (req.CopyDefaultValues && req.OriginalProjectId != Guid.Empty) + await CopyTemplateGraphAsync(project.Id, req.OriginalProjectId); + + await tx.CommitAsync(); return await GetProjectAsync(project.Id, userId); } + /// + /// Copies a content template project's scenes, content elements and colour elements + /// into a freshly-created editable project. Runs inside the caller's transaction; + /// the temp scene-id map drops on commit. Same Postgres DB → atomic cross-schema copy. + /// + private async Task CopyTemplateGraphAsync(Guid savedProjectId, Guid originalProjectId) + { + // 1. scenes → capture old→new id mapping in a temp table + await db.Database.ExecuteSqlRawAsync(@" + CREATE TEMP TABLE _scene_map ON COMMIT DROP AS + WITH ins AS ( + INSERT INTO studio.saved_scenes + (saved_project_id, original_scene_id, key, title, image, demo, + scene_color_svg, scene_type, sort, scene_length_sec, min_duration_sec, + max_duration_sec, overlap_at_end_sec, can_handle_duration, + manual_color_selection, created_at, updated_at) + SELECT {0}, s.id, s.key, s.title, s.image, s.demo, s.scene_color_svg, + s.scene_type::text, s.sort, COALESCE(s.default_duration_sec, 0), + s.min_duration_sec, s.max_duration_sec, s.overlap_at_end_sec, + s.can_handle_duration, s.manual_color_selection, now(), now() + FROM content.scenes s + WHERE s.project_id = {1} AND s.deleted_at IS NULL AND s.is_active = true + RETURNING id AS new_id, original_scene_id AS old_id + ) + SELECT new_id, old_id FROM ins;", + savedProjectId, originalProjectId); + + // 2. content elements (skip repeater children for now) + await db.Database.ExecuteSqlRawAsync(@" + INSERT INTO studio.saved_scene_contents + (saved_scene_id, key, title, localized_title, hint, type, value, + font_face, font_face_name, font_size, default_font_size, default_font_face, + justify, position_in_container, direction_layer_value, is_text_box, + ai_input_type, mapped_list, thumbnail, sort, status, created_at, updated_at) + SELECT sm.new_id, ce.key, ce.title, ce.localized_title, ce.hint, ce.type::text, + ce.default_value, ce.font_face, ce.font_face_name, ce.font_size, + ce.default_font_size, ce.default_font_face, ce.justify::text, + ce.position_in_container, ce.direction_layer_value, ce.is_text_box, + ce.ai_input_type::text, ce.mapped_list, ce.thumbnail, ce.sort, 'default', + now(), now() + FROM content.scene_content_elements ce + JOIN _scene_map sm ON sm.old_id = ce.scene_id + WHERE ce.repeater_item_id IS NULL;"); + + // 3. colour elements + await db.Database.ExecuteSqlRawAsync(@" + INSERT INTO studio.saved_scene_colors + (saved_scene_id, element_key, title, icon, attr_value, value, is_selected, sort) + SELECT sm.new_id, ce.element_key, ce.title, ce.icon, ce.attr_value::text, + COALESCE(ce.default_color, ''), false, ce.sort + FROM content.scene_color_elements ce + JOIN _scene_map sm ON sm.old_id = ce.scene_id;"); + + // 4. shared (project-level) colours — frshare frd_* controls + await db.Database.ExecuteSqlRawAsync(@" + INSERT INTO studio.saved_shared_colors + (saved_project_id, element_key, title, icon, attr_value, value, is_selected, sort) + SELECT {0}, sc.element_key, sc.title, sc.icon, sc.attr_value::text, + COALESCE(sc.default_color, ''), false, sc.sort + FROM content.shared_colors sc + WHERE sc.project_id = {1};", + savedProjectId, originalProjectId); + } + public async Task UpdateProjectAsync( Guid id, Guid userId, UpdateSavedProjectRequest req) { diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index bb33022..9a0b1da 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { gatewayFetch } from "@/lib/api/gateway"; import { createSavedProject, listSavedProjects, @@ -10,6 +11,30 @@ import { getAccessToken } from "@/lib/auth/session"; export const dynamic = "force-dynamic"; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * The studio copies from a content PROJECT (a specific aspect variant, UUID). Catalog + * links carry the container SLUG, so resolve slug → first published variant project id. + * A value that is already a UUID is returned unchanged. + */ +async function resolveContentProjectId( + idOrSlug: string, + token: string +): Promise { + if (UUID_RE.test(idOrSlug)) return idOrSlug; + const res = await gatewayFetch(`/v1/templates/${encodeURIComponent(idOrSlug)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return null; + const tpl = (await res.json().catch(() => null)) as { + projects?: Array<{ id?: string; is_published?: boolean }>; + } | null; + const variants = tpl?.projects ?? []; + const chosen = variants.find((p) => p?.id && p.is_published) ?? variants.find((p) => p?.id); + return chosen?.id ?? null; +} + const createProjectSchema = z.object({ name: z.string().min(1).max(120).optional(), type: z.enum(["video", "image", "trimmer"]), @@ -71,8 +96,18 @@ export async function POST(request: Request) { ); } + // Catalog links carry the container slug; the studio needs a content project UUID + // to copy the scene graph from. + const contentProjectId = await resolveContentProjectId(originalProjectId, token); + if (!contentProjectId) { + return NextResponse.json( + { error: "This template has no editable project yet.", code: "NO_VARIANT" }, + { status: 422 } + ); + } + const result = await createSavedProject({ - original_project_id: originalProjectId, + original_project_id: contentProjectId, name: parsed.data.name, copy_default_values: true, }); diff --git a/src/lib/api/saved-projects.ts b/src/lib/api/saved-projects.ts index 77b940f..c0bc551 100644 --- a/src/lib/api/saved-projects.ts +++ b/src/lib/api/saved-projects.ts @@ -173,9 +173,16 @@ export async function createSavedProject(body: { preset_story_id?: string; copy_default_values?: boolean; }): Promise> { + // The studio service binds camelCase JSON (no snake_case naming policy), so a + // snake_case body silently drops original_project_id → Guid.Empty. Send camelCase. return studioFetch("/v1/saved-projects", { method: "POST", - body: JSON.stringify(body), + body: JSON.stringify({ + originalProjectId: body.original_project_id, + name: body.name, + presetStoryId: body.preset_story_id, + copyDefaultValues: body.copy_default_values ?? true, + }), }); }