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
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:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user