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:
@@ -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