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
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:
@@ -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()}>
|
||||
|
||||
Reference in New Issue
Block a user