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
+128 -9
View File
@@ -19,11 +19,24 @@ interface Scene { id: string; key: string; title: string; default_duration_sec?:
type Draft = {
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 {
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); }
@@ -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);
if (!full) { setErr("بارگذاری ویدیوی نمونه ناموفق بود"); return; }
setEditId(s.id);
const spa = parseSpa(full.scenes_spa);
setDraft({
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 })),
});
};
@@ -70,10 +85,11 @@ export function ProjectPresetStories({ projectId }: { projectId: string }) {
if (!draft) return;
if (!draft.name.trim()) { setErr("نام ویدیوی نمونه الزامی است"); return; }
setSaving(true); setErr(null);
const hasFilled = Object.keys(draft.values).length > 0 || Object.keys(draft.colors).length > 0;
const body = {
project_id: projectId, name: draft.name.trim(), description: draft.description || null,
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 })),
};
const res = await fetch(editId ? `${base}/${editId}` : base, {
@@ -147,11 +163,15 @@ export function ProjectPresetStories({ projectId }: { projectId: string }) {
))}
</div>
<details className="rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
<summary className="cursor-pointer text-xs text-gray-400">مقادیر آماده (JSON پیشرفته) اختیاری</summary>
<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":[…]} // مقادیر متن/تصویر از پیش پر شده' />
<p className="mt-1 text-[10px] text-gray-500">حالت پیشرفته: وضعیت کامل صحنهها با مقادیر پر شده. در حال حاضر میتوان از استودیو خروجی گرفت و اینجا چسباند.</p>
</details>
{/* Filled values — what the user's project starts with */}
<PresetValueEditor
projectId={projectId}
sceneIds={draft.scenes.map((s) => s.scene_id).filter(Boolean)}
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">
<button className={ghost} onClick={() => { setDraft(null); setEditId(null); setErr(null); }}>انصراف</button>
@@ -195,3 +215,102 @@ export function ProjectPresetStories({ projectId }: { projectId: string }) {
</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>
);
}