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
+18
View File
@@ -0,0 +1,18 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
},
{
"name": "hokm-dev",
"runtimeExecutable": "cmd",
"runtimeArgs": ["/c", "cd /d D:\\Projects\\hokm && npm run dev"],
"port": 3020,
"autoPort": true
}
]
}
+130
View File
@@ -0,0 +1,130 @@
export const meta = {
name: 'localize-sweep',
description: 'Localize hardcoded English in components to next-intl (fa + en) in parallel',
phases: [{ title: 'Localize', detail: 'one agent per batch of files' }],
}
// `args` is an array of source file paths (relative to repo root) to localize.
// Be robust to args arriving as an array, a JSON-encoded string, or {files:[...]}.
let files = []
if (Array.isArray(args)) {
files = args
} else if (typeof args === 'string') {
try {
const parsed = JSON.parse(args)
if (Array.isArray(parsed)) files = parsed
else if (parsed && Array.isArray(parsed.files)) files = parsed.files
} catch {
/* not JSON */
}
} else if (args && Array.isArray(args.files)) {
files = args.files
}
// Embedded fallback list (wave 1: user-facing, non-translated) so the workflow runs
// even if args delivery fails.
const DEFAULT_FILES = [
"src/components/image-editor/AiRemoveBgModal.tsx","src/components/image-editor/ImageCropControls.tsx","src/components/image-editor/ImageEditorLayout.tsx","src/components/image-editor/ImageEditorRightPanel.tsx","src/components/image-editor/ImageEditorToolbar.tsx","src/components/image-editor/ImageEditorTopBar.tsx","src/components/image-editor/canvas/ImageBaseLayer.tsx","src/components/image-editor/canvas/ImageCropOverlay.tsx","src/components/image-editor/canvas/ImageEditorCanvas.tsx","src/components/image-editor/canvas/ImageEditorLayerNode.tsx","src/components/image-editor/canvas/VignetteOverlay.tsx","src/components/image-editor/panels/AdjustPanel.tsx","src/components/image-editor/panels/FiltersPanel.tsx","src/components/image-editor/panels/LayersPanel.tsx","src/components/studio/AddSceneMenu.tsx","src/components/studio/CanvasEditor.tsx","src/components/studio/DraggableSceneItem.tsx","src/components/studio/ProjectSaveIndicator.tsx","src/components/studio/PropertiesPanel.tsx","src/components/studio/RenderModal.tsx","src/components/studio/SceneBrowserCard.tsx","src/components/studio/SceneBrowserModal.tsx","src/components/studio/SceneItemActions.tsx","src/components/studio/SceneTransitionPicker.tsx","src/components/studio/StudioMobileGate.tsx","src/components/studio/StudioToolbar.tsx","src/components/studio/Timeline.tsx","src/components/studio/ToolbarIconButton.tsx","src/components/studio/canvas/CanvasLayerNode.tsx","src/components/studio/canvas/ImageLayerNode.tsx","src/components/studio/canvas/ShapeLayerNode.tsx","src/components/studio/canvas/TextLayerNode.tsx","src/components/studio/canvas/VideoLayerNode.tsx","src/components/studio/properties/CommonLayerControls.tsx","src/components/studio/properties/ImageLayerProperties.tsx","src/components/studio/properties/PropertyControls.tsx","src/components/studio/properties/ShapeLayerProperties.tsx","src/components/studio/properties/TextLayerProperties.tsx","src/components/studio/sidebar/AudioSidebarContent.tsx","src/components/studio/sidebar/AudioSidebarMusicTab.tsx","src/components/studio/sidebar/AudioSidebarVoiceoverPane.tsx","src/components/studio/sidebar/ColorsCustomTab.tsx","src/components/studio/sidebar/ColorsPalettesTab.tsx","src/components/studio/sidebar/ColorsSidebarContent.tsx","src/components/studio/sidebar/ColorsTemplatePreviewCard.tsx","src/components/studio/sidebar/FontSidebarContent.tsx","src/components/studio/sidebar/SceneEditSidebarContent.tsx","src/components/studio/sidebar/SidebarPanelShell.tsx","src/components/studio/sidebar/TransitionPreviewTile.tsx","src/components/studio/sidebar/TransitionsSidebarContent.tsx","src/components/studio/sidebar/TtsSidebarContent.tsx","src/components/studio/sidebar/WatermarkSidebarContent.tsx","src/components/studio/timeline/AudioTrack.tsx","src/components/studio/timeline/SceneBlock.tsx","src/components/studio/timeline/SceneThumbnailBlock.tsx","src/components/studio/timeline/SceneThumbnailStrip.tsx","src/components/studio/timeline/SceneTrack.tsx","src/components/studio/timeline/TimeRuler.tsx","src/components/studio/timeline/TimelineActionRow.tsx","src/components/studio/timeline/TimelineControlBar.tsx","src/components/studio/timeline/TimelinePlayhead.tsx","src/components/studio/timeline/TimelineQuickActions.tsx","src/components/studio/video/CanvasArea.tsx","src/components/studio/video/ResizableStudioPanel.tsx","src/components/studio/video/StudioSidebarContent.tsx","src/components/studio/video/StudioSidebarDock.tsx","src/components/studio/video/StudioTopBar.tsx","src/components/studio/video/StudioTopBarSaveBadge.tsx","src/components/studio/video/StudioTopBarTextControls.tsx","src/components/studio/video/VideoNewOptionCard.tsx","src/components/studio/video/VideoNewPresetCard.tsx","src/components/studio/video/VideoProjectNewContent.tsx","src/components/studio/video/VideoStudioLayout.tsx"
]
if (files.length === 0) files = DEFAULT_FILES
log(`args kind=${Array.isArray(args) ? 'array' : typeof args}; resolved ${files.length} files`)
// Deterministic, globally-unique sub-namespace per file (under top-level "auto").
function pathKey(p) {
return p
.replace(/^src\//, '')
.replace(/\.tsx?$/, '')
.replace(/\[locale\]/g, '')
.replace(/[^a-zA-Z0-9]+/g, ' ')
.trim()
.split(/\s+/)
.map((w, i) => (i === 0 ? w[0].toLowerCase() + w.slice(1) : w[0].toUpperCase() + w.slice(1)))
.join('')
}
const targets = files.map((p) => ({ path: p, pathKey: pathKey(p) }))
// Batch size (smaller = lower stall risk on complex files).
const BATCH = 2
const batches = []
for (let i = 0; i < targets.length; i += BATCH) batches.push(targets.slice(i, i + BATCH))
log(`Localizing ${targets.length} files across ${batches.length} agents`)
const SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
files: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string' },
status: { type: 'string', enum: ['localized', 'skipped', 'error'] },
pathKey: { type: ['string', 'null'] },
en: { type: ['object', 'null'], additionalProperties: true },
fa: { type: ['object', 'null'], additionalProperties: true },
note: { type: ['string', 'null'] },
},
required: ['path', 'status'],
},
},
},
required: ['files'],
}
function promptFor(batch) {
const list = batch.map((b) => `- ${b.path} (namespace: "auto.${b.pathKey}")`).join('\n')
return `You are localizing a Next.js 14 App Router project (next-intl) to support Persian (fa, default, RTL) and English (en). Your job: move HARDCODED user-facing English strings in the assigned files into next-intl translation calls, and RETURN the translation keys (you do NOT edit any JSON message files).
Assigned files (each with the exact namespace to use):
${list}
For EACH file:
1. Read it. Decide if it contains user-facing copy a person reads (visible JSX text, button labels, headings, placeholder=, title=, aria-label=, alt= with real words, toast/error messages).
- If it has NONE (pure layout/animation/wrapper, only className/props/icons), return status "skipped" for it. Do not edit it.
2. If it HAS copy, rewrite the file in place:
- Detect component type:
* If the file (or its function) is a Client Component (has "use client" at top), import { useTranslations } from "next-intl" and inside the component add: const t = useTranslations("auto.<pathKey>")
* Otherwise it is a Server Component: import { getTranslations } from "next-intl/server", make the component function async if it is not, and add: const t = await getTranslations("auto.<pathKey>") (only if the component body can be async — page/layout/section server components can).
- Replace each hardcoded English string with t("someKey"). Use short, descriptive camelCase keys (e.g. title, subtitle, ctaLabel, emptyState).
- Use the EXACT namespace given for that file (the "auto.<pathKey>" shown above). One namespace per file.
- Do NOT touch: className, CSS, data-* attrs, object keys, URLs/hrefs, console logs, code identifiers, variable/enum values, import paths, numbers, or non-English text.
- Preserve ALL logic, props, JSX structure, and formatting. Keep imports tidy and valid TypeScript.
- If a visible string is interpolated (e.g. \`Welcome \${name}\`), use t with a placeholder: t("welcome", { name }) and define the value as "Welcome {name}".
3. Return, for that file: status "localized", its pathKey, and two objects "en" and "fa" with the SAME keys. "en" = the original English. "fa" = a NATURAL, professional Persian translation suitable for a video/image creation SaaS (not a literal word-for-word gloss; correct Persian). Keys in en and fa MUST match exactly.
Hard rules:
- en and fa must have identical key sets per file.
- Only edit the .tsx files assigned to you. Never edit messages/*.json, next.config, or other files.
- If editing a file would risk breaking it (complex/uncertain), set status "error" with a short note and leave the file unchanged.
- Keep TypeScript valid — the project runs \`tsc --noEmit\`.
Return ONLY the structured object describing every assigned file.`
}
const results = await parallel(
batches.map((batch, i) => () =>
agent(promptFor(batch), {
label: `localize:batch${i + 1}`,
phase: 'Localize',
schema: SCHEMA,
})
)
)
// Flatten all per-file results from every batch.
const all = results.filter(Boolean).flatMap((r) => (r && r.files) || [])
const localized = all.filter((f) => f.status === 'localized' && f.pathKey && f.en && f.fa)
const skipped = all.filter((f) => f.status === 'skipped')
const errored = all.filter((f) => f.status === 'error')
log(`localized=${localized.length} skipped=${skipped.length} error=${errored.length}`)
return {
localized: localized.map((f) => ({ path: f.path, pathKey: f.pathKey, en: f.en, fa: f.fa })),
skipped: skipped.map((f) => f.path),
errored: errored.map((f) => ({ path: f.path, note: f.note || null })),
}
+226
View File
@@ -0,0 +1,226 @@
# FlatRender — AEP Template Convention & Rule Engine
> Single source of truth for how a FlatRender After Effects template is **authored**, **scanned**, and **rendered (bound)**.
> Both the **scanner** (`scan.jsx` + Go quick-scan) and the **render binder** (the JSX generator) must obey this document.
> Conventions are **versioned** (`convention_version` per project) so the rules can evolve without breaking the existing library.
---
## 0. Project ↔ AEP relationship
- **One project = one `.aep` file** (in V2 stored at `templates/{project_id}/template.aep` or `bundle.zip`).
A real project bundle is usually **a footage folder + the `.aep`**, zipped, and extracted on the render node so relative footage resolves.
- **The project's human name never appears in AE.** Comp/layer names are **universal conventions**, not per-project. The project is identified purely by *which file* it owns.
- **AE version:** the farm always runs the **latest After Effects** (a global setting, not per-project) → Master Properties + Media Replacement are always available.
---
## 1. Naming conventions
### Composition names (universal, identical in every project)
| Comp | Role |
|---|---|
| `frfinal` | Final render comp for **Fix** and **MusicVisualizer** (pre-built). |
| `flatrender` | Final render comp for **Flexible** and **Mockup****assembled at render time** by the binder. |
| `frshare` | Holds the **shared colours** (one text layer per colour; layer name = element key, `sourceText` = colour value). |
| `all` | Holds the **shared-layer definitions**. |
### Layer-name prefixes
| Prefix | Meaning |
|---|---|
| `frl_<key>` | Editable **visible** layer (text / media / audio) — what the user fills in. |
| `frd_<key>` | **Data / direction** layer — hidden values, checkbox/dropdown/fill, colour data, RTL companion. |
| `frc_<key>` | **Container comp** used for duplication (scenes, repeat boxes). |
| `frs_<key>` | **Shared-ref** layer inside an `frc_` container. |
| `frd_<key>d` | Direction companion of an `frl_` layer (`0`=LTR, `1`=RTL), produced by `FRDMaker(key)+"d"`. |
### Key normalization (legacy `Helper`)
- `FRLMaker(key)` → if it already contains `frl`/`frd`, keep; else `frl_<key>`.
- `FRDMaker(key)` → strip `frl_`, ensure `frd``frd_<key>`.
### 🔑 The uniqueness invariant
> **No two layers ever share a name.** Every editable field maps to exactly **one** independent value.
- This is why duplication **renames** (see §4): a shallow duplicate would collide names → values *mirror*; renaming keeps names unique → values *independent*.
- Consequence for the **scanner**: just enumerate every unique `frl_`/`frd_` layer and emit one content-element per name — **no repeat-detection needed**.
---
## 2. Project modes (5)
| Mode | Render comp | Timeline source | Duration | Binder generator | Output |
|---|---|---|---|---|---|
| **FIX** | `frfinal` | fixed (template) | `= project_duration_sec` | `CreateV2FixJSX` | video |
| **FLEXIBLE** | `flatrender` (assembled) | user story (Normal scenes, duplicated) | `Σ scene + first overlap`, clamped to `project.max` | `CreateFlexibleJSX` | video |
| **MockUp** | `flatrender` (assembled) | one frame per scene | `Duration = sceneCount` (`FrameRate=1`) | `CreateMockupJSX` | **images (JPG/scene)** |
| **MusicVisualizer** | `frfinal` | fixed (template) | `min(audioLen, project.max)` | `CreateV2FixJSX` | video |
| **VoiceOver** | *(like Fix/MusicViz, timed to a VO track — to confirm)* | fixed | `= VO length`, capped at `project.max` | *tbd* | video |
**Differentiators:** *does the user control the timeline?* (Fix=no, Flexible=yes) · *content-swap vs design-placement* (Fix/Flexible vs Mockup) · *audio-driven?* (MusicViz/VoiceOver).
---
## 2.1 Output spec — aspects · quality · frame rate
**Aspects (video) — only these three:**
| Aspect | Dimensions |
|---|---|
| `16:9` | 1920 × 1080 |
| `1:1` | 1080 × 1080 |
| `9:16` | 1080 × 1920 |
**Render quality tiers (output resolution, chosen per render):** `360p` · `480p` · `720p` · `1080p` · `4K`.
- This is a **render-time** output tier (downscales the comp), distinct from the project's native design resolution.
- The project's `Resolution` acts as the **ceiling**; the user picks a tier ≤ ceiling, also bounded by their plan.
**Free vs paid:**
| | Free | Paid |
|---|---|---|
| Max quality | **360p** | up to project ceiling (4K) |
| Watermark | **yes** | no |
| Frame rate | **21 fps** (default, per-project configurable) | project fps |
**Frame rate:**
- Free renders use **21 fps** by default — stored per project (`free_fps`), **configurable in project settings**.
- **Maximum frame rate = 60** (any mode/tier).
## 3. Scene types & roles
| `scene_type` | User-pickable? | Role in assembly |
|---|---|---|
| `Normal` | ✅ yes | Story scenes — duplicated into the timeline. |
| `Config` | ❌ no | Global shared layers/config — applied across the whole comp (not on the timeline). |
| `DesignStart` | ❌ no | **BottomDesign** — full-duration **background**, bottom of the layer stack. |
| `DesignEnd` | ❌ no | **TopDesign** — full-duration **overlay** (logo/frame), top of the layer stack. |
Design scenes are **authored once in the template** and **injected** at render time (single instance, full duration — no `_d{N}`).
---
## 4. `flatrender` assembly (Flexible / Mockup)
**Layer stack, bottom → top (z-order = depth):**
```
(global) Config → shared layers/colours applied across
bottom (behind) DesignStart → background, full duration [auto · 1×]
middle (time-line) Normal story → sequenced instances, deep-dup _d{N} [user-picked]
top (in front) DesignEnd → overlay, full duration [auto · 1×]
```
**Story sequencing (Normal scenes), in `Sort` order:**
- Each instance = a **deep-duplicated** scene comp renamed **`<sceneKey>_d{N}`** (`N` = 1-based duplication index). Inner `frl_`/`frd_` names stay identical (each clone is its own namespace).
- Instance layer length `= SceneLength + OverlapAtEnd`; placed at the running time offset; consecutive scenes **cross-overlap** by `OverlapAtEnd`.
- Per-instance inputs are an **ordered list of input-sets**, one per instance (`SceneStory(Name=sceneKey) → Duplications[] → Inputs[]`).
---
## 5. Duration model (universal across modes)
**Per scene:** `default_duration_sec`, `min_duration_sec`, `max_duration_sec`, `overlap_at_end_sec`, `can_handle_duration` (may the user change it?).
**Per project:** `project_duration_sec` (default/target), `min_duration_sec`, `max_duration_sec` (**hard output cap**).
**Flexible:**
```
each scene: min ≤ SceneLength ≤ max
total = Σ SceneLength (+ first scene overlap)
clamp = min(total, project.max_duration_sec) ; preview hard-capped at 180s
```
**MusicVisualizer / audio modes:**
```
audioLen ≤ max → duration = audioLen (no min floor; short audio = short video)
audioLen > max → user trims (≤ max, decrease-only) OR untrimmed → max + fade-out
```
- Trimmer window is **capped at `project.max`** (can shrink, never exceed).
- Cap is enforced in **both** frontend (UX) and backend (authority — never trust the client).
**Fade-out (audio modes):** default **1.5 s**, admin-configurable, studio-toggleable.
```
projects.audio_fade_out_sec default 1.5 (admin default duration)
scenes.audio_fade_out_sec nullable override (per-item)
projects.audio_fade_out_enabled default on (admin default state)
projects.is_audio_fade_changeable default true (may the studio user toggle?)
studio: fade_out_enabled user choice (only if changeable)
→ fadeEnabled = is_audio_fade_changeable ? userChoice : project.audio_fade_out_enabled
→ fadeDuration = item.audio_fade_out_sec ?? project.audio_fade_out_sec ?? 1.5
```
*(These four `projects`/`scenes` columns + the studio flag are NOT yet in the schema — added with the audio/binder work.)*
---
## 6. Responsive fit-box (per element)
Each `frl_` element is **its own precomp (a box/region)** with a fixed area; content **auto-fits** inside it.
- **Text:** single-line or multi-line (`TextArea`); auto-scaled down if it overflows (`is_text_box`, `max_size`); wrapped for multi-line.
- **Alignment in box:** `justify` (`LEFT`/`CENTER`/`RIGHT`/`FULL_JUSTIFY`) + `position_in_container` anchors `08` (0 = centre, corners/edges).
- **Media:** scale-to-fit the box, keep aspect, centred.
> This is **why deep-dup is heavy**: every element is a nested comp, so a scene is comps-inside-comps; cloning duplicates the whole tree (exactly what v2 avoids).
---
## 7. Input types (scanner classification targets)
| Input type | `content_element_type` | AE detection |
|---|---|---|
| image input | `Media` | footage/placeholder layer |
| image / video input | `Media` (`video_support`) | footage layer |
| text / multi-text input | `Text` / `TextArea` | text layer (single vs multi-line box) |
| text input | `Text` | text layer |
| number input | `Number` | text layer (numeric) |
| option input (list) | `DropDown` | data layer + `mapped_list` |
| yes / no input | `CheckBox` / `Toggle` | data layer (0/1) |
The detection walks layers **top → bottom**, classifies each editable layer into one of these, and emits scene content-elements in that order.
---
## 8. Expression-driven data flow ⚠️
Visible layers do **not** hold values — they run **AE expressions** that read from a **central source**:
```
binder writes ─► frshare colour layers + frd_ data layers ─► expressions ─► visible frl_ layers
(one write) (propagate automatically)
```
- **The binder writes only the data sources** (a colour once in `frshare`, a value in a `frd_` layer). Expressions fan it out — it never touches each visible layer.
- **The scanner reads the data sources** (`frshare` layers + `frd_` data layers) as the source of truth for colours/values — **not** the expressions.
- Colours live in `frshare`: layer name = element key, `sourceText` = colour value (hex / `r,g,b`).
---
## 9. Convention versioning + the rule engine
`convention_version` is an **admin-set field on the project** (chosen in the add/edit panel, default = latest). The **rule engine** maps a version → the rules the scanner + binder follow. Both versions are supported (**hybrid**) so nothing breaks.
| Aspect | **v1 — legacy (current)** | **v2 — Master Properties (proposed)** |
|---|---|---|
| Per-instance independence | **deep-dup + rename `_d{N}`** | **1 source comp + N layer instances**, each with **Master/Essential-Property overrides** |
| Scan target | layer-name prefixes (`frl_`/`frd_`) | `comp.masterProperties` / Essential Graphics (typed + named) |
| Media per instance | duplicated footage | **Media Replacement** essential property |
| Expressions | AE auto-repoints intra-clone expressions | global colours **stay expression-from-shared**; per-instance values flow from the **instance's master property** into the internal expression/fit |
| Cost | N full comp trees (heavy) | tiny project, faster open/render |
**Shared rules (both versions):** §1 naming · §3 scene-type roles · §4 z-order assembly · §5 duration/overlap/fade · §6 fit-box · §7 input types · §8 expression data flow.
**v2 golden rule:** *global → expression-from-shared; per-instance → (v1: dup+rename · v2: master-prop feeding the expression/fit).*
### ⚠️ v2 is "validate-first"
Expressions are the #1 thing that complicate Master Properties (expression-driven prop vs master-prop override; expression scope across the master-property boundary). **v1 stays the safe default.** Before re-plumbing to v2, **spike on one real template** to confirm promotion + media replacement + expression scope behave. (Defer to the AE author here.)
---
## 10. What this drives in the V2 codebase
- **`projects.convention_version`** (+ admin panel selector) — *to add.*
- **Scanner** (`scan.jsx` / Go quick-scan) — branch on version: v1 reads names, v2 reads Essential Graphics. Emits the canonical `ScanResult` the importer consumes.
- **Render binder** — *not yet built.* Hybrid generator: v1 = deep-dup + rename + bind data sources; v2 = layer instances + master-prop overrides. Per-mode (`CreateV2FixJSX` / `CreateFlexibleJSX` / `CreateMockupJSX` equivalents). This is the port of the legacy `JSXGenerator.cs`.
- **Audio/duration layer** — `projects`/`scenes` fade + duration columns, trimmer-cap-to-max, MusicViz resolution, fade-out. (One migration, one rebuild.)
- **Dynamic scene generation** — the end goal: given business + logic, select/assemble/author scenes against these rules.
---
## 11. Open items to confirm / validate
- [ ] VoiceOver mode mechanics (separate mode vs flavor of Fix/Flexible).
- [ ] Exact `flatrender` timeline offset formula (cumulative `Duration overlap`).
- [ ] v2 Master-Properties spike on a real template (expressions + media replacement + scope).
- [ ] `frfinal` vs `flatrender` — confirm `flatrender` is always the *assembled* comp (built by the binder) for Flexible/Mockup.
+173
View File
@@ -0,0 +1,173 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FlatRender — Logo Concepts</title>
<style>
:root { --ink:#0f172a; --muted:#64748b; }
* { box-sizing: border-box; }
body { margin:0; font-family: -apple-system, "Segoe UI", system-ui, sans-serif; color:var(--ink); background:#f8fafc; }
header { padding:40px 32px 8px; }
h1 { margin:0; font-size:22px; }
p.sub { margin:6px 0 0; color:var(--muted); font-size:14px; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(360px,1fr)); gap:20px; padding:24px 32px 64px; }
.card { background:#fff; border:1px solid #e2e8f0; border-radius:16px; overflow:hidden; box-shadow:0 1px 2px rgba(0,0,0,.04); }
.card h2 { font-size:14px; margin:0; padding:14px 18px; border-bottom:1px solid #f1f5f9; letter-spacing:.02em; }
.card .tag { color:var(--muted); font-weight:400; }
.row { display:flex; align-items:center; gap:20px; padding:22px 18px; }
.row.dark { background:#0f172a; }
.row.dark .wm { color:#fff; }
.swatch { display:flex; flex-direction:column; gap:4px; align-items:center; }
.swatch small { color:var(--muted); font-size:11px; }
.row.dark .swatch small { color:#94a3b8; }
.wm { display:flex; align-items:center; gap:10px; font-size:22px; font-weight:700; letter-spacing:-.02em; }
.wm .fa { font-size:20px; }
.sizes { display:flex; align-items:flex-end; gap:16px; padding:18px; border-top:1px solid #f1f5f9; }
.note { padding:0 18px 16px; color:var(--muted); font-size:12.5px; line-height:1.5; }
</style>
</head>
<body>
<header>
<h1>FlatRender — Logo Concepts</h1>
<p class="sub">Pick a concept (or mix elements). Each shown on light + dark, with wordmark and at favicon sizes (16/24/40px). Tell me the number + any tweaks.</p>
</header>
<div class="grid">
<!-- ───────────── Concept 1: Layered Play ───────────── -->
<div class="card">
<h2>1 — Layered Play <span class="tag">motion frames + video</span></h2>
<div class="row">
<svg width="72" height="72" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-label="c1">
<defs><linearGradient id="g1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6366F1"/><stop offset="100%" stop-color="#2563EB"/>
</linearGradient></defs>
<rect width="48" height="48" rx="12" fill="url(#g1)"/>
<path d="M17 14.5 31 24 17 33.5Z" fill="#fff" fill-opacity=".35"/>
<path d="M20.5 15.5 33 24 20.5 32.5Z" fill="#fff"/>
</svg>
<div class="wm"><span>Flat<span style="color:#2563EB">Render</span></span></div>
</div>
<div class="row dark">
<svg width="56" height="56" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="url(#g1)"/>
<path d="M17 14.5 31 24 17 33.5Z" fill="#fff" fill-opacity=".35"/>
<path d="M20.5 15.5 33 24 20.5 32.5Z" fill="#fff"/>
</svg>
<div class="wm"><span class="fa">فلت‌رندر</span></div>
</div>
<div class="sizes">
<svg width="40" height="40" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="url(#g1)"/><path d="M17 14.5 31 24 17 33.5Z" fill="#fff" fill-opacity=".35"/><path d="M20.5 15.5 33 24 20.5 32.5Z" fill="#fff"/></svg>
<svg width="24" height="24" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="url(#g1)"/><path d="M17 14.5 31 24 17 33.5Z" fill="#fff" fill-opacity=".35"/><path d="M20.5 15.5 33 24 20.5 32.5Z" fill="#fff"/></svg>
<svg width="16" height="16" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="url(#g1)"/><path d="M20.5 15.5 33 24 20.5 32.5Z" fill="#fff"/></svg>
</div>
<p class="note">Two offset play triangles = motion / video frames, with a flat-design depth cue. Reads cleanly even at 16px (front triangle only).</p>
</div>
<!-- ───────────── Concept 2: Render Frame ───────────── -->
<div class="card">
<h2>2 — Render Frame <span class="tag">viewport + play (image & video)</span></h2>
<div class="row">
<svg width="72" height="72" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-label="c2">
<defs><linearGradient id="g2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0EA5E9"/><stop offset="100%" stop-color="#2563EB"/>
</linearGradient></defs>
<rect width="48" height="48" rx="12" fill="url(#g2)"/>
<!-- corner brackets -->
<g stroke="#fff" stroke-width="2.4" stroke-linecap="round" fill="none" stroke-opacity=".9">
<path d="M13 17v-2.5a1.5 1.5 0 0 1 1.5-1.5H17"/>
<path d="M35 17v-2.5a1.5 1.5 0 0 0-1.5-1.5H31"/>
<path d="M13 31v2.5a1.5 1.5 0 0 0 1.5 1.5H17"/>
<path d="M35 31v2.5a1.5 1.5 0 0 1-1.5 1.5H31"/>
</g>
<path d="M20 18.5 30 24 20 29.5Z" fill="#fff"/>
</svg>
<div class="wm"><span>Flat<span style="color:#2563EB">Render</span></span></div>
</div>
<div class="row dark">
<svg width="56" height="56" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="url(#g2)"/>
<g stroke="#fff" stroke-width="2.4" stroke-linecap="round" fill="none" stroke-opacity=".9">
<path d="M13 17v-2.5a1.5 1.5 0 0 1 1.5-1.5H17"/><path d="M35 17v-2.5a1.5 1.5 0 0 0-1.5-1.5H31"/>
<path d="M13 31v2.5a1.5 1.5 0 0 0 1.5 1.5H17"/><path d="M35 31v2.5a1.5 1.5 0 0 1-1.5 1.5H31"/>
</g>
<path d="M20 18.5 30 24 20 29.5Z" fill="#fff"/>
</svg>
<div class="wm"><span class="fa">فلت‌رندر</span></div>
</div>
<div class="sizes">
<svg width="40" height="40" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="url(#g2)"/><g stroke="#fff" stroke-width="2.4" stroke-linecap="round" fill="none" stroke-opacity=".9"><path d="M13 17v-2.5a1.5 1.5 0 0 1 1.5-1.5H17"/><path d="M35 17v-2.5a1.5 1.5 0 0 0-1.5-1.5H31"/><path d="M13 31v2.5a1.5 1.5 0 0 0 1.5 1.5H17"/><path d="M35 31v2.5a1.5 1.5 0 0 1-1.5 1.5H31"/></g><path d="M20 18.5 30 24 20 29.5Z" fill="#fff"/></svg>
<svg width="24" height="24" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="url(#g2)"/><g stroke="#fff" stroke-width="3" stroke-linecap="round" fill="none"><path d="M13 17v-2.5a1.5 1.5 0 0 1 1.5-1.5H17"/><path d="M35 17v-2.5a1.5 1.5 0 0 0-1.5-1.5H31"/><path d="M13 31v2.5a1.5 1.5 0 0 0 1.5 1.5H17"/><path d="M35 31v2.5a1.5 1.5 0 0 1-1.5 1.5H31"/></g><path d="M20 18.5 30 24 20 29.5Z" fill="#fff"/></svg>
<svg width="16" height="16" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="url(#g2)"/><path d="M19 17 31 24 19 31Z" fill="#fff"/></svg>
</div>
<p class="note">Camera/crop corner brackets frame a play triangle — speaks to both the image editor and video maker. At 16px the brackets drop, leaving a clean play.</p>
</div>
<!-- ───────────── Concept 3: Monoline F ───────────── -->
<div class="card">
<h2>3 — Monoline “F” <span class="tag">lettermark + forward motion</span></h2>
<div class="row">
<svg width="72" height="72" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-label="c3">
<rect width="48" height="48" rx="12" fill="#2563EB"/>
<!-- F stem -->
<rect x="16" y="13" width="3.6" height="22" rx="1.8" fill="#fff"/>
<!-- F arms taper like motion -->
<rect x="16" y="13" width="16" height="3.6" rx="1.8" fill="#fff"/>
<rect x="16" y="22.2" width="11" height="3.6" rx="1.8" fill="#fff" fill-opacity=".75"/>
<!-- play accent -->
<path d="M30 29 35.5 32 30 35Z" fill="#fff" fill-opacity=".9"/>
</svg>
<div class="wm"><span>Flat<span style="color:#2563EB">Render</span></span></div>
</div>
<div class="row dark">
<svg width="56" height="56" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="12" fill="#2563EB"/>
<rect x="16" y="13" width="3.6" height="22" rx="1.8" fill="#fff"/>
<rect x="16" y="13" width="16" height="3.6" rx="1.8" fill="#fff"/>
<rect x="16" y="22.2" width="11" height="3.6" rx="1.8" fill="#fff" fill-opacity=".75"/>
<path d="M30 29 35.5 32 30 35Z" fill="#fff" fill-opacity=".9"/>
</svg>
<div class="wm"><span class="fa">فلت‌رندر</span></div>
</div>
<div class="sizes">
<svg width="40" height="40" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="#2563EB"/><rect x="16" y="13" width="3.6" height="22" rx="1.8" fill="#fff"/><rect x="16" y="13" width="16" height="3.6" rx="1.8" fill="#fff"/><rect x="16" y="22.2" width="11" height="3.6" rx="1.8" fill="#fff" fill-opacity=".75"/><path d="M30 29 35.5 32 30 35Z" fill="#fff" fill-opacity=".9"/></svg>
<svg width="24" height="24" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="#2563EB"/><rect x="16" y="13" width="4" height="22" rx="2" fill="#fff"/><rect x="16" y="13" width="16" height="4" rx="2" fill="#fff"/><rect x="16" y="22" width="11" height="4" rx="2" fill="#fff" fill-opacity=".8"/></svg>
<svg width="16" height="16" viewBox="0 0 48 48"><rect width="48" height="48" rx="12" fill="#2563EB"/><rect x="17" y="13" width="5" height="22" rx="2.5" fill="#fff"/><rect x="17" y="13" width="15" height="5" rx="2.5" fill="#fff"/><rect x="17" y="22" width="10" height="5" rx="2.5" fill="#fff"/></svg>
</div>
<p class="note">An “F” whose stacked arms + small play chevron suggest forward motion and flat layers. Strongest as a recognizable lettermark; scales to a clean monogram at 16px.</p>
</div>
<!-- ───────────── Concept 4: Aperture Play ───────────── -->
<div class="card">
<h2>4 — Aperture Play <span class="tag">render/lens + play</span></h2>
<div class="row">
<svg width="72" height="72" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-label="c4">
<defs><linearGradient id="g4" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7C3AED"/><stop offset="100%" stop-color="#2563EB"/>
</linearGradient></defs>
<rect width="48" height="48" rx="14" fill="url(#g4)"/>
<circle cx="24" cy="24" r="11.5" fill="none" stroke="#fff" stroke-width="2.2" stroke-opacity=".55"/>
<path d="M21 18.5 31 24 21 29.5Z" fill="#fff"/>
</svg>
<div class="wm"><span>Flat<span style="color:#2563EB">Render</span></span></div>
</div>
<div class="row dark">
<svg width="56" height="56" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="14" fill="url(#g4)"/>
<circle cx="24" cy="24" r="11.5" fill="none" stroke="#fff" stroke-width="2.2" stroke-opacity=".55"/>
<path d="M21 18.5 31 24 21 29.5Z" fill="#fff"/>
</svg>
<div class="wm"><span class="fa">فلت‌رندر</span></div>
</div>
<div class="sizes">
<svg width="40" height="40" viewBox="0 0 48 48"><rect width="48" height="48" rx="14" fill="url(#g4)"/><circle cx="24" cy="24" r="11.5" fill="none" stroke="#fff" stroke-width="2.2" stroke-opacity=".55"/><path d="M21 18.5 31 24 21 29.5Z" fill="#fff"/></svg>
<svg width="24" height="24" viewBox="0 0 48 48"><rect width="48" height="48" rx="14" fill="url(#g4)"/><circle cx="24" cy="24" r="11.5" fill="none" stroke="#fff" stroke-width="2.8" stroke-opacity=".6"/><path d="M21 18.5 31 24 21 29.5Z" fill="#fff"/></svg>
<svg width="16" height="16" viewBox="0 0 48 48"><rect width="48" height="48" rx="14" fill="url(#g4)"/><path d="M20 17 32 24 20 31Z" fill="#fff"/></svg>
</div>
<p class="note">A thin ring (lens/aperture/“render in progress”) around a play triangle. Friendly, modern, distinct from generic play-button icons.</p>
</div>
</div>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
// Functional test for project cross-aspect duplication.
// Mints an admin JWT (HS256, same secret/issuer/audience as identity-svc),
// adds a scene to a real project, duplicates it to 1:1, verifies the clone has
// the scene under a NEW project id + new aspect, then cleans up.
import crypto from "node:crypto";
const GW = "http://172.28.144.1:8088";
const SECRET = "p9Xv7Lm2Qq8Nz4TfKc1Hs6YwRe3Ud0BafwefWEFw324234QEWF";
const b64url = (b) => Buffer.from(b).toString("base64url");
function mintToken() {
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const now = Math.floor(Date.now() / 1000);
const payload = b64url(JSON.stringify({
sub: "00000000-0000-0000-0000-0000000000aa",
jti: crypto.randomUUID(),
tenant_id: "00000000-0000-0000-0000-0000000000bb",
tenant_slug: "flatrender",
is_admin: "true", is_tenant_admin: "false", role: "Admin",
iss: "flatrender-identity", aud: "flatrender",
exp: now + 3600, iat: now,
}));
const sig = crypto.createHmac("sha256", SECRET).update(`${header}.${payload}`).digest("base64url");
return `${header}.${payload}.${sig}`;
}
const H = { "Content-Type": "application/json", Authorization: `Bearer ${mintToken()}` };
async function j(method, path, body) {
const res = await fetch(GW + path, { method, headers: H, body: body ? JSON.stringify(body) : undefined, redirect: "follow" });
const text = await res.text();
let data; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
return { status: res.status, data };
}
// 1. find a project
const pl = await j("GET", "/v1/projects/?page=1&page_size=1");
const items = pl.data?.items ?? pl.data?.data ?? (Array.isArray(pl.data) ? pl.data : []);
const pid = items[0]?.id;
console.log("source project:", pid ?? "<none>");
if (!pid) { console.log("NO PROJECT — create one in admin first."); process.exit(0); }
// 2. add a test scene to source
const SK = "scene_dup_TEST";
console.log("add test scene:", (await j("POST", "/v1/scenes", {
project_id: pid, key: SK, title: "Dup Test Scene", scene_type: "Normal",
default_duration_sec: 4, overlap_at_end_sec: 0, can_handle_duration: true,
generate_kf: false, manual_color_selection: false, sort: 99, is_active: true,
})).status);
// 3. duplicate to 1:1
const dup = await j("POST", `/v1/projects/${pid}/duplicate`, { aspect: "1:1", original_width: 1080, original_height: 1080, name: "DUP TEST 1:1" });
const newId = dup.data?.id;
console.log("duplicate:", dup.status, "| new id:", newId, "| aspect:", dup.data?.aspect, "| width:", dup.data?.original_width);
// 4. verify clone has the scene, scoped to the new project
if (newId) {
const r = await j("GET", `/v1/scenes/?project_id=${newId}`);
const scenes = Array.isArray(r.data) ? r.data : r.data?.data ?? [];
const found = scenes.find((s) => s.key === SK);
console.log(`VERIFY → clone scenes: ${scenes.length} | test scene present: ${!!found} | scoped to new project: ${found?.project_id === newId} | new id != source: ${newId !== pid}`);
}
// 5. cleanup
if (newId) console.log("cleanup dup project:", (await j("DELETE", `/v1/projects/${newId}`)).status);
const ss = await j("GET", `/v1/scenes/?project_id=${pid}`);
const srcScenes = Array.isArray(ss.data) ? ss.data : ss.data?.data ?? [];
const ts = srcScenes.find((x) => x.key === SK);
if (ts) console.log("cleanup test scene:", (await j("DELETE", `/v1/scenes/${ts.id}`)).status);
@@ -184,6 +184,163 @@ public class TemplateService(ContentDbContext db)
return await GetProjectDetailAsync(project.Id); return await GetProjectDetailAsync(project.Id);
} }
/// <summary>Clone a project (all scenes / content-elements / colour-elements / presets /
/// shared colours / shared layers) into a NEW project for a different aspect ratio. All
/// keys/values are copied identically — only the output dimensions (+ optional name/container)
/// change. The AEP file is intentionally NOT copied: each aspect has its own .aep, attached
/// after. The duplicate starts unpublished until its .aep is set.</summary>
public async Task<ProjectDetailResponse> DuplicateProjectAsync(Guid id, DuplicateProjectRequest req)
{
var src = await db.Projects
.Include(p => p.Scenes.Where(s => s.DeletedAt == null)).ThenInclude(s => s.RepeaterItems)
.Include(p => p.Scenes).ThenInclude(s => s.ContentElements)
.Include(p => p.Scenes).ThenInclude(s => s.ColorElements)
.Include(p => p.Scenes).ThenInclude(s => s.ColorPresets).ThenInclude(cp => cp.Items)
.Include(p => p.SharedColors)
.Include(p => p.SharedLayers)
.Include(p => p.SharedColorPresets).ThenInclude(sp => sp.Items)
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == id)
?? throw new KeyNotFoundException($"Project {id} not found");
var resolution = src.Resolution;
if (req.Resolution != null)
{
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var r))
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
resolution = r;
}
var np = new Project
{
ContainerId = req.ContainerId ?? src.ContainerId,
ProjectServerId = src.ProjectServerId,
Name = string.IsNullOrWhiteSpace(req.Name) ? $"{src.Name} ({req.Aspect ?? src.Aspect})" : req.Name!,
Description = src.Description, Image = src.Image, FullDemo = src.FullDemo,
DemoScriptTag = src.DemoScriptTag, DownloadLink = src.DownloadLink, Folder = src.Folder,
// AEP fields deliberately left null — this aspect gets its own .aep.
OriginalWidth = req.OriginalWidth ?? src.OriginalWidth,
OriginalHeight = req.OriginalHeight ?? src.OriginalHeight,
Aspect = req.Aspect ?? src.Aspect,
ProjectDurationSec = src.ProjectDurationSec, MinDurationSec = src.MinDurationSec,
MaxDurationSec = src.MaxDurationSec, FreeFps = src.FreeFps, ChooseMode = src.ChooseMode,
Resolution = resolution, VipFactor = src.VipFactor, RenderAepComp = src.RenderAepComp,
SharedLayerImage = src.SharedLayerImage, SharedColorsSvg = src.SharedColorsSvg,
SharedColorPresetsSvg = src.SharedColorPresetsSvg,
IsPublished = false, Sort = src.Sort,
};
foreach (var s in src.Scenes.OrderBy(x => x.Sort))
{
var ns = new Scene
{
Key = s.Key, Title = s.Title, LocalizedTitle = s.LocalizedTitle, SceneType = s.SceneType,
Image = s.Image, Demo = s.Demo, SceneColorSvg = s.SceneColorSvg, SnapshotUrl = s.SnapshotUrl,
GenerateKf = s.GenerateKf, DefaultDurationSec = s.DefaultDurationSec, MinDurationSec = s.MinDurationSec,
MaxDurationSec = s.MaxDurationSec, OverlapAtEndSec = s.OverlapAtEndSec, CanHandleDuration = s.CanHandleDuration,
ManualColorSelection = s.ManualColorSelection, Sort = s.Sort, IsActive = s.IsActive,
};
// Repeater items first, so repeater-scoped content elements can re-parent to the clones.
var repMap = new Dictionary<Guid, RepeaterItem>();
foreach (var ri in s.RepeaterItems)
{
var nri = new RepeaterItem
{
Title = ri.Title, RepeatBoxKey = ri.RepeatBoxKey, RepeatItemKey = ri.RepeatItemKey,
MaxRepeatCount = ri.MaxRepeatCount, UserCanChangeSort = ri.UserCanChangeSort,
RepeatSortStrategy = ri.RepeatSortStrategy, Sort = ri.Sort,
};
repMap[ri.Id] = nri;
ns.RepeaterItems.Add(nri);
}
foreach (var ce in s.ContentElements)
{
var nce = CloneContentElement(ce);
ns.ContentElements.Add(nce);
if (ce.RepeaterItemId.HasValue && repMap.TryGetValue(ce.RepeaterItemId.Value, out var nri))
nri.ContentElements.Add(nce); // sets RepeaterItemId on the clone
}
foreach (var col in s.ColorElements)
ns.ColorElements.Add(new SceneColorElement
{
ElementKey = col.ElementKey, Title = col.Title, Icon = col.Icon,
AttrValue = col.AttrValue, DefaultColor = col.DefaultColor, Sort = col.Sort,
});
foreach (var cp in s.ColorPresets)
{
var ncp = new SceneColorPreset { Name = cp.Name, Sort = cp.Sort };
foreach (var it in cp.Items)
ncp.Items.Add(new SceneColorPresetItem { ElementKey = it.ElementKey, Value = it.Value, Sort = it.Sort });
ns.ColorPresets.Add(ncp);
}
np.Scenes.Add(ns);
}
foreach (var sc in src.SharedColors)
np.SharedColors.Add(new SharedColor
{
ElementKey = sc.ElementKey, Title = sc.Title, Icon = sc.Icon,
AttrValue = sc.AttrValue, DefaultColor = sc.DefaultColor, Sort = sc.Sort,
});
foreach (var sp in src.SharedColorPresets)
{
var nsp = new SharedColorPreset { Name = sp.Name, Sort = sp.Sort };
foreach (var it in sp.Items)
nsp.Items.Add(new SharedColorPresetItem { ElementKey = it.ElementKey, Value = it.Value, Sort = it.Sort });
np.SharedColorPresets.Add(nsp);
}
foreach (var sl in src.SharedLayers)
np.SharedLayers.Add(CloneSharedLayer(sl));
db.Projects.Add(np);
await db.SaveChangesAsync();
return await GetProjectDetailAsync(np.Id);
}
private static SceneContentElement CloneContentElement(SceneContentElement e) => new()
{
Key = e.Key, Title = e.Title, LocalizedTitle = e.LocalizedTitle, Hint = e.Hint,
Type = e.Type, DefaultValue = e.DefaultValue,
FontId = e.FontId, FontFace = e.FontFace, FontFaceName = e.FontFaceName, FontSize = e.FontSize,
DefaultFontSize = e.DefaultFontSize, DefaultFontFace = e.DefaultFontFace,
IsFontChangeable = e.IsFontChangeable, IsFontSizeChangeable = e.IsFontSizeChangeable,
Justify = e.Justify, CanJustify = e.CanJustify, PositionInContainer = e.PositionInContainer,
IsTextBox = e.IsTextBox, MaxSize = e.MaxSize, DirectionLayerKey = e.DirectionLayerKey,
DirectionLayerValue = e.DirectionLayerValue, VideoSupport = e.VideoSupport,
MinDurationSec = e.MinDurationSec, MaxDurationSec = e.MaxDurationSec, Width = e.Width, Height = e.Height,
Thumbnail = e.Thumbnail, MappedList = e.MappedList, CounterMode = e.CounterMode,
AiInputType = e.AiInputType, IsHidden = e.IsHidden, IsFocused = e.IsFocused,
OpacityControllerKey = e.OpacityControllerKey,
Dp1Image = e.Dp1Image, Dp1Title = e.Dp1Title, Dp2Image = e.Dp2Image, Dp2Title = e.Dp2Title,
Dp3Image = e.Dp3Image, Dp3Title = e.Dp3Title, Dp4Image = e.Dp4Image, Dp4Title = e.Dp4Title,
VirtualCount = e.VirtualCount, Sort = e.Sort,
};
private static SharedLayer CloneSharedLayer(SharedLayer l) => new()
{
Key = l.Key, Title = l.Title, LocalizedTitle = l.LocalizedTitle, Hint = l.Hint,
Type = l.Type, DefaultValue = l.DefaultValue,
FontId = l.FontId, FontFace = l.FontFace, FontFaceName = l.FontFaceName, FontSize = l.FontSize,
DefaultFontSize = l.DefaultFontSize, DefaultFontFace = l.DefaultFontFace,
IsFontChangeable = l.IsFontChangeable, IsFontSizeChangeable = l.IsFontSizeChangeable,
Justify = l.Justify, CanJustify = l.CanJustify, PositionInContainer = l.PositionInContainer,
IsTextBox = l.IsTextBox, MaxSize = l.MaxSize, DirectionLayerKey = l.DirectionLayerKey,
DirectionLayerValue = l.DirectionLayerValue, VideoSupport = l.VideoSupport,
MinDurationSec = l.MinDurationSec, MaxDurationSec = l.MaxDurationSec, Width = l.Width, Height = l.Height,
Thumbnail = l.Thumbnail, MappedList = l.MappedList, CounterMode = l.CounterMode,
AiInputType = l.AiInputType, IsHidden = l.IsHidden, IsFocused = l.IsFocused,
Dp1Image = l.Dp1Image, Dp1Title = l.Dp1Title, Dp2Image = l.Dp2Image, Dp2Title = l.Dp2Title,
Dp3Image = l.Dp3Image, Dp3Title = l.Dp3Title, Dp4Image = l.Dp4Image, Dp4Title = l.Dp4Title,
VirtualCount = l.VirtualCount, Sort = l.Sort,
};
public async Task<ProjectDetailResponse> UpdateProjectAsync(Guid id, UpdateProjectRequest req) public async Task<ProjectDetailResponse> UpdateProjectAsync(Guid id, UpdateProjectRequest req)
{ {
var project = await db.Projects.FindAsync(id) var project = await db.Projects.FindAsync(id)
@@ -0,0 +1,17 @@
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Models.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.ContentSvc.Controllers;
/// <summary>Duplicate a project to another aspect ratio (same scene/element structure, new size).</summary>
[ApiController]
[Route("v1/projects")]
public class ProjectDuplicateController(TemplateService svc) : ControllerBase
{
[Authorize(Roles = "Admin")]
[HttpPost("{id:guid}/duplicate")]
public async Task<IActionResult> Duplicate(Guid id, [FromBody] DuplicateProjectRequest req)
=> Ok(await svc.DuplicateProjectAsync(id, req ?? new DuplicateProjectRequest(null, null, null, null, null, null)));
}
@@ -0,0 +1,13 @@
namespace FlatRender.ContentSvc.Models.Requests;
/// <summary>Clone a project to a new aspect ratio. Only dimensions (+ optional name/container/
/// resolution) change; all scene/element/colour keys are copied identically. Null fields inherit
/// from the source project.</summary>
public record DuplicateProjectRequest(
string? Aspect,
int? OriginalWidth,
int? OriginalHeight,
string? Resolution,
string? Name,
Guid? ContainerId
);
+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 RESOLUTIONS = ["HD", "FullHD", "TwoK", "FourK"];
const MODES = ["FIX", "FLEXIBLE", "MockUp", "MusicVisualizer", "VoiceOver"]; const MODES = ["FIX", "FLEXIBLE", "MockUp", "MusicVisualizer", "VoiceOver"];
const ASPECTS = ["16:9", "9:16", "1:1", "4:5", "21:9"]; // Supported video aspects only.
const emptyNew = { container_id: "", name: "", width: 1920, height: 1080, aspect: "16:9", resolution: "FullHD", duration: 15, fps: 30, mode: "FLEXIBLE" }; 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() { export function ProjectsAdmin() {
const [rows, setRows] = useState<Proj[]>([]); const [rows, setRows] = useState<Proj[]>([]);
@@ -33,6 +37,10 @@ export function ProjectsAdmin() {
const [openAssets, setOpenAssets] = useState<Proj | null>(null); const [openAssets, setOpenAssets] = useState<Proj | null>(null);
const [openScenes, setOpenScenes] = useState<Proj | null>(null); const [openScenes, setOpenScenes] = useState<Proj | null>(null);
const [aepMsg, setAepMsg] = useState<string | 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 [containers, setContainers] = useState<{ id: string; name: string }[]>([]);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [nf, setNf] = useState({ ...emptyNew }); const [nf, setNf] = useState({ ...emptyNew });
@@ -109,6 +117,30 @@ export function ProjectsAdmin() {
load(); 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 ( return (
<div className="space-y-4" dir="rtl"> <div className="space-y-4" dir="rtl">
<div className="flex flex-wrap items-end justify-between gap-3"> <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.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}>ارتفاع (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.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"> <div className="sm:col-span-2">
<label className={lbl}>حالت</label> <label className={lbl}>حالت</label>
<select className={`${inp} w-full`} value={nf.mode} onChange={(e) => setNf({ ...nf, mode: e.target.value })}> <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"> <div className="flex items-center justify-end gap-2">
<button className={ghost} onClick={() => setOpenScenes(p)}>صحنهها</button> <button className={ghost} onClick={() => setOpenScenes(p)}>صحنهها</button>
<button className={ghost} onClick={() => { setAepMsg(null); setOpenAssets(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> <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> </div>
</td> </td>
@@ -238,6 +271,40 @@ export function ProjectsAdmin() {
</div> </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 && ( {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="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()}> <div className={`${card} flex max-h-full w-full max-w-4xl flex-col`} onClick={(e) => e.stopPropagation()}>