feat(presets): pre-fill the user's project from preset values (A4)
Build backend images / build content-svc (push) Failing after 30s
Build backend images / build file-svc (push) Failing after 30s
Build backend images / build gateway (push) Failing after 31s
Build backend images / build identity-svc (push) Failing after 31s
Build backend images / build notification-svc (push) Failing after 30s
Build backend images / build render-svc (push) Failing after 30s
Build backend images / build studio-svc (push) Failing after 31s

"Use this example" now actually fills the new project, not just stores a ref.

- studio-svc: CreateProjectAsync applies the chosen preset story's saved values
  after the template-graph copy. ApplyPresetValuesAsync reads
  content.preset_stories.scenes_spa = { values: {contentKey:value},
  colors: {elementKey:hex} } and overlays them onto studio.saved_scene_contents
  (by key) + saved_shared_colors/saved_scene_colors (by element_key, is_selected).
  Keys are globally unique (AE convention) so key-only matching is safe.
  Malformed scenes_spa is skipped (defaults kept). Runs in the create tx.
- admin UI: ProjectPresetStories raw scenes_spa textarea replaced with a
  structured PresetValueEditor — loads each preset scene's content elements +
  the project's shared colours and renders a type-aware input per item
  (text/textarea/number, media→upload, fill/color→colour). Serializes to
  scenes_spa {values,colors}; parses it back on edit.

Verified e2e: authored a preset with values+colour → used it → the new
project's saved_scene_contents + saved_shared_colors carry the preset values
(which the B2 render binder then writes into AE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 06:49:22 +03:30
parent ab568c0663
commit 93411da462
2 changed files with 186 additions and 9 deletions
@@ -1,3 +1,4 @@
using System.Text.Json;
using FlatRender.StudioSvc.Domain.Entities; using FlatRender.StudioSvc.Domain.Entities;
using FlatRender.StudioSvc.Domain.Enums; using FlatRender.StudioSvc.Domain.Enums;
using FlatRender.StudioSvc.Infrastructure.Data; using FlatRender.StudioSvc.Infrastructure.Data;
@@ -91,10 +92,67 @@ public class StudioService(StudioDbContext db)
if (req.CopyDefaultValues && req.OriginalProjectId != Guid.Empty) if (req.CopyDefaultValues && req.OriginalProjectId != Guid.Empty)
await CopyTemplateGraphAsync(project.Id, req.OriginalProjectId); await CopyTemplateGraphAsync(project.Id, req.OriginalProjectId);
// Pre-fill from the chosen preset story (premade example video): overlay its
// saved values onto the freshly-copied scene contents + colours.
if (req.PresetStoryId.HasValue)
await ApplyPresetValuesAsync(project.Id, req.PresetStoryId.Value);
await tx.CommitAsync(); await tx.CommitAsync();
return await GetProjectAsync(project.Id, userId); return await GetProjectAsync(project.Id, userId);
} }
/// <summary>
/// Applies an admin-authored preset story's filled values onto a freshly-created
/// project. The preset stores them in content.preset_stories.scenes_spa as
/// <c>{ "values": { contentKey: value }, "colors": { elementKey: hex } }</c>.
/// Content/colour keys are globally unique (AE naming convention), so matching by
/// key alone reaches the right scene element. Runs inside the create transaction.
/// </summary>
private async Task ApplyPresetValuesAsync(Guid savedProjectId, Guid presetStoryId)
{
var spa = await db.Database
.SqlQuery<string?>($@"SELECT scenes_spa AS ""Value"" FROM content.preset_stories
WHERE id = {presetStoryId} AND deleted_at IS NULL")
.FirstOrDefaultAsync();
if (string.IsNullOrWhiteSpace(spa)) return;
JsonElement root;
try { root = JsonDocument.Parse(spa).RootElement; }
catch { return; } // malformed scenes_spa → skip pre-fill, leave defaults
if (root.ValueKind != JsonValueKind.Object) return;
// content element values → saved_scene_contents.value (match by key)
if (root.TryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Object)
{
var valuesJson = vals.GetRawText();
await db.Database.ExecuteSqlInterpolatedAsync($@"
UPDATE studio.saved_scene_contents c
SET value = pv.value, updated_at = now()
FROM json_each_text({valuesJson}::json) AS pv(key, value)
WHERE c.key = pv.key
AND c.saved_scene_id IN (
SELECT id FROM studio.saved_scenes WHERE saved_project_id = {savedProjectId});");
}
// colour values → shared + per-scene colours (match by element_key)
if (root.TryGetProperty("colors", out var cols) && cols.ValueKind == JsonValueKind.Object)
{
var colorsJson = cols.GetRawText();
await db.Database.ExecuteSqlInterpolatedAsync($@"
UPDATE studio.saved_shared_colors sc
SET value = pc.value, is_selected = true
FROM json_each_text({colorsJson}::json) AS pc(key, value)
WHERE sc.element_key = pc.key AND sc.saved_project_id = {savedProjectId};");
await db.Database.ExecuteSqlInterpolatedAsync($@"
UPDATE studio.saved_scene_colors c
SET value = pc.value, is_selected = true
FROM json_each_text({colorsJson}::json) AS pc(key, value)
WHERE c.element_key = pc.key
AND c.saved_scene_id IN (
SELECT id FROM studio.saved_scenes WHERE saved_project_id = {savedProjectId});");
}
}
/// <summary> /// <summary>
/// Copies a content template project's scenes, content elements and colour elements /// Copies a content template project's scenes, content elements and colour elements
/// into a freshly-created editable project. Runs inside the caller's transaction; /// into a freshly-created editable project. Runs inside the caller's transaction;
+128 -9
View File
@@ -19,11 +19,24 @@ interface Scene { id: string; key: string; title: string; default_duration_sec?:
type Draft = { type Draft = {
name: string; description: string; demo: string; is_published: boolean; sort: number; name: string; description: string; demo: string; is_published: boolean; sort: number;
scenes_spa: string; scenes: PresetScene[]; // Filled values the user's project starts with: content-element key → value, and
// colour element key → hex. Serialized into scenes_spa = { values, colors }.
values: Record<string, string>; colors: Record<string, string>; scenes: PresetScene[];
}; };
function emptyDraft(sort: number): Draft { function emptyDraft(sort: number): Draft {
return { name: "", description: "", demo: "", is_published: true, sort, scenes_spa: "", scenes: [] }; return { name: "", description: "", demo: "", is_published: true, sort, values: {}, colors: {}, scenes: [] };
}
function parseSpa(spa?: string | null): { values: Record<string, string>; colors: Record<string, string> } {
if (!spa) return { values: {}, colors: {} };
try {
const o = JSON.parse(spa);
return {
values: o && typeof o.values === "object" && o.values ? o.values : {},
colors: o && typeof o.colors === "object" && o.colors ? o.colors : {},
};
} catch { return { values: {}, colors: {} }; }
} }
function num(v: number | null | undefined) { return v === null || v === undefined ? "" : String(v); } function num(v: number | null | undefined) { return v === null || v === undefined ? "" : String(v); }
@@ -57,9 +70,11 @@ export function ProjectPresetStories({ projectId }: { projectId: string }) {
const full: StoryFull | null = await fetch(`${base}/${s.id}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null); const full: StoryFull | null = await fetch(`${base}/${s.id}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
if (!full) { setErr("بارگذاری ویدیوی نمونه ناموفق بود"); return; } if (!full) { setErr("بارگذاری ویدیوی نمونه ناموفق بود"); return; }
setEditId(s.id); setEditId(s.id);
const spa = parseSpa(full.scenes_spa);
setDraft({ setDraft({
name: full.name, description: full.description ?? "", demo: full.demo ?? "", name: full.name, description: full.description ?? "", demo: full.demo ?? "",
is_published: full.is_published, sort: full.sort, scenes_spa: full.scenes_spa ?? "", is_published: full.is_published, sort: full.sort,
values: spa.values, colors: spa.colors,
scenes: (full.scenes ?? []).map((p) => ({ ...p })), scenes: (full.scenes ?? []).map((p) => ({ ...p })),
}); });
}; };
@@ -70,10 +85,11 @@ export function ProjectPresetStories({ projectId }: { projectId: string }) {
if (!draft) return; if (!draft) return;
if (!draft.name.trim()) { setErr("نام ویدیوی نمونه الزامی است"); return; } if (!draft.name.trim()) { setErr("نام ویدیوی نمونه الزامی است"); return; }
setSaving(true); setErr(null); setSaving(true); setErr(null);
const hasFilled = Object.keys(draft.values).length > 0 || Object.keys(draft.colors).length > 0;
const body = { const body = {
project_id: projectId, name: draft.name.trim(), description: draft.description || null, project_id: projectId, name: draft.name.trim(), description: draft.description || null,
demo: draft.demo || null, is_published: draft.is_published, sort: draft.sort ?? 0, demo: draft.demo || null, is_published: draft.is_published, sort: draft.sort ?? 0,
scenes_spa: draft.scenes_spa || null, scenes_spa: hasFilled ? JSON.stringify({ values: draft.values, colors: draft.colors }) : null,
scenes: draft.scenes.map((s, i) => ({ scene_id: s.scene_id, sort: i, default_duration_sec: s.default_duration_sec ?? null })), scenes: draft.scenes.map((s, i) => ({ scene_id: s.scene_id, sort: i, default_duration_sec: s.default_duration_sec ?? null })),
}; };
const res = await fetch(editId ? `${base}/${editId}` : base, { const res = await fetch(editId ? `${base}/${editId}` : base, {
@@ -147,11 +163,15 @@ export function ProjectPresetStories({ projectId }: { projectId: string }) {
))} ))}
</div> </div>
<details className="rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3"> {/* Filled values — what the user's project starts with */}
<summary className="cursor-pointer text-xs text-gray-400">مقادیر آماده (JSON پیشرفته) اختیاری</summary> <PresetValueEditor
<textarea className={`${inp} mt-2 h-28 w-full font-mono`} dir="ltr" value={draft.scenes_spa} onChange={(e) => set({ scenes_spa: e.target.value })} placeholder='{"scenes":[…]} // مقادیر متن/تصویر از پیش پر شده' /> projectId={projectId}
<p className="mt-1 text-[10px] text-gray-500">حالت پیشرفته: وضعیت کامل صحنهها با مقادیر پر شده. در حال حاضر میتوان از استودیو خروجی گرفت و اینجا چسباند.</p> sceneIds={draft.scenes.map((s) => s.scene_id).filter(Boolean)}
</details> values={draft.values}
colors={draft.colors}
onValue={(k, v) => set({ values: { ...draft.values, [k]: v } })}
onColor={(k, v) => set({ colors: { ...draft.colors, [k]: v } })}
/>
<div className="flex items-center justify-end gap-2 border-t border-[#1e2235] pt-3"> <div className="flex items-center justify-end gap-2 border-t border-[#1e2235] pt-3">
<button className={ghost} onClick={() => { setDraft(null); setEditId(null); setErr(null); }}>انصراف</button> <button className={ghost} onClick={() => { setDraft(null); setEditId(null); setErr(null); }}>انصراف</button>
@@ -195,3 +215,102 @@ export function ProjectPresetStories({ projectId }: { projectId: string }) {
</div> </div>
); );
} }
// ── Filled-value editor ───────────────────────────────────────────────────────
interface ContentEl { id: string; scene_id: string; key: string; title: string; type: string; default_value?: string | null }
interface SharedColorEl { id: string; element_key: string; title: string; default_color?: string | null }
const MEDIA_TYPES = new Set(["media", "audio", "voiceover"]);
const COLOR_TYPES = new Set(["fill", "color"]);
const isHex = (v: string) => /^#[0-9a-fA-F]{6}$/.test(v);
function PresetValueEditor({
projectId, sceneIds, values, colors, onValue, onColor,
}: {
projectId: string; sceneIds: string[];
values: Record<string, string>; colors: Record<string, string>;
onValue: (k: string, v: string) => void; onColor: (k: string, v: string) => void;
}) {
const [groups, setGroups] = useState<{ sceneId: string; elements: ContentEl[] }[]>([]);
const [shared, setShared] = useState<SharedColorEl[]>([]);
const [loading, setLoading] = useState(false);
const sceneKey = sceneIds.join(",");
useEffect(() => {
let cancelled = false;
if (sceneIds.length === 0) { setGroups([]); return; }
setLoading(true);
Promise.all(sceneIds.map((sid) =>
fetch(`/api/admin/resource/scene-elements?scene_id=${sid}`, { cache: "no-store" })
.then((r) => r.json())
.then((d) => ({ sceneId: sid, elements: (Array.isArray(d) ? d : d?.items ?? []) as ContentEl[] }))
.catch(() => ({ sceneId: sid, elements: [] as ContentEl[] }))
)).then((res) => { if (!cancelled) setGroups(res); }).finally(() => { if (!cancelled) setLoading(false); });
fetch(`/api/admin/resource/shared-colors?project_id=${projectId}`, { cache: "no-store" })
.then((r) => r.json())
.then((d) => { if (!cancelled) setShared(Array.isArray(d) ? d : d?.data ?? []); })
.catch(() => {});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sceneKey, projectId]);
const renderInput = (el: ContentEl) => {
const v = values[el.key] ?? "";
const t = el.type.toLowerCase();
if (MEDIA_TYPES.has(t)) return <FileUploadField value={v} onChange={(u) => onValue(el.key, u)} accept={t === "media" ? "image/*,video/*" : "audio/*"} />;
if (COLOR_TYPES.has(t)) return (
<div className="flex items-center gap-2">
<input type="color" className="h-9 w-12 rounded border border-[#262b40] bg-[#0c0e1a]" value={isHex(v) ? v : "#000000"} onChange={(e) => onValue(el.key, e.target.value)} />
<input className={`${inp} flex-1`} dir="ltr" value={v} onChange={(e) => onValue(el.key, e.target.value)} placeholder="#RRGGBB" />
</div>
);
if (t === "textarea") return <textarea className={`${inp} w-full`} value={v} onChange={(e) => onValue(el.key, e.target.value)} rows={2} />;
return <input className={`${inp} w-full`} type={t === "number" ? "number" : "text"} value={v} onChange={(e) => onValue(el.key, e.target.value)} placeholder={el.default_value ?? ""} />;
};
return (
<div className="space-y-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
<p className="text-xs font-medium text-gray-300">مقادیر آماده پروژهٔ کاربر با این مقادیر شروع میشود</p>
{sceneIds.length === 0 ? (
<p className="text-[11px] text-gray-600">ابتدا صحنهها را بالا اضافه کنید تا ورودیها اینجا ظاهر شوند.</p>
) : loading ? (
<p className="text-[11px] text-gray-500">در حال بارگذاری ورودیها</p>
) : (
<div className="space-y-3">
{groups.map((g, i) => (
<div key={g.sceneId} className="space-y-1.5">
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">صحنه {i + 1}</p>
{g.elements.length === 0 ? (
<p className="text-[11px] text-gray-600">ورودیای ندارد.</p>
) : g.elements.map((el) => (
<div key={el.id} className="grid grid-cols-[110px_1fr] items-center gap-2">
<label className="truncate text-[11px] text-gray-400" title={el.key}>{el.title}</label>
{renderInput(el)}
</div>
))}
</div>
))}
{shared.length > 0 && (
<div className="space-y-1.5">
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500">رنگهای مشترک</p>
{shared.map((c) => {
const v = colors[c.element_key] ?? "";
const swatch = isHex(v) ? v : (c.default_color && isHex(c.default_color) ? c.default_color : "#000000");
return (
<div key={c.id} className="grid grid-cols-[110px_1fr] items-center gap-2">
<label className="truncate text-[11px] text-gray-400" title={c.element_key}>{c.title}</label>
<div className="flex items-center gap-2">
<input type="color" className="h-9 w-12 rounded border border-[#262b40] bg-[#0c0e1a]" value={swatch} onChange={(e) => onColor(c.element_key, e.target.value)} />
<input className={`${inp} flex-1`} dir="ltr" value={v} onChange={(e) => onColor(c.element_key, e.target.value)} placeholder={c.default_color ?? "#RRGGBB"} />
</div>
</div>
);
})}
</div>
)}
</div>
)}
<p className="text-[10px] text-gray-500">خالی بماند = مقدار پیشفرض قالب. مقداردهی = پروژهٔ کاربر از همینجا پر میشود.</p>
</div>
);
}