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