/* * 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\\extracted\proj\template.aep * SET FR_SCAN_OUT=C:\work\scans\\scan.json * "...\Adobe After Effects \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) {} })();