diff --git a/.gitignore b/.gitignore index 951f4e9..bec8c31 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ next-env.d.ts # .NET build output [Bb]in/ [Oo]bj/ + +# built node-agent binary (large) +node-agent.exe diff --git a/backend/db/migrations/27_render_ae_version_2026.sql b/backend/db/migrations/27_render_ae_version_2026.sql new file mode 100644 index 0000000..5770e27 --- /dev/null +++ b/backend/db/migrations/27_render_ae_version_2026.sql @@ -0,0 +1,8 @@ +-- ===================================================================== +-- RENDER SCHEMA — extend ae_version enum for AE 2026 / 2027 +-- (PostgreSQL: ADD VALUE cannot run inside a txn block on older versions; +-- run standalone. IF NOT EXISTS is idempotent on PG 12+.) +-- ===================================================================== + +ALTER TYPE render.ae_version ADD VALUE IF NOT EXISTS '2026'; +ALTER TYPE render.ae_version ADD VALUE IF NOT EXISTS '2027'; diff --git a/backend/db/migrations/28_render_scan_mode.sql b/backend/db/migrations/28_render_scan_mode.sql new file mode 100644 index 0000000..a257fdd --- /dev/null +++ b/backend/db/migrations/28_render_scan_mode.sql @@ -0,0 +1,8 @@ +-- ===================================================================== +-- RENDER SCHEMA — scan jobs carry the project TYPE so scan.jsx parses with +-- the right convention (fix = layer-name encoding, flexible/mockup = comps). +-- ===================================================================== + +SET search_path TO render, public; + +ALTER TABLE scan_jobs ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'flexible'; diff --git a/docker-compose.v2.yml b/docker-compose.v2.yml index ec92ab9..d4f2ec1 100644 --- a/docker-compose.v2.yml +++ b/docker-compose.v2.yml @@ -180,11 +180,14 @@ services: context: ./services/render container_name: fr2-render restart: unless-stopped + ports: + - "5010:8080" # exposed so a LOCAL (host) node-agent can reach /v1/internal/* environment: DATABASE_URL: "postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/flatrender?search_path=render,public" JWT_SECRET: "${JWT_SECRET}" NODE_HMAC_SECRET: "${NODE_HMAC_SECRET:-node-secret-change-me}" - MINIO_ENDPOINT: "minio:9000" + # Host-reachable so presigned template/output URLs work for a host-run node-agent. + MINIO_ENDPOINT: "${MINIO_HOST_ENDPOINT:-172.28.144.1:9000}" MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}" MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}" MINIO_USE_SSL: "false" diff --git a/docs/aep-template-convention.md b/docs/aep-template-convention.md index 30db802..78ec8ba 100644 --- a/docs/aep-template-convention.md +++ b/docs/aep-template-convention.md @@ -38,6 +38,24 @@ - `FRLMaker(key)` → if it already contains `frl`/`frd`, keep; else `frl_`. - `FRDMaker(key)` → strip `frl_`, ensure `frd` → `frd_`. +### Naming differs by PROJECT TYPE ⚠️ (scanner must be told the type) + +**Only two layer kinds:** `t` = text · `m` = media. In AE image / video / audio are the **same** footage (AVLayer), so all three are `m`. + +**FIX / MusicVisualizer** — no per-scene `frc_` comps. AE project-panel folders: +| Folder | Holds | +|---|---| +| `Final/` | `frfinal` — the mother render comp | +| `Edit/` | editable comps (any name); layers `frl_c(x)t(y)` (text) / `frl_c(x)m(y)` (media). `c(x)` = scene no., `(y)` = element index | +| `Share/` | `frshare` comp — `frd_` layers: **colours** (RGBA, 4 numbers) + **design-selector** layers (a number **0–3** that shows/hides layers via expression). All user-editable. | +| `Other/` | footage (video/image) files | + +→ scanner derives scenes from the distinct `c` in `frl_c(x)t/m(y)` layer names; element key = the full layer name; type `t`→Text, `m`→Media. + +**FLEXIBLE / Mockup** — each scene **is a comp**; editable layers `frl_` inside; story duplicates rename `_d{N}`. + +→ The **scan takes a project-type argument** (defaults to the project's `ChooseMode`) and branches its parsing rule (`FR_SCAN_MODE` → `fix` parses layer names, `flexible` parses comps). + ### 🔑 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*. @@ -219,6 +237,41 @@ Expressions are the #1 thing that complicate Master Properties (expression-drive --- +## 11.5 Project import bundles (admin upload) + +Adding a project uses **two zips** with strict conventions: + +### Zip 1 — render bundle (AE project) +``` +final.aep ← at the zip root +(Footage)/... ← footage folder (name may be "(Footage)" with parens), SIBLING of final.aep +(Footage)/LONG VERSION/Items/SFX1..n.mp3 ← SFX are footage the AEP references (NOT separate assets) +``` +- `final.aep` + the footage folder **must be siblings** so AE resolves relative paths. Footage-folder name match is case-insensitive and accepts `Footage` or `(Footage)`. +- Stored at `templates/{project_id}/bundle.zip`; the node extracts it keeping the tree intact and runs `aerender` **from the `.aep`'s folder** → footage is beside it → no "missing footage". +- **Validation on upload:** the zip must contain `final.aep` (prefer this name) with a sibling footage folder. Reject otherwise. +- The scanner reads `final.aep` from this same bundle. +- **SFX** ship *inside* this footage; they are not in the assets bundle. + +### Zip 2 — assets bundle (demos / placeholders / colour SVGs / music) +May be wrapped in a top folder (e.g. `New folder/`) → **match by basename**, strip leading dirs. +`s{i}` = the i-th scene by sort order. + +| File | Meaning | Target | +|---|---|---| +| `p.jpg` | project image / thumbnail | Project image | +| `p.mp4` | full project demo (audio, 1080) | Project full demo | +| `p.svg` | project colour SVG | Project colour SVG | +| `demo.mp4` | hover preview loop (on the card) | Project hover/mini demo | +| `.mp3` | **the single non-`sfx` mp3** = default **music** (arbitrary name, e.g. `Playful Ink Reveal.mp3`) | Project default music | +| `s1.mp4 … s(n).mp4` | per-scene loop demos | `Scene[i].Demo` | +| `s1.jpg … s(n).jpg` | per-scene placeholder images | `Scene[i].Image` | +| `s1.svg … s(n).svg` | per-scene colour SVG (optional) | `Scene[i].SceneColorSvg` | + +**Music rule:** the project's default music is the one `.mp3` in the assets bundle whose name is **not** `sfx`. (SFX itself comes from the render footage.) + +**Flow:** render bundle → scan → scenes created → assets bundle ingested → each asset uploaded to storage and its field set, mapping `s{i}` to the i-th scene by sort. (Assets-bundle ingestion is a separate admin feature — TODO.) + ## 11. Open items to confirm / validate - [ ] VoiceOver mode mechanics (separate mode vs flavor of Fix/Flexible). - [ ] Exact `flatrender` timeline offset formula (cumulative `Duration − overlap`). diff --git a/node-agent.exe~ b/node-agent.exe~ new file mode 100644 index 0000000..df6864b Binary files /dev/null and b/node-agent.exe~ differ diff --git a/services/content/FlatRender.ContentSvc/Application/Services/AepImportService.cs b/services/content/FlatRender.ContentSvc/Application/Services/AepImportService.cs index 71a5ef8..b440d4f 100644 --- a/services/content/FlatRender.ContentSvc/Application/Services/AepImportService.cs +++ b/services/content/FlatRender.ContentSvc/Application/Services/AepImportService.cs @@ -21,8 +21,11 @@ public class AepImportService(ContentDbContext db) private async Task RunAsync(Guid projectId, ScanResult scan, ScanApplyOptions? apply) { + // Load ALL scenes incl. soft-deleted: the UNIQUE(project_id,key) constraint + // covers deleted rows too, so a re-scan must REVIVE a soft-deleted match + // rather than insert a duplicate (which would violate the constraint). var existing = await db.Scenes - .Where(s => s.ProjectId == projectId && s.DeletedAt == null) + .Where(s => s.ProjectId == projectId) .Include(s => s.ContentElements) .Include(s => s.ColorElements) .ToListAsync(); @@ -74,13 +77,19 @@ public class AepImportService(ContentDbContext db) scene = new Scene { ProjectId = projectId, Key = ss.Key, Sort = ss.Sort ?? sortBase++ }; db.Scenes.Add(scene); } + else if (scene!.DeletedAt is not null) + { + scene.DeletedAt = null; // revive a soft-deleted scene the scan re-discovered + } ApplySceneHeader(scene!, ss, isNew, apply); ApplyElements(scene!, scElems, exElemByKey, scElemKeys, apply); ApplyColors(scene!, scColors, exColorByKey, scColorKeys, apply); } } - var orphans = existing.Where(s => !scanKeys.Contains(s.Key)).ToList(); + // Orphans = currently-active scenes the scan no longer contains (ignore + // already soft-deleted rows so they don't inflate the diff). + var orphans = existing.Where(s => s.DeletedAt == null && !scanKeys.Contains(s.Key)).ToList(); if (apply is not null && apply.RemoveOrphanScenes) foreach (var s in orphans) s.DeletedAt = DateTime.UtcNow; diff --git a/services/node-agent/cmd/agent/main.go b/services/node-agent/cmd/agent/main.go index 2ba3465..f4cd962 100644 --- a/services/node-agent/cmd/agent/main.go +++ b/services/node-agent/cmd/agent/main.go @@ -31,6 +31,7 @@ import ( "path/filepath" "runtime" "sync" + "sync/atomic" "syscall" "time" @@ -255,8 +256,35 @@ func (a *Agent) pollScanOnce(ctx context.Context) { outPath := filepath.Join(a.cfg.WorkDir, "scans", claim.ScanJobID, "scan.json") scanCtx, cancel2 := context.WithTimeout(ctx, 10*time.Minute) - result, serr := runner.RunScan(scanCtx, a.cfg.AfterFxPath, aepPath, a.cfg.WorkDir, outPath) - cancel2() + defer cancel2() + + // Watchdog: if the user cancels the scan server-side, cancel the context → + // exec.CommandContext kills the AfterFX process. + var cancelled atomic.Bool + go func() { + t := time.NewTicker(3 * time.Second) + defer t.Stop() + for { + select { + case <-scanCtx.Done(): + return + case <-t.C: + st, e := a.orch.ScanStatus(scanCtx, claim.ScanJobID) + if e == nil && (st == "cancelled" || st == "error") { + log.Printf("[scan %s] cancelled server-side — killing AE", claim.ScanJobID) + cancelled.Store(true) + cancel2() + return + } + } + } + }() + + result, serr := runner.RunScan(scanCtx, a.cfg.AfterFxPath, aepPath, a.cfg.WorkDir, outPath, claim.Mode) + if cancelled.Load() { + log.Printf("[scan %s] aborted (cancelled)", claim.ScanJobID) + return // already cancelled in DB; don't report fail + } if serr != nil { a.failScan(claim.ScanJobID, serr.Error()) return diff --git a/services/node-agent/internal/client/client.go b/services/node-agent/internal/client/client.go index ad23ea9..bf5b46d 100644 --- a/services/node-agent/internal/client/client.go +++ b/services/node-agent/internal/client/client.go @@ -250,6 +250,7 @@ func (c *Client) ClaimJob(ctx context.Context, nodeID, region string) (*ClaimedJ type ScanClaim struct { ScanJobID string `json:"scan_job_id"` ProjectID string `json:"project_id"` + Mode string `json:"mode"` // fix | flexible | mockup | musicvisualizer AEPDownloadURL string `json:"aep_download_url"` IsBundle bool `json:"is_bundle"` BundleMD5 string `json:"bundle_md5"` @@ -306,6 +307,23 @@ func (c *Client) ReportScanFail(ctx context.Context, scanJobID, reason string) e return nil } +// ScanStatus returns the scan job's current status (for the cancel watchdog). +func (c *Client) ScanStatus(ctx context.Context, scanJobID string) (string, error) { + resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/v1/internal/scan/%s/status", scanJobID), nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return "", fmt.Errorf("scan status: HTTP %d", resp.StatusCode) + } + var out struct { + Status string `json:"status"` + } + _ = json.NewDecoder(resp.Body).Decode(&out) + return out.Status, nil +} + // UpdatePreview sends a base64-encoded preview frame to the orchestrator. // Errors are non-fatal — the UI simply won't update the preview image. func (c *Client) UpdatePreview(ctx context.Context, jobID, imageB64 string) error { diff --git a/services/node-agent/internal/runner/bind.jsx b/services/node-agent/internal/runner/bind.jsx new file mode 100644 index 0000000..10d60f1 --- /dev/null +++ b/services/node-agent/internal/runner/bind.jsx @@ -0,0 +1,135 @@ +// bind.jsx — FlatRender data-driven binder (v1 convention). +// +// Reads a JSON "bind-spec" and writes the user's values INTO a template, then +// saves a "bound" .aep that aerender renders. This is the data-driven successor +// to the legacy JSXGenerator string-concat approach: render-svc emits the JSON, +// this one generic script interprets it. +// +// Environment: +// FR_BIND_AEP — path to the template .aep to open +// FR_BIND_SPEC — path to the bind-spec JSON file +// FR_BIND_SAVE — path to write the bound .aep (aerender input) +// FR_BIND_QUIT — "0" to keep AE open (debug); default quits +// +// Bind-spec shape (v1): +// { +// "comp": "frfinal", "duration": 15, "fps": 30, +// "colors": [ { "key": "frd_primary", "value": "#3366ff" } ], // frshare text layers +// "data": [ { "key": "frd_toggle", "value": "1" } ], // frd_ data layers +// "layers": [ +// { "key":"frl_title","type":"text","value":"Hello", +// "font":"Vazirmatn","size":72,"justify":"CENTER_JUSTIFY" }, +// { "key":"frl_logo","type":"media","value":"C:/work/cdn/logo.png" }, +// { "key":"frl_music","type":"audio","value":"C:/work/cdn/track.mp3" } +// ] +// } +// +// Conventions (see docs/aep-template-convention.md): +// - colours live as TEXT layers inside the `frshare` comp; their Source Text IS +// the colour value, and template expressions read them → set once, propagate. +// - frl_ = editable visible layers (text / media / audio). +// - frd_ = data/direction layers (hidden values, RTL companions). + +(function () { + function getenv(n) { try { return $.getenv(n); } catch (e) { return null; } } + function readFile(p) { var f = new File(p); f.open("r"); var s = f.read(); f.close(); return s; } + function marker(p, txt) { try { var f = new File(p); f.open("w"); f.write(txt); f.close(); } catch (e) {} } + + var aep = getenv("FR_BIND_AEP"); + var specPath = getenv("FR_BIND_SPEC"); + var savePath = getenv("FR_BIND_SAVE"); + var donePath = (savePath || "bind") + ".done"; + var errPath = (savePath || "bind") + ".error"; + + try { + if (aep) app.open(new File(aep)); + var proj = app.project; + + var spec = {}; + if (specPath) spec = eval("(" + readFile(specPath) + ")"); // bind-spec JSON + + // index the spec by layer key for O(1) lookups while walking the project + var colorMap = {}, dataMap = {}, layerMap = {}, i; + if (spec.colors) for (i = 0; i < spec.colors.length; i++) colorMap[spec.colors[i].key] = spec.colors[i]; + if (spec.data) for (i = 0; i < spec.data.length; i++) dataMap[spec.data[i].key] = spec.data[i]; + if (spec.layers) for (i = 0; i < spec.layers.length; i++) layerMap[spec.layers[i].key] = spec.layers[i]; + + // ── helpers ─────────────────────────────────────────────────────────── + function setText(layer, value) { + var p = layer.property("Source Text"); + if (!p) return; + var d = p.value; + d.text = (value == null) ? "" : ("" + value); + p.setValue(d); + } + function justifyOf(name) { + switch (name) { + case "LEFT_JUSTIFY": return ParagraphJustification.LEFT_JUSTIFY; + case "RIGHT_JUSTIFY": return ParagraphJustification.RIGHT_JUSTIFY; + case "FULL_JUSTIFY": return ParagraphJustification.FULL_JUSTIFY; + default: return ParagraphJustification.CENTER_JUSTIFY; + } + } + function textBind(layer, item) { + var p = layer.property("Source Text"); + if (!p) return; + var d = p.value; + d.text = (item.value == null) ? "" : ("" + item.value); + if (item.font) d.font = item.font; + if (item.size) d.fontSize = item.size; + if (item.justify) d.justification = justifyOf(item.justify); + p.setValue(d); + // NOTE (v1 TODO): box auto-fit + positionMode 0-8 anchoring go here later. + } + function mediaBind(layer, item) { + if (!item.value) return; + try { + var footage = proj.importFile(new ImportOptions(new File(item.value))); + layer.replaceSource(footage, false); + // NOTE (v1 TODO): scale-to-fit the box (legacy mediaBind) goes here later. + } catch (e) {} + } + + function bindLayer(layer) { + var name = layer.name; + if (layerMap[name]) { + var it = layerMap[name]; + if (it.type === "media" || it.type === "audio") mediaBind(layer, it); + else textBind(layer, it); + } else if (dataMap[name]) { + setText(layer, dataMap[name].value); // frd_ data / direction layers + } + } + + // ── walk every comp; bind layers by name (frl_/frd_ live anywhere in the tree) ── + for (i = 1; i <= proj.numItems; i++) { + var item = proj.item(i); + if (!(item instanceof CompItem)) continue; + if (item.name === "frshare") { + for (var c = 1; c <= item.numLayers; c++) { + var L = item.layer(c); + if (colorMap[L.name]) setText(L, colorMap[L.name].value); + } + } + for (var l = 1; l <= item.numLayers; l++) bindLayer(item.layer(l)); + } + + // ── comp timing ───────────────────────────────────────────────────────── + var comp = null, target = spec.comp || "frfinal"; + for (i = 1; i <= proj.numItems; i++) { + var x = proj.item(i); + if (x instanceof CompItem && x.name === target) { comp = x; break; } + } + if (comp) { + if (spec.duration) comp.duration = spec.duration; + if (spec.fps) comp.frameRate = spec.fps; + } + + // ── save the bound project for aerender ─────────────────────────────────── + if (savePath) proj.save(new File(savePath)); + marker(donePath, "ok comp=" + (comp ? comp.name : "?")); + } catch (e) { + marker(errPath, "bind error: " + e.toString()); + } + try { if (getenv("FR_BIND_QUIT") !== "0") app.quit(); } catch (e) {} +})(); diff --git a/services/node-agent/internal/runner/scan.go b/services/node-agent/internal/runner/scan.go index e49cba6..b6c923e 100644 --- a/services/node-agent/internal/runner/scan.go +++ b/services/node-agent/internal/runner/scan.go @@ -32,7 +32,7 @@ func WriteScanScript(workDir string) (string, error) { // // afterfx -r runs the script and the script calls app.quit(); we still poll for the // output file because afterfx can return before the file is flushed. -func RunScan(ctx context.Context, afterfxPath, aepPath, workDir, outPath string) ([]byte, error) { +func RunScan(ctx context.Context, afterfxPath, aepPath, workDir, outPath, mode string) ([]byte, error) { scriptPath, err := WriteScanScript(workDir) if err != nil { return nil, fmt.Errorf("write scan script: %w", err) @@ -43,8 +43,11 @@ func RunScan(ctx context.Context, afterfxPath, aepPath, workDir, outPath string) _ = os.Remove(outPath) _ = os.Remove(outPath + ".error") + if mode == "" { + mode = "flexible" + } cmd := exec.CommandContext(ctx, afterfxPath, "-r", scriptPath) - cmd.Env = append(os.Environ(), "FR_SCAN_AEP="+aepPath, "FR_SCAN_OUT="+outPath) + cmd.Env = append(os.Environ(), "FR_SCAN_AEP="+aepPath, "FR_SCAN_OUT="+outPath, "FR_SCAN_MODE="+mode) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr // afterfx may exit non-zero on app.quit() — don't treat the exit code as fatal; diff --git a/services/node-agent/internal/runner/scan.jsx b/services/node-agent/internal/runner/scan.jsx index 6b763eb..f457642 100644 --- a/services/node-agent/internal/runner/scan.jsx +++ b/services/node-agent/internal/runner/scan.jsx @@ -27,6 +27,7 @@ var aepPath = getenv("FR_SCAN_AEP"); var outPath = getenv("FR_SCAN_OUT") || (Folder.temp.fsName + "/fr_scan.json"); + var mode = String(getenv("FR_SCAN_MODE") || "flexible").toLowerCase(); // fix | flexible | mockup | musicvisualizer // ── minimal JSON serializer (older AE has no JSON.stringify) ────────────── function esc(s) { @@ -138,29 +139,32 @@ return false; } - function run() { - if (aepPath) app.open(new File(aepPath)); - var proj = app.project; - var result = { source: "ae-jsx", render_comp: null, scenes: [], shared_colors: [] }; + // ── frshare colours (shared by all modes) ────────────────────────────────── + function readSharedColors(proj) { + var colors = []; + for (var i = 1; i <= proj.numItems; i++) { + var item = proj.item(i); + if (!(item instanceof CompItem) || item.name !== "frshare") continue; + for (var j = 1; j <= item.numLayers; j++) { + var cl = item.layer(j), ct = readText(cl); + if (ct && isColor(ct.text)) { + colors.push({ element_key: cl.name, title: cl.name, attr_value: "fill", default_color: normColor(ct.text), sort: j }); + } + } + } + return colors; + } + // ── FLEXIBLE / Mockup — each scene IS a comp; layers frl_/frd_ inside ─────── + function scanFlexible(proj) { + var result = { source: "ae-jsx", render_comp: null, scenes: [], shared_colors: readSharedColors(proj) }; for (var i = 1; i <= proj.numItems; i++) { var item = proj.item(i); if (!(item instanceof CompItem)) continue; var nm = item.name; - - if (nm === "frfinal") { result.render_comp = "frfinal"; continue; } - if (nm === "frshare") { - for (var j = 1; j <= item.numLayers; j++) { - var cl = item.layer(j), ct = readText(cl); - result.shared_colors.push({ - element_key: cl.name, title: cl.name, attr_value: "fill", - default_color: (ct && isColor(ct.text)) ? normColor(ct.text) : "#000000", sort: j - }); - } - continue; - } + if (nm === "frfinal" || nm === "flatrender") { result.render_comp = nm; continue; } + if (nm === "frshare") continue; if (!compHasEditable(item)) continue; - var s = scanScene(item); result.scenes.push({ key: nm, title: nm, scene_type: "Normal", @@ -168,6 +172,52 @@ sort: result.scenes.length, elements: s.elements, colors: s.colors }); } + return result; + } + + // ── FIX / MusicVisualizer — scenes encoded in layer names frl_c(x)t/m(y) ──── + function scanFix(proj) { + var result = { source: "ae-jsx", render_comp: "frfinal", scenes: [], shared_colors: readSharedColors(proj) }; + var sceneMap = {}, order = []; + var re = /^frl_c(\d+)([tm])(\d+)$/; + for (var i = 1; i <= proj.numItems; i++) { + var item = proj.item(i); + if (!(item instanceof CompItem) || item.name === "frshare") continue; + for (var l = 1; l <= item.numLayers; l++) { + var layer = item.layer(l), m = String(layer.name).match(re); + if (!m) continue; + var sc = parseInt(m[1], 10), typ = m[2], idx = parseInt(m[3], 10); + if (!sceneMap[sc]) { + sceneMap[sc] = { key: "c" + sc, title: "Scene " + sc, scene_type: "Normal", sort: sc, elements: [], colors: [] }; + order.push(sc); + } + var el = { key: layer.name, title: layer.name, sort: idx }; + if (typ === "t") { + var txt = readText(layer); + el.type = "Text"; + if (txt) { el.default_value = txt.text; el.font_face = txt.font; el.font_size = txt.fontSize; el.justify = txt.justify; } + } else { + el.type = "Media"; + var src = null; try { src = layer.source; } catch (e) {} + if (src) { try { el.width = src.width; el.height = src.height; } catch (e) {} try { el.video_support = !!src.hasVideo; } catch (e) {} } + } + sceneMap[sc].elements.push(el); + } + } + order.sort(function (a, b) { return a - b; }); + for (var k = 0; k < order.length; k++) { var s = sceneMap[order[k]]; s.sort = k; result.scenes.push(s); } + return result; + } + + function run() { + // Silence ALL AE dialogs (missing fonts/footage, version prompts, alerts) so a + // real project never hangs the headless scan waiting for a click. + try { app.beginSuppressDialogs(); } catch (e) {} + try { app.preferences.savePrefAsLong("Misc Section", "Play sound when render finishes", 0, PREFType.PREF_Type_MACHINE_INDEPENDENT); } catch (e) {} + if (aepPath) app.open(new File(aepPath)); + var proj = app.project; + + var result = (mode === "fix" || mode === "musicvisualizer") ? scanFix(proj) : scanFlexible(proj); var out = new File(outPath); out.encoding = "UTF-8"; @@ -181,6 +231,13 @@ } catch (e) { try { var ef = new File(outPath + ".error"); ef.open("w"); ef.write("scan error: " + e.toString()); ef.close(); } catch (e2) {} } - // Quit AE so the headless afterfx process returns (set FR_SCAN_QUIT=0 to keep open while debugging). - try { if (getenv("FR_SCAN_QUIT") !== "0") app.quit(); } catch (e) {} + try { app.endSuppressDialogs(false); } catch (e) {} + // Quit AE without a save prompt so the headless afterfx process returns + // (set FR_SCAN_QUIT=0 to keep it open while debugging). + try { + if (getenv("FR_SCAN_QUIT") !== "0") { + try { app.project.close(CloseOptions.DO_NOT_SAVE_CHANGES); } catch (e2) {} + app.quit(); + } + } catch (e) {} })(); diff --git a/services/render/cmd/server/main.go b/services/render/cmd/server/main.go index 17aadb3..c5ab376 100644 --- a/services/render/cmd/server/main.go +++ b/services/render/cmd/server/main.go @@ -157,6 +157,7 @@ func main() { v1.POST("/template-scans/:project_id/quick", auth, admin, scanH.QuickScan) // headless Go quick-scan v1.POST("/template-scans/:project_id/jobs", auth, admin, scanH.CreateJob) // queue an AE full scan v1.GET("/template-scan-jobs/:id", auth, admin, scanH.GetJob) + v1.POST("/template-scan-jobs/:id/cancel", auth, admin, scanH.Cancel) // ── Exports management (admin: all users' rendered videos) ──────────────── adminExports := v1.Group("/admin-exports", auth, admin) @@ -185,6 +186,7 @@ func main() { // AE scan jobs (node claims, runs scan.jsx, posts the ScanResult back) internal.POST("/scan/claim", scanH.Claim) + internal.GET("/scan/:id/status", scanH.Status) // node watchdog (cancel detection) internal.POST("/scan/:id/result", scanH.Result) internal.POST("/scan/:id/fail", scanH.Fail) } diff --git a/services/render/internal/db/scan.go b/services/render/internal/db/scan.go index d00f5db..c8815c4 100644 --- a/services/render/internal/db/scan.go +++ b/services/render/internal/db/scan.go @@ -22,13 +22,17 @@ type ScanJob struct { type ScanClaim struct { ID uuid.UUID ProjectID uuid.UUID + Mode string // fix | flexible | mockup | musicvisualizer → drives scan.jsx parsing } -func (s *Store) CreateScanJob(ctx context.Context, projectID uuid.UUID, engine string) (uuid.UUID, error) { +func (s *Store) CreateScanJob(ctx context.Context, projectID uuid.UUID, engine, mode string) (uuid.UUID, error) { + if mode == "" { + mode = "flexible" + } var id uuid.UUID err := s.pool.QueryRow(ctx, - `INSERT INTO render.scan_jobs (project_id, engine, status) VALUES ($1, $2, 'queued') RETURNING id`, - projectID, engine).Scan(&id) + `INSERT INTO render.scan_jobs (project_id, engine, status, mode) VALUES ($1, $2, 'queued', $3) RETURNING id`, + projectID, engine, mode).Scan(&id) return id, err } @@ -44,7 +48,7 @@ func (s *Store) ClaimScanJob(ctx context.Context, nodeID uuid.UUID) (*ScanClaim, ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED ) - RETURNING id, project_id`, nodeID).Scan(&c.ID, &c.ProjectID) + RETURNING id, project_id, mode`, nodeID).Scan(&c.ID, &c.ProjectID, &c.Mode) if err != nil { if err == pgx.ErrNoRows { return nil, nil @@ -54,19 +58,42 @@ func (s *Store) ClaimScanJob(ctx context.Context, nodeID uuid.UUID) (*ScanClaim, return &c, nil } +// SetScanResult / SetScanError only act on a 'running' job, so a result that +// arrives after the user cancelled doesn't un-cancel it. func (s *Store) SetScanResult(ctx context.Context, id uuid.UUID, resultJSON string) error { _, err := s.pool.Exec(ctx, - `UPDATE render.scan_jobs SET status = 'done', result = $2::jsonb, error = NULL, updated_at = NOW() WHERE id = $1`, + `UPDATE render.scan_jobs SET status = 'done', result = $2::jsonb, error = NULL, updated_at = NOW() WHERE id = $1 AND status = 'running'`, id, resultJSON) return err } func (s *Store) SetScanError(ctx context.Context, id uuid.UUID, msg string) error { _, err := s.pool.Exec(ctx, - `UPDATE render.scan_jobs SET status = 'error', error = $2, updated_at = NOW() WHERE id = $1`, id, msg) + `UPDATE render.scan_jobs SET status = 'error', error = $2, updated_at = NOW() WHERE id = $1 AND status = 'running'`, id, msg) return err } +// CancelScanJob marks a queued/running scan as cancelled (user-requested). The +// node's watchdog sees this and kills the AE process. +func (s *Store) CancelScanJob(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, + `UPDATE render.scan_jobs SET status = 'cancelled', error = 'cancelled by user', updated_at = NOW() WHERE id = $1 AND status IN ('queued','running')`, id) + return err +} + +// GetScanStatus returns just the status string (lightweight, for the node watchdog). +func (s *Store) GetScanStatus(ctx context.Context, id uuid.UUID) (string, error) { + var st string + err := s.pool.QueryRow(ctx, `SELECT status FROM render.scan_jobs WHERE id = $1`, id).Scan(&st) + if err != nil { + if err == pgx.ErrNoRows { + return "", nil + } + return "", err + } + return st, nil +} + func (s *Store) GetScanJob(ctx context.Context, id uuid.UUID) (*ScanJob, error) { var j ScanJob err := s.pool.QueryRow(ctx, diff --git a/services/render/internal/handlers/scan.go b/services/render/internal/handlers/scan.go index 3892f51..25a7e61 100644 --- a/services/render/internal/handlers/scan.go +++ b/services/render/internal/handlers/scan.go @@ -173,19 +173,27 @@ func extractAepFromZip(zb []byte) ([]byte, error) { } // ── AE scan jobs (async, full fidelity) ─────────────────────────────────────── -// POST /v1/template-scans/:project_id/jobs (admin) +// POST /v1/template-scans/:project_id/jobs (admin) body: {"mode":"fix|flexible|mockup|musicvisualizer"} func (h *ScanHandler) CreateJob(c *gin.Context) { pid, err := uuid.Parse(c.Param("project_id")) if err != nil { c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid project_id"}) return } - id, err := h.store.CreateScanJob(c.Request.Context(), pid, "ae-jsx") + var req struct { + Mode string `json:"mode"` + } + _ = c.ShouldBindJSON(&req) + mode := strings.ToLower(strings.TrimSpace(req.Mode)) + if mode == "" { + mode = "flexible" + } + id, err := h.store.CreateScanJob(c.Request.Context(), pid, "ae-jsx", mode) if err != nil { c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"id": id, "status": "queued"}) + c.JSON(http.StatusOK, gin.H{"id": id, "status": "queued", "mode": mode}) } // GET /v1/template-scan-jobs/:id (admin) @@ -223,9 +231,18 @@ func (h *ScanHandler) Claim(c *gin.Context) { return } url, isBundle, md5 := resolveTemplateObject(h.minio, h.templatesBucket, claim.ProjectID) + if url == "" { + // No template object stored for this project — fail the job with a clear + // message instead of handing the node an empty URL. + _ = h.store.SetScanError(c.Request.Context(), claim.ID, + "no template stored for this project — upload the .aep from «فایل‌ها» first") + c.Status(http.StatusNoContent) + return + } c.JSON(http.StatusOK, gin.H{ "scan_job_id": claim.ID, "project_id": claim.ProjectID, + "mode": claim.Mode, "aep_download_url": url, "is_bundle": isBundle, "bundle_md5": md5, @@ -272,6 +289,35 @@ func (h *ScanHandler) Fail(c *gin.Context) { c.Status(http.StatusNoContent) } +// POST /v1/template-scan-jobs/:id/cancel (admin) +func (h *ScanHandler) Cancel(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"}) + return + } + if err := h.store.CancelScanJob(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "cancelled"}) +} + +// GET /v1/internal/scan/:id/status (node watchdog, HMAC) → {"status": "..."} +func (h *ScanHandler) Status(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"}) + return + } + st, err := h.store.GetScanStatus(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": st}) +} + // resolveTemplateObject presigns the canonical template object for a project, // probing bundle.zip → template.aep → template.aepx (same order as render claim). func resolveTemplateObject(mc *minio.Client, bucket string, projectID uuid.UUID) (url string, isBundle bool, md5 string) { diff --git a/services/render/internal/handlers/template_bundles.go b/services/render/internal/handlers/template_bundles.go index 9f015a0..84bb7a7 100644 --- a/services/render/internal/handlers/template_bundles.go +++ b/services/render/internal/handlers/template_bundles.go @@ -128,13 +128,30 @@ func (h *TemplateBundleHandler) Set(c *gin.Context) { }) } -// parseObjectURL extracts the bucket and object key from a path-style storage URL -// such as http://host:9000//. Query/fragment are ignored. +// parseObjectURL extracts the bucket and object key from a storage URL. Handles +// two forms: +// - minio:/// (file-svc FileAddress — bucket is the HOST) +// - http(s)://host[:port]// (public path-style — bucket is the 1st path seg) +// Query/fragment are ignored. func parseObjectURL(raw string) (bucket, key string, err error) { u, perr := url.Parse(strings.TrimSpace(raw)) if perr != nil { return "", "", fmt.Errorf("parse url: %w", perr) } + + // minio:/// — the bucket lives in the host component. + if u.Scheme == "minio" { + k := strings.TrimPrefix(u.Path, "/") + if u.Host == "" || k == "" { + return "", "", fmt.Errorf("cannot derive bucket/key from %q", raw) + } + if dec, derr := url.PathUnescape(k); derr == nil { + k = dec + } + return u.Host, k, nil + } + + // http(s)://host[:port]// p := strings.TrimPrefix(u.Path, "/") if p == "" { return "", "", fmt.Errorf("url has no path: %q", raw) diff --git a/src/components/admin/ProjectScanImport.tsx b/src/components/admin/ProjectScanImport.tsx index e12a9af..af9fef4 100644 --- a/src/components/admin/ProjectScanImport.tsx +++ b/src/components/admin/ProjectScanImport.tsx @@ -41,9 +41,27 @@ export function ProjectScanImport({ projectId, onClose, onApplied }: { const [diff, setDiff] = useState(null); const [removeOrphans, setRemoveOrphans] = useState(false); const [overwrite, setOverwrite] = useState(true); + const [mode, setMode] = useState("fix"); // project type → drives scan.jsx parsing convention const pollRef = useRef | null>(null); + const timerRef = useRef | null>(null); + const jobRef = useRef(null); + const [elapsed, setElapsed] = useState(0); - const fail = (m: string) => { setErr(m); setStep("error"); }; + const stopTimers = () => { + if (pollRef.current) clearTimeout(pollRef.current); + if (timerRef.current) clearInterval(timerRef.current); + }; + const fail = (m: string) => { stopTimers(); setErr(m); setStep("error"); }; + + const cancelScan = async () => { + stopTimers(); + const jid = jobRef.current; + jobRef.current = null; + if (jid) { + try { await fetch(`/api/admin/resource/template-scan-jobs/${jid}/cancel`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); } catch {} + } + setErr("اسکن لغو شد."); setStep("error"); + }; // shared: send a scan to content for a dry-run diff const preview = useCallback(async (scanResult: unknown) => { @@ -60,6 +78,7 @@ export function ProjectScanImport({ projectId, onClose, onApplied }: { // Quick scan — headless Go parser (no AE) const runQuick = async () => { + jobRef.current = null; // quick scan is synchronous — no timer/cancel setStep("scanning"); setErr(null); setStatusMsg("در حال خواندن ساختار پروژه از فایل AEP…"); const r = await fetch(`/api/admin/resource/template-scans/${projectId}/quick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); const d = await r.json().catch(() => null); @@ -69,19 +88,21 @@ export function ProjectScanImport({ projectId, onClose, onApplied }: { // Full scan — queue an AE job on a render node, then poll const runFull = async () => { - setStep("scanning"); setErr(null); setStatusMsg("در حال ارسال کار اسکن به نود افترافکت…"); - const r = await fetch(`/api/admin/resource/template-scans/${projectId}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); + setStep("scanning"); setErr(null); setElapsed(0); setStatusMsg("در حال ارسال کار اسکن به نود افترافکت…"); + const r = await fetch(`/api/admin/resource/template-scans/${projectId}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }) }); const d = await r.json().catch(() => null); if (!r.ok || !d?.id) { fail(d?.error ?? "ایجاد کار اسکن ناموفق بود"); return; } const jobId = d.id; + jobRef.current = jobId; + timerRef.current = setInterval(() => setElapsed((e) => e + 1), 1000); const started = Date.now(); const poll = async () => { const jr = await fetch(`/api/admin/resource/template-scan-jobs/${jobId}`, { cache: "no-store" }); const job = await jr.json().catch(() => null); if (!jr.ok) { fail(job?.error ?? "خطا در دریافت وضعیت اسکن"); return; } - if (job.status === "done") { await preview(job.result); return; } - if (job.status === "error") { fail("اسکن روی نود ناموفق بود: " + (job.error ?? "")); return; } - if (Date.now() - started > 6 * 60 * 1000) { fail("اسکن طول کشید — آیا یک نود افترافکت آنلاین است؟"); return; } + if (job.status === "done") { stopTimers(); jobRef.current = null; await preview(job.result); return; } + if (job.status === "error" || job.status === "cancelled") { fail("اسکن روی نود ناموفق بود: " + (job.error ?? "")); return; } + if (Date.now() - started > 10 * 60 * 1000) { fail("اسکن طول کشید — آیا یک نود افترافکت آنلاین است؟"); return; } setStatusMsg(job.status === "running" ? "در حال اجرای اسکریپت اسکن در افترافکت…" : "در صف اجرا روی نود…"); pollRef.current = setTimeout(poll, 3000); }; @@ -99,7 +120,7 @@ export function ProjectScanImport({ projectId, onClose, onApplied }: { setDiff(d); setStep("done"); onApplied(); }; - const close = () => { if (pollRef.current) clearTimeout(pollRef.current); onClose(); }; + const close = () => { stopTimers(); jobRef.current = null; onClose(); }; return (
@@ -118,9 +139,18 @@ export function ProjectScanImport({ projectId, onClose, onApplied }: {
اسکن سریع (بدون افترافکت)
فقط نام صحنه‌ها و مدت‌ها را از فایل AEP می‌خواند. فوری، بدون نیاز به نود. رنگ‌ها/فونت‌ها بعداً با اسکن کامل پر می‌شوند.
+
+ + +
)} @@ -129,6 +159,12 @@ export function ProjectScanImport({ projectId, onClose, onApplied }: {

{statusMsg}

+ {step === "scanning" && jobRef.current && ( + <> +

زمان سپری‌شده: {elapsed.toLocaleString("fa-IR")} ثانیه

+ + + )}
)}