feat(admin): auto-fill new scene length from the AEP
Build backend images / build content-svc (push) Failing after 2m22s
Build backend images / build file-svc (push) Failing after 1m49s
Build backend images / build gateway (push) Failing after 1m6s
Build backend images / build identity-svc (push) Failing after 59s
Build backend images / build notification-svc (push) Failing after 50s
Build backend images / build render-svc (push) Failing after 54s
Build backend images / build studio-svc (push) Failing after 55s

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 22:22:39 +03:30
parent da3f92fbe8
commit 23624f7db9
2 changed files with 147 additions and 22 deletions
+34 -17
View File
@@ -181,27 +181,44 @@ func clean(b []byte) string {
return strings.TrimSpace(strings.TrimRight(string(b), "\x00")) return strings.TrimSpace(strings.TrimRight(string(b), "\x00"))
} }
// durationFromCdta makes a best-effort attempt to read the comp duration from the // durationFromCdta reads the comp duration (in seconds) from the cdta chunk.
// 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. // cdta layout (verified empirically against AE 2024/2026 project files):
// Returning 0 is safe — the importer treats it as "leave duration unset". //
// 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 { func durationFromCdta(cdta []byte) float64 {
// cdta encodes time as rational values. Two uint32 BE commonly hold the comp if len(cdta) < 52 {
// 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 {
return 0 return 0
} }
durFrames := binary.BigEndian.Uint32(cdta[0x20:0x24]) frDivisor := binary.BigEndian.Uint32(cdta[4:8])
scale := binary.BigEndian.Uint32(cdta[0x24:0x28]) timeBase := binary.BigEndian.Uint32(cdta[8:12]) // framerate dividend
if scale == 0 || durFrames == 0 || scale > 100000 || durFrames > 100000000 { if frDivisor == 0 || timeBase == 0 {
return 0 return 0
} }
sec := float64(durFrames) / float64(scale) if fps := float64(timeBase) / float64(frDivisor); fps < 1 || fps > 240 {
if sec <= 0 || sec > 36000 { // > 10h is nonsense → treat as unknown return 0 // not a comp cdta we recognise
return 0
} }
// round to 2 dp for _, off := range []int{44, 40} { // recent layout first, then older
return float64(int(sec*100+0.5)) / 100 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
} }
+113 -5
View File
@@ -27,6 +27,9 @@ interface SharedColor {
} }
interface PresetItem { id?: string; element_key: string; value: string; sort: number } 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[] } 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 = [ const SCENE_TYPES = [
{ v: "Normal", l: "معمولی" }, { v: "Normal", l: "معمولی" },
@@ -102,6 +105,10 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes?
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [scanOpen, setScanOpen] = useState(false); const [scanOpen, setScanOpen] = useState(false);
const [inputsFor, setInputsFor] = useState<string | null>(null); const [inputsFor, setInputsFor] = useState<string | null>(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<AepComp[]>([]);
const [compsState, setCompsState] = useState<CompsState>("idle");
const base = "/api/admin/resource/scenes"; const base = "/api/admin/resource/scenes";
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -112,6 +119,31 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes?
}, [projectId]); }, [projectId]);
useEffect(() => { load(); }, [load]); 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<string, unknown>)
.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 () => { const save = async () => {
if (!draft) return; if (!draft) return;
if (!draft.key.trim() || !draft.title.trim()) { setErr("کلید و عنوان صحنه الزامی است"); 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} draft={draft} setDraft={setDraft} saving={saving} err={err}
onCancel={() => { setDraft(null); setEditId(null); setErr(null); }} onCancel={() => { setDraft(null); setEditId(null); setErr(null); }}
onSave={save} isEdit={!!editId} onSave={save} isEdit={!!editId}
aepComps={aepComps} compsState={compsState} onLoadComps={loadComps}
/> />
); );
} }
@@ -160,7 +193,7 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes?
{fixedScenes ? ( {fixedScenes ? (
<span className="rounded-lg border border-[#262b40] px-3 py-2 text-xs text-gray-500">صحنهها از روی پروژهٔ افترافکت تعریف میشوند (پروژهٔ Fix)</span> <span className="rounded-lg border border-[#262b40] px-3 py-2 text-xs text-gray-500">صحنهها از روی پروژهٔ افترافکت تعریف میشوند (پروژهٔ Fix)</span>
) : ( ) : (
<button className={btn} onClick={() => { setEditId(null); setDraft(emptyDraft(rows.length)); }}>+ صحنهٔ جدید</button> <button className={btn} onClick={() => { setEditId(null); setDraft(emptyDraft(rows.length)); if (compsState === "idle") loadComps(); }}>+ صحنهٔ جدید</button>
)} )}
</div> </div>
</div> </div>
@@ -192,7 +225,7 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes?
> >
ورودیها ورودیها
</button> </button>
<button className={ghost} onClick={() => { setEditId(s.id); setDraft(sceneToDraft(s)); }}>ویرایش</button> <button className={ghost} onClick={() => { setEditId(s.id); setDraft(sceneToDraft(s)); if (compsState === "idle") loadComps(); }}>ویرایش</button>
<button className={del} onClick={() => remove(s)}>حذف</button> <button className={del} onClick={() => remove(s)}>حذف</button>
</div> </div>
</div> </div>
@@ -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 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 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; draft: SceneDraft; setDraft: (d: SceneDraft) => void; onSave: () => void; onCancel: () => void;
saving: boolean; err: string | null; isEdit: boolean; saving: boolean; err: string | null; isEdit: boolean;
aepComps: AepComp[]; compsState: CompsState; onLoadComps: () => void;
}) { }) {
const set = (p: Partial<SceneDraft>) => setDraft({ ...draft, ...p }); const set = (p: Partial<SceneDraft>) => 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -225,8 +291,42 @@ function SceneForm({ draft, setDraft, onSave, onCancel, saving, err, isEdit }: {
</div> </div>
{err && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>} {err && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>}
{!isEdit && (
<div className="space-y-2 rounded-lg border border-indigo-500/30 bg-indigo-500/5 p-3">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-indigo-200">پر کردن خودکار از کامپوزیشن افترافکت</label>
<button type="button" className={ghost} onClick={onLoadComps} disabled={compsState === "loading"}>
{compsState === "loading" ? "در حال خواندن AEP…" : "↻ خواندن از AEP"}
</button>
</div>
{compsState === "loaded" && aepComps.length > 0 ? (
<select className={`${inp} w-full`} value="" onChange={(e) => pickComp(e.target.value)}>
<option value=""> انتخاب کامپوزیشن (کلید، عنوان و مدت پر میشود) </option>
{aepComps.map((c) => (
<option key={c.key} value={c.key}>
{c.title}{c.default_duration_sec != null ? `${c.default_duration_sec} ثانیه` : " — مدت نامشخص"}
</option>
))}
</select>
) : compsState === "loading" ? (
<p className="text-[11px] text-gray-400">در حال خواندن کامپوزیشنها از فایل AEP</p>
) : compsState === "none" ? (
<p className="text-[11px] text-amber-300/80">کامپوزیشنی خوانده نشد. برای پر شدن خودکار مدت، ابتدا فایل AEP پروژه را از بخش «فایلها» آپلود کنید.</p>
) : compsState === "error" ? (
<p className="text-[11px] text-red-300/80">خطا در خواندن فایل AEP.</p>
) : (
<p className="text-[11px] text-gray-500">در حال آمادهسازی</p>
)}
<p className="text-[10px] text-gray-500">کلید صحنه با نام کامپوزیشن یکی است؛ هنگام تایپ کلید نیز مدت بهصورت خودکار از AEP پر میشود.</p>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div><label className={lbl}>کلید (نام کامپوزیشن AE) *</label><input className={`${inp} w-full`} dir="ltr" value={draft.key} onChange={(e) => set({ key: e.target.value })} placeholder="scene_intro" /></div> <div>
<label className={lbl}>کلید (نام کامپوزیشن AE) *</label>
<input className={`${inp} w-full`} dir="ltr" list="aep-comp-names" value={draft.key} onChange={(e) => onKeyChange(e.target.value)} placeholder="scene_intro" />
<datalist id="aep-comp-names">{aepComps.map((c) => <option key={c.key} value={c.key} />)}</datalist>
</div>
<div><label className={lbl}>عنوان *</label><input className={`${inp} w-full`} value={draft.title} onChange={(e) => set({ title: e.target.value })} /></div> <div><label className={lbl}>عنوان *</label><input className={`${inp} w-full`} value={draft.title} onChange={(e) => set({ title: e.target.value })} /></div>
<div><label className={lbl}>عنوان (فارسی)</label><input className={`${inp} w-full`} value={draft._fa} onChange={(e) => set({ _fa: e.target.value })} /></div> <div><label className={lbl}>عنوان (فارسی)</label><input className={`${inp} w-full`} value={draft._fa} onChange={(e) => set({ _fa: e.target.value })} /></div>
<div><label className={lbl}>عنوان (انگلیسی)</label><input className={`${inp} w-full`} dir="ltr" value={draft._en} onChange={(e) => set({ _en: e.target.value })} /></div> <div><label className={lbl}>عنوان (انگلیسی)</label><input className={`${inp} w-full`} dir="ltr" value={draft._en} onChange={(e) => set({ _en: e.target.value })} /></div>
@@ -238,7 +338,15 @@ function SceneForm({ draft, setDraft, onSave, onCancel, saving, err, isEdit }: {
</div> </div>
<div><label className={lbl}>ترتیب</label><input className={`${inp} w-full`} type="number" dir="ltr" value={num(draft.sort)} onChange={(e) => set({ sort: Number(e.target.value) || 0 })} /></div> <div><label className={lbl}>ترتیب</label><input className={`${inp} w-full`} type="number" dir="ltr" value={num(draft.sort)} onChange={(e) => set({ sort: Number(e.target.value) || 0 })} /></div>
<div><label className={lbl}>مدت پیشفرض (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.default_duration_sec)} onChange={(e) => set({ default_duration_sec: toNum(e.target.value) })} /></div> <div>
<label className={lbl}>مدت پیشفرض (ثانیه)</label>
<input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.default_duration_sec)} onChange={(e) => set({ default_duration_sec: toNum(e.target.value) })} />
{matched && matched.default_duration_sec != null && matched.default_duration_sec !== draft.default_duration_sec && (
<button type="button" className="mt-1 text-[11px] text-indigo-300 hover:underline" onClick={() => set({ default_duration_sec: matched.default_duration_sec })}>
مدت در AEP: {matched.default_duration_sec} ثانیه درج
</button>
)}
</div>
<div><label className={lbl}>همپوشانی پایان (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.overlap_at_end_sec)} onChange={(e) => set({ overlap_at_end_sec: Number(e.target.value) || 0 })} /></div> <div><label className={lbl}>همپوشانی پایان (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.overlap_at_end_sec)} onChange={(e) => set({ overlap_at_end_sec: Number(e.target.value) || 0 })} /></div>
<div><label className={lbl}>حداقل مدت (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.min_duration_sec)} onChange={(e) => set({ min_duration_sec: toNum(e.target.value) })} /></div> <div><label className={lbl}>حداقل مدت (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.min_duration_sec)} onChange={(e) => set({ min_duration_sec: toNum(e.target.value) })} /></div>
<div><label className={lbl}>حداکثر مدت (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.max_duration_sec)} onChange={(e) => set({ max_duration_sec: toNum(e.target.value) })} /></div> <div><label className={lbl}>حداکثر مدت (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.max_duration_sec)} onChange={(e) => set({ max_duration_sec: toNum(e.target.value) })} /></div>