feat(studio): copy template scene graph into editable project (use-template works)
Build backend images / build content-svc (push) Failing after 53s
Build backend images / build file-svc (push) Failing after 51s
Build backend images / build gateway (push) Failing after 1m2s
Build backend images / build identity-svc (push) Failing after 57s
Build backend images / build notification-svc (push) Failing after 45s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 53s

Build now created an EMPTY project: (1) the studio binds camelCase but the frontend
sent snake_case → original_project_id dropped to Guid.Empty; (2) CreateProjectAsync
never copied scenes. Now:
- saved-projects.ts sends camelCase (originalProjectId/copyDefaultValues).
- /api/projects resolves the container slug → first published variant content project.
- StudioService.CreateProjectAsync deep-copies the content scene graph (scenes +
  content elements + scene colours + shared colours) into the new saved project via
  one atomic cross-schema SQL copy (enum cols cast to text; temp scene-id map).

Verified: insta-promo → 1 scene, 6 content fields, 4 shared colours, loadable by the
studio editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-05 09:27:25 +03:30
parent baf6e40dde
commit 0ca11f19dd
3 changed files with 117 additions and 2 deletions
+36 -1
View File
@@ -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<string | null> {
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,
});
+8 -1
View File
@@ -173,9 +173,16 @@ export async function createSavedProject(body: {
preset_story_id?: string;
copy_default_values?: boolean;
}): Promise<StudioResult<V2SavedProjectFull>> {
// 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<V2SavedProjectFull>("/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,
}),
});
}