Files
flatrender/services/node-agent/internal/runner/scan.jsx
T
soroush.asadi 47dd87c60b
Build backend images / build content-svc (push) Failing after 1m13s
Build backend images / build file-svc (push) Failing after 1m35s
Build backend images / build gateway (push) Failing after 57s
Build backend images / build identity-svc (push) Failing after 1m28s
Build backend images / build notification-svc (push) Failing after 53s
Build backend images / build render-svc (push) Failing after 1m4s
Build backend images / build studio-svc (push) Failing after 55s
fix(scan): launch AE with the project as arg to bypass the Home screen
afterfx -r alone leaves AE on its empty Home/Start screen, which blocks the
script from running (AE sits idle on Untitled Project until the scan times out).
Now launch 'afterfx <aep> -r scan.jsx' so the project opens directly; scan.jsx
uses the already-open project and only app.open()s as a fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:13:16 +03:30

259 lines
12 KiB
React

/*
* FlatRender — After Effects template SCANNER (read-only / ExtendScript).
*
* Walks an opened .aep project and emits its structure as JSON for the content
* service to import (scenes, frl_/frd_ elements, frd_/frshare colours).
*
* Launch (headless), with the node agent setting env vars first:
* SET FR_SCAN_AEP=C:\work\templates\cache\<md5>\extracted\proj\template.aep
* SET FR_SCAN_OUT=C:\work\scans\<job>\scan.json
* "...\Adobe After Effects <ver>\Support Files\afterfx.exe" -r "...\scan.jsx"
*
* Conventions (mirrors the legacy NewBrain/JSX binder, in reverse):
* comp "frfinal" → final render comp (recorded, not a scene)
* comp "frshare" → shared colours: each layer name = key, its "Source Text" = colour value
* layer frl_* → editable element: TextLayer→Text (font/size/justify/text), else Media/Audio
* layer frd_* → data/direction layer; if its text looks like a colour → per-scene colour zone
* any comp containing frl_/frd_ layers → a scene (key = comp name, duration = comp.duration)
*
* Output shape == content-svc ScanResult:
* { source, render_comp, scenes:[{key,title,scene_type,default_duration_sec,sort,
* elements:[{key,title,type,default_value,font_face,font_size,justify,width,height,video_support,is_hidden,sort}],
* colors:[{element_key,title,attr_value,default_color,sort}] }],
* shared_colors:[{element_key,title,attr_value,default_color,sort}] }
*/
(function () {
function getenv(name) { try { return $.getenv(name); } catch (e) { return null; } }
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) {
s = String(s); var out = "", i, c;
for (i = 0; i < s.length; i++) {
c = s.charAt(i);
if (c === '"') out += '\\"';
else if (c === '\\') out += '\\\\';
else if (c === '\n') out += '\\n';
else if (c === '\r') out += '\\r';
else if (c === '\t') out += '\\t';
else if (c < ' ') out += '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4);
else out += c;
}
return out;
}
function jstr(v) {
if (v === null || v === undefined) return "null";
var t = typeof v;
if (t === "number") return isFinite(v) ? String(v) : "null";
if (t === "boolean") return v ? "true" : "false";
if (t === "string") return '"' + esc(v) + '"';
if (v instanceof Array) {
var a = [], i; for (i = 0; i < v.length; i++) a.push(jstr(v[i]));
return "[" + a.join(",") + "]";
}
var props = [], k;
for (k in v) { if (v.hasOwnProperty(k)) props.push('"' + esc(k) + '":' + jstr(v[k])); }
return "{" + props.join(",") + "}";
}
// ── colour helpers ────────────────────────────────────────────────────────
function trim(s) { return String(s).replace(/^\s+|\s+$/g, ""); }
function isColor(s) {
if (!s) return false; s = trim(s);
// hex, RGB (3 numbers) or RGBA (4 numbers — the FIX/frshare format, e.g. 253,226,228,255)
return /^#?[0-9a-fA-F]{6}$/.test(s) || /^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(\s*,\s*\d{1,3})?$/.test(s);
}
function normColor(s) {
s = trim(s);
if (/^[0-9a-fA-F]{6}$/.test(s)) return "#" + s; // bare hex → add #
var m = s.match(/^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*\d{1,3})?$/); // r,g,b[,a] → #hex (alpha dropped)
if (m) {
function h(n) { n = Math.max(0, Math.min(255, parseInt(n, 10))); var x = n.toString(16); return x.length === 1 ? "0" + x : x; }
return "#" + h(m[1]) + h(m[2]) + h(m[3]);
}
return s;
}
// A frshare control layer holds a single integer (e.g. 0-3) that an expression
// reads to switch a design variant (e.g. logo = image vs fill-colour overlay).
function isControl(s) { return /^\s*\d+\s*$/.test(String(s)); }
function justifyName(j) {
try {
if (j === ParagraphJustification.LEFT_JUSTIFY) return "LEFT_JUSTIFY";
if (j === ParagraphJustification.RIGHT_JUSTIFY) return "RIGHT_JUSTIFY";
if (j === ParagraphJustification.FULL_JUSTIFY) return "FULL_JUSTIFY";
} catch (e) {}
return "CENTER_JUSTIFY";
}
function readText(layer) {
try {
var st = layer.property("Source Text");
if (!st) return null;
var td = st.value; // TextDocument
return { text: td.text, font: td.font, fontSize: Math.round(td.fontSize), justify: justifyName(td.justification) };
} catch (e) { return null; }
}
function pre3(name) { return String(name).substring(0, 3); }
function scanScene(comp) {
var elements = [], colors = [];
for (var i = 1; i <= comp.numLayers; i++) {
var layer = comp.layer(i), name = layer.name, p = pre3(name);
if (p === "frl") {
var el = { key: name, title: name, sort: i };
var txt = readText(layer);
if (txt) {
el.type = "Text"; el.default_value = txt.text;
el.font_face = txt.font; el.font_size = txt.fontSize; el.justify = txt.justify;
} else {
var src = null; try { src = layer.source; } catch (e) {}
if (src && src.hasVideo === false && src.hasAudio === true) {
el.type = "Audio";
} else {
el.type = "Media";
if (src) { try { el.width = src.width; el.height = src.height; } catch (e) {} }
try { el.video_support = !!(src && src.hasVideo); } catch (e) {}
}
}
elements.push(el);
} else if (p === "frd") {
var t2 = readText(layer);
if (t2 && isColor(t2.text)) {
colors.push({ element_key: name, title: name, attr_value: "fill", default_color: normColor(t2.text), sort: i });
} else {
var del = { key: name, title: name, type: "Text", is_hidden: true, sort: i };
if (t2) { del.default_value = t2.text; del.font_face = t2.font; del.font_size = t2.fontSize; }
elements.push(del);
}
}
}
return { elements: elements, colors: colors };
}
function compHasEditable(comp) {
for (var k = 1; k <= comp.numLayers; k++) {
var pp = pre3(comp.layer(k).name);
if (pp === "frl" || pp === "frd") return true;
}
return false;
}
// ── frshare: shared colours (RGBA) + shared controls (single 0-3 number) ────
function readShared(proj) {
var colors = [], controls = [];
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) continue;
if (isColor(ct.text)) {
colors.push({ element_key: cl.name, title: cl.name, attr_value: "fill", default_color: normColor(ct.text), sort: j });
} else if (isControl(ct.text)) {
controls.push({ element_key: cl.name, title: cl.name, default_value: trim(ct.text), min: 0, max: 3, sort: j });
}
}
}
return { colors: colors, controls: controls };
}
// ── FLEXIBLE / Mockup — each scene IS a comp; layers frl_/frd_ inside ───────
function scanFlexible(proj) {
var sh = readShared(proj);
var result = { source: "ae-jsx", render_comp: null, scenes: [], shared_colors: sh.colors, shared_controls: sh.controls };
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" || 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",
default_duration_sec: Math.round(item.duration * 100) / 100,
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 sh = readShared(proj);
var result = { source: "ae-jsx", render_comp: "frfinal", scenes: [], shared_colors: sh.colors, shared_controls: sh.controls };
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) {}
// AE is usually launched WITH the project as an argument (so the Home screen
// can't block us); only open it ourselves if nothing is loaded yet.
var hasProject = false;
try { hasProject = !!(app.project && app.project.file); } catch (e) {}
if (!hasProject && aepPath) {
try { app.open(new File(aepPath)); } catch (e) {}
}
var proj = app.project;
var result = (mode === "fix" || mode === "musicvisualizer") ? scanFix(proj) : scanFlexible(proj);
var out = new File(outPath);
out.encoding = "UTF-8";
out.open("w");
out.write(jstr(result));
out.close();
}
try {
run();
} catch (e) {
try { var ef = new File(outPath + ".error"); ef.open("w"); ef.write("scan error: " + e.toString()); ef.close(); } catch (e2) {}
}
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) {}
})();