feat(snapshots): AE scene-snapshot pipeline + admin trigger (Epic C, C1)
Build backend images / build content-svc (push) Failing after 31s
Build backend images / build file-svc (push) Failing after 30s
Build backend images / build gateway (push) Failing after 30s
Build backend images / build identity-svc (push) Failing after 30s
Build backend images / build notification-svc (push) Failing after 31s
Build backend images / build render-svc (push) Failing after 31s
Build backend images / build studio-svc (push) Failing after 31s

Per-scene preview thumbnails for templates. Admin clicks "ساخت پیش‌نمایش
صحنه‌ها" → one single-frame AE render per scene → content.scenes.snapshot_url
→ shown as a thumbnail in the admin scene list (and available to the studio).

- migration 30_render_snapshot_jobs.sql: render.snapshot_jobs (queued|running|
  done|error, per scene, image_url).
- render-svc: db/snapshotjobs.go (EnqueueSceneSnapshots, List, Claim, SetResult
  -> writes content.scenes.snapshot_url cross-schema, SetError); handlers/
  snapshotjobs.go (admin POST/GET /v1/scene-snapshots/:project_id + node-facing
  internal claim/result/fail); main.go routes; gateway route.
- devworker: RunSnapshots — fulfils snapshot jobs with a generated placeholder
  PNG (data: URL, scene-key-tinted) so the flow is verifiable without an AE node.
  Gated by RENDER_DEV_SNAPSHOTS (default off; never hijacks real render jobs).
- admin UI: ProjectScenes "generate snapshots" button (enqueue + poll + reload)
  and a thumbnail (snapshot_url || image) per scene row.

Verified e2e via the dev mock: enqueue -> jobs run -> content.scenes.snapshot_url
populated -> scenes API returns it -> admin renders the thumbnail.

Remaining (C2): node-agent real-AE runner — claim snapshot, aerender -s0 -e0 ->
ffmpeg still -> upload to a PERMANENT URL (mirror file-svc, not the time-limited
export presign) -> post result. Needs a live AE node to build + verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-11 09:54:42 +03:30
parent 93411da462
commit 8488acb115
8 changed files with 406 additions and 0 deletions
+45
View File
@@ -109,6 +109,8 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes?
// auto-fill a new scene's length from After Effects.
const [aepComps, setAepComps] = useState<AepComp[]>([]);
const [compsState, setCompsState] = useState<CompsState>("idle");
const [snapMsg, setSnapMsg] = useState<string | null>(null);
const [snapBusy, setSnapBusy] = useState(false);
const base = "/api/admin/resource/scenes";
const load = useCallback(async () => {
@@ -119,6 +121,40 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes?
}, [projectId]);
useEffect(() => { load(); }, [load]);
// Queue per-scene AE single-frame snapshots, poll until the jobs settle, then
// reload scenes to pick up the new snapshot_url thumbnails.
const generateSnapshots = useCallback(async () => {
setSnapBusy(true); setSnapMsg(null); setErr(null);
const sbase = `/api/admin/resource/scene-snapshots/${projectId}`;
try {
const r = await fetch(sbase, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
const d = await r.json().catch(() => null);
if (!r.ok) { setErr(d?.error ?? "شروع ساخت پیش‌نمایش ناموفق بود"); setSnapBusy(false); return; }
const enqueued = d?.enqueued ?? 0;
if (enqueued === 0) { setSnapMsg("صحنه‌ای برای پیش‌نمایش وجود ندارد."); setSnapBusy(false); return; }
setSnapMsg(`در حال ساخت ${enqueued} پیش‌نمایش از افترافکت…`);
for (let i = 0; i < 60; i++) {
await new Promise((res) => setTimeout(res, 2000));
const jr = await fetch(sbase, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
const jobs: { status: string }[] = jr?.jobs ?? [];
const pending = jobs.filter((j) => j.status === "queued" || j.status === "running").length;
const errored = jobs.filter((j) => j.status === "error").length;
if (pending === 0) {
await load();
setSnapMsg(errored > 0 ? `پیش‌نمایش‌ها آماده شد (${errored} خطا).` : "پیش‌نمایش همهٔ صحنه‌ها ساخته شد.");
setSnapBusy(false);
return;
}
setSnapMsg(`در حال ساخت پیش‌نمایش… (${jobs.length - pending}/${jobs.length})`);
}
setSnapMsg("ساخت پیش‌نمایش بیش از حد طول کشید — بعداً بررسی کنید.");
} catch {
setErr("خطا در ساخت پیش‌نمایش");
} finally {
setSnapBusy(false);
}
}, [projectId, load]);
// Quick-scan the project's .aep (headless Go parser) for comp names + durations.
const loadComps = useCallback(async () => {
setCompsState("loading");
@@ -189,6 +225,9 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes?
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-500">صحنهها بلوکهای قابلویرایش این قالب هستند. کلید هر صحنه باید با نام کامپوزیشن افترافکت یکی باشد.</p>
<div className="flex shrink-0 items-center gap-2">
<button className="rounded-lg border border-emerald-500/40 px-3 py-2 text-sm text-emerald-300 hover:bg-emerald-600/10 disabled:opacity-50" onClick={generateSnapshots} disabled={snapBusy}>
{snapBusy ? "در حال ساخت…" : "ساخت پیش‌نمایش صحنه‌ها"}
</button>
<button className="rounded-lg border border-indigo-500/40 px-3 py-2 text-sm text-indigo-300 hover:bg-indigo-600/10" onClick={() => setScanOpen(true)}>اسکن از افترافکت</button>
{fixedScenes ? (
<span className="rounded-lg border border-[#262b40] px-3 py-2 text-xs text-gray-500">صحنهها از روی پروژهٔ افترافکت تعریف میشوند (پروژهٔ Fix)</span>
@@ -197,6 +236,8 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes?
)}
</div>
</div>
{snapMsg && <p className="rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-300">{snapMsg}</p>}
{err && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-300">{err}</p>}
{scanOpen && (
<ProjectScanImport projectId={projectId} onClose={() => setScanOpen(false)} onApplied={load} />
)}
@@ -210,6 +251,10 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes?
<li key={s.id} className="rounded-lg border border-[#1e2235] bg-[#0c0e1a]">
<div className="flex items-center justify-between px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
{(s.snapshot_url || s.image)
// eslint-disable-next-line @next/next/no-img-element
? <img src={(s.snapshot_url || s.image) as string} alt="" className="h-8 w-12 shrink-0 rounded object-cover" />
: <span className="grid h-8 w-12 shrink-0 place-items-center rounded bg-[#161a2e] text-[9px] text-gray-600"></span>}
<span className="text-[10px] text-gray-600">#{s.sort}</span>
<span className="truncate text-sm text-gray-200">{s.title}</span>
<code className="truncate rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-indigo-300" dir="ltr">{s.key}</code>