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