fix(scan): Fix-mode scanner + dialog suppression + cancel/timer + importer revive
Build backend images / build content-svc (push) Failing after 1m25s
Build backend images / build file-svc (push) Failing after 1m10s
Build backend images / build gateway (push) Failing after 56s
Build backend images / build identity-svc (push) Failing after 53s
Build backend images / build notification-svc (push) Failing after 57s
Build backend images / build render-svc (push) Failing after 48s
Build backend images / build studio-svc (push) Failing after 1m5s
Build backend images / build content-svc (push) Failing after 1m25s
Build backend images / build file-svc (push) Failing after 1m10s
Build backend images / build gateway (push) Failing after 56s
Build backend images / build identity-svc (push) Failing after 53s
Build backend images / build notification-svc (push) Failing after 57s
Build backend images / build render-svc (push) Failing after 48s
Build backend images / build studio-svc (push) Failing after 1m5s
- scan.jsx: app.beginSuppressDialogs() + clean quit (no AE hang on font/footage dialogs); FIX-mode branch parses frl_c(x)t/m(y) layer names → scenes by c(x); flexible/mockup keep comp-based walk; FR_SCAN_MODE selects. - render-svc: scan job carries project mode; cancel endpoint + node watchdog that kills AE on cancel; parseObjectURL handles minio:// (bucket in host); scan with no template fails cleanly; status guards so late results can't un-cancel. - content importer: revive soft-deleted scenes instead of duplicate-inserting (fixes scenes_project_id_key unique violation); orphan diff ignores deleted. - admin: scan dialog gets project-type picker + elapsed timer + Cancel button. - node-agent: AE-2026 wiring (host port 5010, host-reachable presign endpoint), FR_SCAN_MODE plumbing. docs/aep-template-convention.md: per-type naming + bundles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -48,3 +48,6 @@ next-env.d.ts
|
||||
# .NET build output
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
|
||||
# built node-agent binary (large)
|
||||
node-agent.exe
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
|
||||
@@ -38,6 +38,24 @@
|
||||
- `FRLMaker(key)` → if it already contains `frl`/`frd`, keep; else `frl_<key>`.
|
||||
- `FRDMaker(key)` → strip `frl_`, ensure `frd` → `frd_<key>`.
|
||||
|
||||
### 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_<name>` 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<x>` 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_<key>` inside; story duplicates rename `<scene>_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 |
|
||||
| `<name>.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`).
|
||||
|
||||
Binary file not shown.
@@ -21,8 +21,11 @@ public class AepImportService(ContentDbContext db)
|
||||
|
||||
private async Task<ImportDiff> 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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {}
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {}
|
||||
})();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/<bucket>/<key...>. Query/fragment are ignored.
|
||||
// parseObjectURL extracts the bucket and object key from a storage URL. Handles
|
||||
// two forms:
|
||||
// - minio://<bucket>/<key...> (file-svc FileAddress — bucket is the HOST)
|
||||
// - http(s)://host[:port]/<bucket>/<key> (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://<bucket>/<key> — 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]/<bucket>/<key...>
|
||||
p := strings.TrimPrefix(u.Path, "/")
|
||||
if p == "" {
|
||||
return "", "", fmt.Errorf("url has no path: %q", raw)
|
||||
|
||||
@@ -41,9 +41,27 @@ export function ProjectScanImport({ projectId, onClose, onApplied }: {
|
||||
const [diff, setDiff] = useState<ImportDiff | null>(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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const jobRef = useRef<string | null>(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 (
|
||||
<div className="fixed inset-0 z-[60] flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={close}>
|
||||
@@ -118,9 +139,18 @@ export function ProjectScanImport({ projectId, onClose, onApplied }: {
|
||||
<div className="text-sm font-medium text-white">اسکن سریع (بدون افترافکت)</div>
|
||||
<div className="mt-0.5 text-xs text-gray-500">فقط نام صحنهها و مدتها را از فایل AEP میخواند. فوری، بدون نیاز به نود. رنگها/فونتها بعداً با اسکن کامل پر میشوند.</div>
|
||||
</button>
|
||||
<div className="rounded-lg border border-[#262b40] p-3">
|
||||
<label className="mb-1 block text-xs text-gray-400">نوع پروژه (برای اسکن کامل)</label>
|
||||
<select className="w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-2.5 py-1.5 text-sm text-gray-100 outline-none focus:border-indigo-500" value={mode} onChange={(e) => setMode(e.target.value)}>
|
||||
<option value="fix">Fix — صحنهها از نام لایهها (frl_c1t1)</option>
|
||||
<option value="flexible">Flexible — هر صحنه یک کامپوزیشن</option>
|
||||
<option value="mockup">Mockup</option>
|
||||
<option value="musicvisualizer">Music Visualizer</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="w-full rounded-lg border border-[#262b40] p-3 text-right hover:bg-[#161a2e]" onClick={runFull}>
|
||||
<div className="text-sm font-medium text-white">اسکن کامل (روی نود افترافکت)</div>
|
||||
<div className="mt-0.5 text-xs text-gray-500">صحنهها، عناصر (frl_/frd_)، فونتها، چینش و رنگها (frshare) را کامل میخواند. نیازمند یک نود افترافکت آنلاین است.</div>
|
||||
<div className="mt-0.5 text-xs text-gray-500">صحنهها، عناصر، فونتها، چینش و رنگها (frshare) را کامل میخواند. نیازمند یک نود افترافکت آنلاین است.</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -129,6 +159,12 @@ export function ProjectScanImport({ projectId, onClose, onApplied }: {
|
||||
<div className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
|
||||
<p className="text-sm text-gray-300">{statusMsg}</p>
|
||||
{step === "scanning" && jobRef.current && (
|
||||
<>
|
||||
<p className="text-xs text-gray-500">زمان سپریشده: {elapsed.toLocaleString("fa-IR")} ثانیه</p>
|
||||
<button className="rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10" onClick={cancelScan}>لغو اسکن</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user