From 23624f7db9fc96762ddb9bd2e956ef7c5ec6d429 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sun, 7 Jun 2026 22:22:39 +0330 Subject: [PATCH] feat(admin): auto-fill new scene length from the AEP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When adding a scene in the admin scene editor, its duration is now pulled from the After Effects project automatically (scene key = comp name). frontend (ProjectScenes): - the new/edit scene form quick-scans the project .aep for comp names + durations and offers a "pick composition" dropdown that fills key, title and default duration in one click - the key field gains a datalist of comp names; typing a key that matches a comp auto-fills the length (only when empty, never clobbering a manual value) - an inline "AEP duration: Ns — insert" hint next to the duration field - graceful states when no .aep is uploaded / scan fails render-svc (aep.durationFromCdta): fix the composition-duration offset. The duration rational lives at cdta offset 44 (numerator) / 48 (time base) on AE 2024/2026, not 32/36 (previous guess) or 40/44 (boltframe reference, older builds). Made it version-robust: read the time base from the framerate dividend (offsets 4/8) and accept whichever offset places the time base right after the numerator. Verified against a real project — render comp frfinal parses to 15.02s (matches project_duration_sec 15.00). Co-Authored-By: Claude Opus 4.8 --- services/render/internal/aep/parse.go | 51 +++++++---- src/components/admin/ProjectScenes.tsx | 118 +++++++++++++++++++++++-- 2 files changed, 147 insertions(+), 22 deletions(-) diff --git a/services/render/internal/aep/parse.go b/services/render/internal/aep/parse.go index 8c5de0a..b606fb0 100644 --- a/services/render/internal/aep/parse.go +++ b/services/render/internal/aep/parse.go @@ -181,27 +181,44 @@ func clean(b []byte) string { return strings.TrimSpace(strings.TrimRight(string(b), "\x00")) } -// durationFromCdta makes a best-effort attempt to read the comp duration from the -// cdta chunk. The cdta layout varies by AE version; we read the frame-duration / -// time-scale pair at well-known offsets and fall back to 0 (unknown) on any doubt. -// Returning 0 is safe — the importer treats it as "leave duration unset". +// durationFromCdta reads the comp duration (in seconds) from the cdta chunk. +// +// cdta layout (verified empirically against AE 2024/2026 project files): +// +// off 4 u32 framerate divisor +// off 8 u32 framerate dividend == comp time base (ticks/sec) +// off 44 u32 duration numerator (in time-base ticks) +// off 48 u32 duration divisor (== time base) +// +// duration_seconds = numerator / time_base. The duration's byte offset shifts by +// AE version (44 on recent builds, 40 on older ones documented by the boltframe +// reference parser), so rather than hard-code one offset we accept whichever +// places the time base immediately *after* the numerator — that self-selects the +// correct field and rejects garbage. Returns 0 (unknown) on any doubt; the +// importer treats 0 as "leave duration unset". func durationFromCdta(cdta []byte) float64 { - // cdta encodes time as rational values. Two uint32 BE commonly hold the comp - // duration (in frames at the comp's time scale) and the time scale (fps base). - // Offsets 0x20 (duration) and 0x24 (scale) are the most consistent across - // recent versions; guard heavily and bail to 0 if the numbers look invalid. - if len(cdta) < 0x28 { + if len(cdta) < 52 { return 0 } - durFrames := binary.BigEndian.Uint32(cdta[0x20:0x24]) - scale := binary.BigEndian.Uint32(cdta[0x24:0x28]) - if scale == 0 || durFrames == 0 || scale > 100000 || durFrames > 100000000 { + frDivisor := binary.BigEndian.Uint32(cdta[4:8]) + timeBase := binary.BigEndian.Uint32(cdta[8:12]) // framerate dividend + if frDivisor == 0 || timeBase == 0 { return 0 } - sec := float64(durFrames) / float64(scale) - if sec <= 0 || sec > 36000 { // > 10h is nonsense → treat as unknown - return 0 + if fps := float64(timeBase) / float64(frDivisor); fps < 1 || fps > 240 { + return 0 // not a comp cdta we recognise } - // round to 2 dp - return float64(int(sec*100+0.5)) / 100 + for _, off := range []int{44, 40} { // recent layout first, then older + num := binary.BigEndian.Uint32(cdta[off : off+4]) + div := binary.BigEndian.Uint32(cdta[off+4 : off+8]) + if num == 0 || div != timeBase { + continue // divisor must equal the time base to be the duration field + } + sec := float64(num) / float64(timeBase) + if sec <= 0 || sec > 36000 { // > 10h is nonsense + continue + } + return float64(int(sec*100+0.5)) / 100 // round to 2 dp + } + return 0 } diff --git a/src/components/admin/ProjectScenes.tsx b/src/components/admin/ProjectScenes.tsx index fccad8c..ed968bc 100644 --- a/src/components/admin/ProjectScenes.tsx +++ b/src/components/admin/ProjectScenes.tsx @@ -27,6 +27,9 @@ interface SharedColor { } interface PresetItem { id?: string; element_key: string; value: string; sort: number } interface Preset { id: string; project_id: string; name?: string | null; sort: number; items: PresetItem[] } +// A composition discovered in the project's .aep (quick scan): name + duration. +interface AepComp { key: string; title: string; default_duration_sec: number | null } +type CompsState = "idle" | "loading" | "loaded" | "none" | "error"; const SCENE_TYPES = [ { v: "Normal", l: "معمولی" }, @@ -102,6 +105,10 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes? const [err, setErr] = useState(null); const [scanOpen, setScanOpen] = useState(false); const [inputsFor, setInputsFor] = useState(null); + // AEP compositions (key + duration) read from the uploaded .aep — used to + // auto-fill a new scene's length from After Effects. + const [aepComps, setAepComps] = useState([]); + const [compsState, setCompsState] = useState("idle"); const base = "/api/admin/resource/scenes"; const load = useCallback(async () => { @@ -112,6 +119,31 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes? }, [projectId]); useEffect(() => { load(); }, [load]); + // Quick-scan the project's .aep (headless Go parser) for comp names + durations. + const loadComps = useCallback(async () => { + setCompsState("loading"); + try { + const r = await fetch(`/api/admin/resource/template-scans/${projectId}/quick`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", + }); + if (!r.ok) { setAepComps([]); setCompsState("none"); return; } + const d = await r.json().catch(() => null); + const scenes: unknown[] = Array.isArray(d?.scenes) ? d.scenes : []; + const comps: AepComp[] = scenes + .map((s) => s as Record) + .filter((s) => typeof s.key === "string" && s.key) + .map((s) => ({ + key: String(s.key), + title: typeof s.title === "string" && s.title ? String(s.title) : String(s.key), + default_duration_sec: typeof s.default_duration_sec === "number" ? s.default_duration_sec : null, + })); + setAepComps(comps); + setCompsState(comps.length ? "loaded" : "none"); + } catch { + setAepComps([]); setCompsState("error"); + } + }, [projectId]); + const save = async () => { if (!draft) return; if (!draft.key.trim() || !draft.title.trim()) { setErr("کلید و عنوان صحنه الزامی است"); return; } @@ -147,6 +179,7 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes? draft={draft} setDraft={setDraft} saving={saving} err={err} onCancel={() => { setDraft(null); setEditId(null); setErr(null); }} onSave={save} isEdit={!!editId} + aepComps={aepComps} compsState={compsState} onLoadComps={loadComps} /> ); } @@ -160,7 +193,7 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes? {fixedScenes ? ( صحنه‌ها از روی پروژهٔ افترافکت تعریف می‌شوند (پروژهٔ Fix) ) : ( - + )} @@ -192,7 +225,7 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes? > ورودی‌ها - + @@ -212,11 +245,44 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes? function num(v: number | null | undefined) { return v === null || v === undefined ? "" : String(v); } function toNum(v: string): number | null { return v.trim() === "" ? null : Number(v); } -function SceneForm({ draft, setDraft, onSave, onCancel, saving, err, isEdit }: { +function SceneForm({ draft, setDraft, onSave, onCancel, saving, err, isEdit, aepComps, compsState, onLoadComps }: { draft: SceneDraft; setDraft: (d: SceneDraft) => void; onSave: () => void; onCancel: () => void; saving: boolean; err: string | null; isEdit: boolean; + aepComps: AepComp[]; compsState: CompsState; onLoadComps: () => void; }) { const set = (p: Partial) => setDraft({ ...draft, ...p }); + + // Typing the key: if it exactly matches an AEP comp, auto-fill the length + // (only when the duration is still empty, so a manual value is never clobbered) + // and the title when blank. + const onKeyChange = (k: string) => { + const c = aepComps.find((x) => x.key.toLowerCase() === k.trim().toLowerCase()); + if (!isEdit && c) { + set({ + key: k, + title: draft.title.trim() ? draft.title : c.title, + default_duration_sec: draft.default_duration_sec == null ? c.default_duration_sec : draft.default_duration_sec, + }); + } else { + set({ key: k }); + } + }; + + // Explicit pick from the comp dropdown: always fill key/title/length from AEP. + const pickComp = (key: string) => { + const c = aepComps.find((x) => x.key === key); + if (!c) return; + set({ + key: c.key, + title: draft.title.trim() ? draft.title : c.title, + default_duration_sec: c.default_duration_sec ?? draft.default_duration_sec, + max_duration_sec: draft.max_duration_sec ?? c.default_duration_sec ?? null, + }); + }; + + // The AEP comp whose name matches the current key (for the inline duration hint). + const matched = aepComps.find((x) => x.key.trim().toLowerCase() === draft.key.trim().toLowerCase()) ?? null; + return (
@@ -225,8 +291,42 @@ function SceneForm({ draft, setDraft, onSave, onCancel, saving, err, isEdit }: {
{err &&

{err}

} + {!isEdit && ( +
+
+ + +
+ {compsState === "loaded" && aepComps.length > 0 ? ( + + ) : compsState === "loading" ? ( +

در حال خواندن کامپوزیشن‌ها از فایل AEP…

+ ) : compsState === "none" ? ( +

کامپوزیشنی خوانده نشد. برای پر شدن خودکار مدت، ابتدا فایل AEP پروژه را از بخش «فایل‌ها» آپلود کنید.

+ ) : compsState === "error" ? ( +

خطا در خواندن فایل AEP.

+ ) : ( +

در حال آماده‌سازی…

+ )} +

کلید صحنه با نام کامپوزیشن یکی است؛ هنگام تایپ کلید نیز مدت به‌صورت خودکار از AEP پر می‌شود.

+
+ )} +
-
set({ key: e.target.value })} placeholder="scene_intro" />
+
+ + onKeyChange(e.target.value)} placeholder="scene_intro" /> + {aepComps.map((c) => +
set({ title: e.target.value })} />
set({ _fa: e.target.value })} />
set({ _en: e.target.value })} />
@@ -238,7 +338,15 @@ function SceneForm({ draft, setDraft, onSave, onCancel, saving, err, isEdit }: {
set({ sort: Number(e.target.value) || 0 })} />
-
set({ default_duration_sec: toNum(e.target.value) })} />
+
+ + set({ default_duration_sec: toNum(e.target.value) })} /> + {matched && matched.default_duration_sec != null && matched.default_duration_sec !== draft.default_duration_sec && ( + + )} +
set({ overlap_at_end_sec: Number(e.target.value) || 0 })} />
set({ min_duration_sec: toNum(e.target.value) })} />
set({ max_duration_sec: toNum(e.target.value) })} />