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

- 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:
soroush.asadi
2026-06-04 19:06:08 +03:30
parent ee670552a8
commit 6661f53734
17 changed files with 498 additions and 45 deletions
@@ -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) {}
})();
+5 -2
View File
@@ -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;
+76 -19
View File
@@ -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) {}
})();