0a7dd9b84c
Build backend images / build content-svc (push) Failing after 45s
Build backend images / build file-svc (push) Failing after 55s
Build backend images / build gateway (push) Failing after 53s
Build backend images / build identity-svc (push) Failing after 54s
Build backend images / build notification-svc (push) Failing after 53s
Build backend images / build render-svc (push) Failing after 47s
Build backend images / build studio-svc (push) Failing after 51s
- node-agent: internal/metrics — read CPU% (GetSystemTimes), RAM (GlobalMemoryStatusEx), disk used%/total (GetDiskFreeSpaceEx) via stdlib kernel32 (no external dep; windows build + non-windows stub). Heartbeat now reports cpu_pct/ram_available_mb/disk_used_pct/ disk_total_gb + ae_running. - render-svc: heartbeat persists last_disk_pct + disk_total_gb (migration 29); RenderNode model + node SELECT/scan carry them. - admin: rewrite NodesTable to the real RenderNode shape (fixes a pre-existing items/V2Node mismatch that left the list empty) + a CPU/RAM/disk bars column + stale-heartbeat flag. - assets-bundle ingestion: ProjectMediaBundle (jszip) auto-maps project.zip → project/scene image/demo/colour + music; PatchProject gains image/full_demo/shared_colors_svg. - scan: RGBA (4-number) colours recognised + frshare single-int controls detected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
12 KiB
React
253 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) {}
|
|
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";
|
|
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) {}
|
|
})();
|