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
+113 -5
View File
@@ -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>