From ee670552a87dbc9f0f89b104701182368ffac1bb Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 16:59:23 +0330 Subject: [PATCH] feat: cross-aspect project duplication + AEP convention/rule-engine spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/launch.json | 18 ++ .claude/workflows/localize-sweep.js | 130 ++++++++++ docs/aep-template-convention.md | 226 ++++++++++++++++++ logo-preview.html | 173 ++++++++++++++ scripts/test-duplicate.mjs | 68 ++++++ .../Application/Services/TemplateService.cs | 157 ++++++++++++ .../Controllers/ProjectDuplicateController.cs | 17 ++ .../Models/ProjectExtraModels.cs | 13 + src/components/admin/ProjectsAdmin.tsx | 73 +++++- 9 files changed, 872 insertions(+), 3 deletions(-) create mode 100644 .claude/launch.json create mode 100644 .claude/workflows/localize-sweep.js create mode 100644 docs/aep-template-convention.md create mode 100644 logo-preview.html create mode 100644 scripts/test-duplicate.mjs create mode 100644 services/content/FlatRender.ContentSvc/Controllers/ProjectDuplicateController.cs create mode 100644 services/content/FlatRender.ContentSvc/Models/ProjectExtraModels.cs diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..17de532 --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/.claude/workflows/localize-sweep.js b/.claude/workflows/localize-sweep.js new file mode 100644 index 0000000..7925c98 --- /dev/null +++ b/.claude/workflows/localize-sweep.js @@ -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.") + * 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.") (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." 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 })), +} diff --git a/docs/aep-template-convention.md b/docs/aep-template-convention.md new file mode 100644 index 0000000..30db802 --- /dev/null +++ b/docs/aep-template-convention.md @@ -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_` | Editable **visible** layer (text / media / audio) — what the user fills in. | +| `frd_` | **Data / direction** layer — hidden values, checkbox/dropdown/fill, colour data, RTL companion. | +| `frc_` | **Container comp** used for duplication (scenes, repeat boxes). | +| `frs_` | **Shared-ref** layer inside an `frc_` container. | +| `frd_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_`. +- `FRDMaker(key)` → strip `frl_`, ensure `frd` → `frd_`. + +### 🔑 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 **`_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 `0–8` (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. diff --git a/logo-preview.html b/logo-preview.html new file mode 100644 index 0000000..fd68259 --- /dev/null +++ b/logo-preview.html @@ -0,0 +1,173 @@ + + + + + +FlatRender — Logo Concepts + + + +
+

FlatRender — Logo Concepts

+

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.

+
+ +
+ + +
+

1 — Layered Play motion frames + video

+
+ + + + + + + + +
FlatRender
+
+
+ + + + + +
فلت‌رندر
+
+
+ + + +
+

Two offset play triangles = motion / video frames, with a flat-design depth cue. Reads cleanly even at 16px (front triangle only).

+
+ + +
+

2 — Render Frame viewport + play (image & video)

+
+ + + + + + + + + + + + + + +
FlatRender
+
+
+ + + + + + + + +
فلت‌رندر
+
+
+ + + +
+

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.

+
+ + +
+

3 — Monoline “F” lettermark + forward motion

+
+ + + + + + + + + + +
FlatRender
+
+
+ + + + + + + +
فلت‌رندر
+
+
+ + + +
+

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.

+
+ + +
+

4 — Aperture Play render/lens + play

+
+ + + + + + + + +
FlatRender
+
+
+ + + + + +
فلت‌رندر
+
+
+ + + +
+

A thin ring (lens/aperture/“render in progress”) around a play triangle. Friendly, modern, distinct from generic play-button icons.

+
+ +
+ + diff --git a/scripts/test-duplicate.mjs b/scripts/test-duplicate.mjs new file mode 100644 index 0000000..d3de057 --- /dev/null +++ b/scripts/test-duplicate.mjs @@ -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 ?? ""); +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); diff --git a/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs b/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs index 2e3bf72..d66ba01 100644 --- a/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs +++ b/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs @@ -184,6 +184,163 @@ public class TemplateService(ContentDbContext db) return await GetProjectDetailAsync(project.Id); } + /// 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. + public async Task 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(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(); + 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 UpdateProjectAsync(Guid id, UpdateProjectRequest req) { var project = await db.Projects.FindAsync(id) diff --git a/services/content/FlatRender.ContentSvc/Controllers/ProjectDuplicateController.cs b/services/content/FlatRender.ContentSvc/Controllers/ProjectDuplicateController.cs new file mode 100644 index 0000000..c720bc9 --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Controllers/ProjectDuplicateController.cs @@ -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; + +/// Duplicate a project to another aspect ratio (same scene/element structure, new size). +[ApiController] +[Route("v1/projects")] +public class ProjectDuplicateController(TemplateService svc) : ControllerBase +{ + [Authorize(Roles = "Admin")] + [HttpPost("{id:guid}/duplicate")] + public async Task Duplicate(Guid id, [FromBody] DuplicateProjectRequest req) + => Ok(await svc.DuplicateProjectAsync(id, req ?? new DuplicateProjectRequest(null, null, null, null, null, null))); +} diff --git a/services/content/FlatRender.ContentSvc/Models/ProjectExtraModels.cs b/services/content/FlatRender.ContentSvc/Models/ProjectExtraModels.cs new file mode 100644 index 0000000..e8860cd --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Models/ProjectExtraModels.cs @@ -0,0 +1,13 @@ +namespace FlatRender.ContentSvc.Models.Requests; + +/// 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. +public record DuplicateProjectRequest( + string? Aspect, + int? OriginalWidth, + int? OriginalHeight, + string? Resolution, + string? Name, + Guid? ContainerId +); diff --git a/src/components/admin/ProjectsAdmin.tsx b/src/components/admin/ProjectsAdmin.tsx index de5e709..e29c109 100644 --- a/src/components/admin/ProjectsAdmin.tsx +++ b/src/components/admin/ProjectsAdmin.tsx @@ -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 = { + "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([]); @@ -33,6 +37,10 @@ export function ProjectsAdmin() { const [openAssets, setOpenAssets] = useState(null); const [openScenes, setOpenScenes] = useState(null); const [aepMsg, setAepMsg] = useState(null); + const [dupOf, setDupOf] = useState(null); + const [dupForm, setDupForm] = useState({ aspect: "1:1", width: 1080, height: 1080, resolution: "FullHD", name: "" }); + const [dupBusy, setDupBusy] = useState(false); + const [dupErr, setDupErr] = useState(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 (
@@ -154,7 +186,7 @@ export function ProjectsAdmin() {
setNf({ ...nf, width: Number(e.target.value) })} />
setNf({ ...nf, height: Number(e.target.value) })} />
setNf({ ...nf, duration: Number(e.target.value) })} />
-
setNf({ ...nf, fps: Number(e.target.value) })} />
+
setNf({ ...nf, fps: Math.min(60, Math.max(1, Number(e.target.value) || 1)) })} />
{ 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) => )} + +
+
+ + +
+
setDupForm((f) => ({ ...f, width: Number(e.target.value) }))} />
+
setDupForm((f) => ({ ...f, height: Number(e.target.value) }))} />
+
setDupForm((f) => ({ ...f, name: e.target.value }))} placeholder={`${dupOf.name} (${dupForm.aspect})`} />
+
+
+ + +
+
+ + )} + {openScenes && (
setOpenScenes(null)}>
e.stopPropagation()}>