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
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
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 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<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 () => {
|
||||
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 ? (
|
||||
<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>
|
||||
@@ -192,7 +225,7 @@ function ScenesTab({ projectId, fixedScenes }: { projectId: string; fixedScenes?
|
||||
>
|
||||
ورودیها
|
||||
</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>
|
||||
</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 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<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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -225,8 +291,42 @@ function SceneForm({ draft, setDraft, onSave, onCancel, saving, err, isEdit }: {
|
||||
</div>
|
||||
{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><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._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>
|
||||
@@ -238,7 +338,15 @@ function SceneForm({ draft, setDraft, onSave, onCancel, saving, err, isEdit }: {
|
||||
</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.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>
|
||||
|
||||
Reference in New Issue
Block a user