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 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:
@@ -81,11 +81,84 @@ public class StudioService(StudioDbContext db)
|
|||||||
ProjectDurationSec = 0,
|
ProjectDurationSec = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
db.SavedProjects.Add(project);
|
db.SavedProjects.Add(project);
|
||||||
await db.SaveChangesAsync();
|
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);
|
return await GetProjectAsync(project.Id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<SavedProjectFullResponse> UpdateProjectAsync(
|
public async Task<SavedProjectFullResponse> UpdateProjectAsync(
|
||||||
Guid id, Guid userId, UpdateSavedProjectRequest req)
|
Guid id, Guid userId, UpdateSavedProjectRequest req)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { gatewayFetch } from "@/lib/api/gateway";
|
||||||
import {
|
import {
|
||||||
createSavedProject,
|
createSavedProject,
|
||||||
listSavedProjects,
|
listSavedProjects,
|
||||||
@@ -10,6 +11,30 @@ import { getAccessToken } from "@/lib/auth/session";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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({
|
const createProjectSchema = z.object({
|
||||||
name: z.string().min(1).max(120).optional(),
|
name: z.string().min(1).max(120).optional(),
|
||||||
type: z.enum(["video", "image", "trimmer"]),
|
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({
|
const result = await createSavedProject({
|
||||||
original_project_id: originalProjectId,
|
original_project_id: contentProjectId,
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
copy_default_values: true,
|
copy_default_values: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -173,9 +173,16 @@ export async function createSavedProject(body: {
|
|||||||
preset_story_id?: string;
|
preset_story_id?: string;
|
||||||
copy_default_values?: boolean;
|
copy_default_values?: boolean;
|
||||||
}): Promise<StudioResult<V2SavedProjectFull>> {
|
}): 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", {
|
return studioFetch<V2SavedProjectFull>("/v1/saved-projects", {
|
||||||
method: "POST",
|
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,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user