feat: cross-aspect project duplication + AEP convention/rule-engine spec
Build backend images / build content-svc (push) Failing after 1s
Build backend images / build file-svc (push) Failing after 0s
Build backend images / build gateway (push) Failing after 0s
Build backend images / build identity-svc (push) Failing after 0s
Build backend images / build notification-svc (push) Failing after 1s
Build backend images / build render-svc (push) Failing after 2s
Build backend images / build studio-svc (push) Failing after 0s

- content-svc: DuplicateProjectAsync clones full scene/element/colour graph
  (identical keys, new dimensions/aspect; AEP intentionally not copied;
  starts unpublished) + POST /v1/projects/{id}/duplicate.
- admin: «تکثیر» button + modal on each project row; aspects reduced to
  supported 16:9/1:1/9:16; free fps default 21 (clamped 1-60).
- docs/aep-template-convention.md: versioned (v1/v2) convention + rule-engine
  spec — modes, scene types, flatrender assembly, duration/fade model,
  fit-box, input types, expression-driven data flow, output spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 16:59:23 +03:30
parent 1ff6e494c0
commit ee670552a8
9 changed files with 872 additions and 3 deletions
+70 -3
View File
@@ -21,8 +21,12 @@ const lbl = "mb-1 block text-xs text-gray-400";
const RESOLUTIONS = ["HD", "FullHD", "TwoK", "FourK"];
const MODES = ["FIX", "FLEXIBLE", "MockUp", "MusicVisualizer", "VoiceOver"];
const ASPECTS = ["16:9", "9:16", "1:1", "4:5", "21:9"];
const emptyNew = { container_id: "", name: "", width: 1920, height: 1080, aspect: "16:9", resolution: "FullHD", duration: 15, fps: 30, mode: "FLEXIBLE" };
// Supported video aspects only.
const ASPECTS = ["16:9", "1:1", "9:16"];
const ASPECT_DIMS: Record<string, [number, number]> = {
"16:9": [1920, 1080], "1:1": [1080, 1080], "9:16": [1080, 1920],
};
const emptyNew = { container_id: "", name: "", width: 1920, height: 1080, aspect: "16:9", resolution: "FullHD", duration: 15, fps: 21, mode: "FLEXIBLE" };
export function ProjectsAdmin() {
const [rows, setRows] = useState<Proj[]>([]);
@@ -33,6 +37,10 @@ export function ProjectsAdmin() {
const [openAssets, setOpenAssets] = useState<Proj | null>(null);
const [openScenes, setOpenScenes] = useState<Proj | null>(null);
const [aepMsg, setAepMsg] = useState<string | null>(null);
const [dupOf, setDupOf] = useState<Proj | null>(null);
const [dupForm, setDupForm] = useState({ aspect: "1:1", width: 1080, height: 1080, resolution: "FullHD", name: "" });
const [dupBusy, setDupBusy] = useState(false);
const [dupErr, setDupErr] = useState<string | null>(null);
const [containers, setContainers] = useState<{ id: string; name: string }[]>([]);
const [showCreate, setShowCreate] = useState(false);
const [nf, setNf] = useState({ ...emptyNew });
@@ -109,6 +117,30 @@ export function ProjectsAdmin() {
load();
};
const openDuplicate = (p: Proj) => {
const targetAspect = p.aspect === "16:9" ? "1:1" : "16:9";
const [w, h] = ASPECT_DIMS[targetAspect] ?? [1080, 1080];
setDupForm({ aspect: targetAspect, width: w, height: h, resolution: p.resolution || "FullHD", name: "" });
setDupErr(null);
setDupOf(p);
};
const duplicate = async () => {
if (!dupOf) return;
setDupBusy(true); setDupErr(null);
const res = await fetch(`/api/admin/resource/projects/${dupOf.id}/duplicate`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
aspect: dupForm.aspect, original_width: Number(dupForm.width) || null,
original_height: Number(dupForm.height) || null, resolution: dupForm.resolution,
name: dupForm.name || null,
}),
});
const d = await res.json().catch(() => null);
if (res.ok) { setDupOf(null); load(); }
else setDupErr(d?.error ?? d?.message ?? "تکثیر ناموفق بود");
setDupBusy(false);
};
return (
<div className="space-y-4" dir="rtl">
<div className="flex flex-wrap items-end justify-between gap-3">
@@ -154,7 +186,7 @@ export function ProjectsAdmin() {
<div><label className={lbl}>عرض (px)</label><input className={`${inp} w-full`} type="number" dir="ltr" value={nf.width} onChange={(e) => setNf({ ...nf, width: Number(e.target.value) })} /></div>
<div><label className={lbl}>ارتفاع (px)</label><input className={`${inp} w-full`} type="number" dir="ltr" value={nf.height} onChange={(e) => setNf({ ...nf, height: Number(e.target.value) })} /></div>
<div><label className={lbl}>مدت (ثانیه)</label><input className={`${inp} w-full`} type="number" dir="ltr" value={nf.duration} onChange={(e) => setNf({ ...nf, duration: Number(e.target.value) })} /></div>
<div><label className={lbl}>نرخ فریم</label><input className={`${inp} w-full`} type="number" dir="ltr" value={nf.fps} onChange={(e) => setNf({ ...nf, fps: Number(e.target.value) })} /></div>
<div><label className={lbl}>نرخ فریم رایگان (حداکثر ۶۰)</label><input className={`${inp} w-full`} type="number" dir="ltr" min={1} max={60} value={nf.fps} onChange={(e) => setNf({ ...nf, fps: Math.min(60, Math.max(1, Number(e.target.value) || 1)) })} /></div>
<div className="sm:col-span-2">
<label className={lbl}>حالت</label>
<select className={`${inp} w-full`} value={nf.mode} onChange={(e) => setNf({ ...nf, mode: e.target.value })}>
@@ -203,6 +235,7 @@ export function ProjectsAdmin() {
<div className="flex items-center justify-end gap-2">
<button className={ghost} onClick={() => setOpenScenes(p)}>صحنهها</button>
<button className={ghost} onClick={() => { setAepMsg(null); setOpenAssets(p); }}>فایلها</button>
<button className={ghost} onClick={() => openDuplicate(p)}>تکثیر</button>
<button className="rounded-lg border border-red-500/30 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10" onClick={() => remove(p)}>حذف</button>
</div>
</td>
@@ -238,6 +271,40 @@ export function ProjectsAdmin() {
</div>
)}
{dupOf && (
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={() => setDupOf(null)}>
<div className={`${card} flex max-h-full w-full max-w-lg flex-col`} onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
<h2 className="text-sm font-semibold text-white">تکثیر برای ابعاد دیگر {dupOf.name}</h2>
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={() => setDupOf(null)}></button>
</div>
<div className="grid gap-3 p-5 sm:grid-cols-2">
{dupErr && <p className="sm:col-span-2 rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{dupErr}</p>}
<p className="sm:col-span-2 text-xs text-gray-500">همهٔ صحنهها، عناصر و رنگها با همان نامها کپی میشوند؛ فقط ابعاد خروجی تغییر میکند. فایل افترافکتِ این نسخه را بعداً از دکمهٔ «فایلها» آپلود کنید.</p>
<div>
<label className={lbl}>تناسب</label>
<select className={`${inp} w-full`} value={dupForm.aspect} onChange={(e) => { const a = e.target.value; const d = ASPECT_DIMS[a]; setDupForm((f) => ({ ...f, aspect: a, width: d?.[0] ?? f.width, height: d?.[1] ?? f.height })); }}>
{ASPECTS.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
<div>
<label className={lbl}>کیفیت</label>
<select className={`${inp} w-full`} value={dupForm.resolution} onChange={(e) => setDupForm((f) => ({ ...f, resolution: e.target.value }))}>
{RESOLUTIONS.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div><label className={lbl}>عرض (px)</label><input className={`${inp} w-full`} type="number" dir="ltr" value={dupForm.width} onChange={(e) => setDupForm((f) => ({ ...f, width: Number(e.target.value) }))} /></div>
<div><label className={lbl}>ارتفاع (px)</label><input className={`${inp} w-full`} type="number" dir="ltr" value={dupForm.height} onChange={(e) => setDupForm((f) => ({ ...f, height: Number(e.target.value) }))} /></div>
<div className="sm:col-span-2"><label className={lbl}>نام (اختیاری)</label><input className={`${inp} w-full`} value={dupForm.name} onChange={(e) => setDupForm((f) => ({ ...f, name: e.target.value }))} placeholder={`${dupOf.name} (${dupForm.aspect})`} /></div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[#1e2235] px-5 py-3">
<button className={ghost} onClick={() => setDupOf(null)}>انصراف</button>
<button className={btn} onClick={duplicate} disabled={dupBusy}>{dupBusy ? "در حال تکثیر…" : "تکثیر"}</button>
</div>
</div>
</div>
)}
{openScenes && (
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={() => setOpenScenes(null)}>
<div className={`${card} flex max-h-full w-full max-w-4xl flex-col`} onClick={(e) => e.stopPropagation()}>