playrig 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/AGENTS.md +106 -0
  2. package/LICENSE +37 -0
  3. package/README.md +77 -2
  4. package/bin/playrig.js +4 -0
  5. package/editions/ae/README.md +144 -0
  6. package/editions/ae/client/aeb.js +9 -0
  7. package/editions/ae/examples/hello.jsx +3 -0
  8. package/editions/ae/examples/inspect.jsx +7 -0
  9. package/editions/ae/examples/snap.jsx +15 -0
  10. package/editions/ae/lib/actions.json +159 -0
  11. package/editions/ae/lib/bridge-api.jsxinc +474 -0
  12. package/editions/ae/lib/dev.jsxinc +21 -0
  13. package/editions/ae/lib/icons/README.md +4 -0
  14. package/editions/ae/lib/icons/dot-error.svg +3 -0
  15. package/editions/ae/lib/icons/dot-idle.svg +3 -0
  16. package/editions/ae/lib/icons/dot-running.svg +3 -0
  17. package/editions/ae/lib/icons/make-png.js +25 -0
  18. package/editions/ae/lib/icons/png/dot-error.png +0 -0
  19. package/editions/ae/lib/icons/png/dot-error@2x.png +0 -0
  20. package/editions/ae/lib/icons/png/dot-idle.png +0 -0
  21. package/editions/ae/lib/icons/png/dot-idle@2x.png +0 -0
  22. package/editions/ae/lib/icons/png/dot-running.png +0 -0
  23. package/editions/ae/lib/icons/png/dot-running@2x.png +0 -0
  24. package/editions/ae/lib/icons/png/power-off.png +0 -0
  25. package/editions/ae/lib/icons/png/power-off@2x.png +0 -0
  26. package/editions/ae/lib/icons/png/power-on.png +0 -0
  27. package/editions/ae/lib/icons/png/power-on@2x.png +0 -0
  28. package/editions/ae/lib/icons/png/settings.png +0 -0
  29. package/editions/ae/lib/icons/png/settings@2x.png +0 -0
  30. package/editions/ae/lib/icons/png/status-error.png +0 -0
  31. package/editions/ae/lib/icons/png/status-error@2x.png +0 -0
  32. package/editions/ae/lib/icons/png/status-ok.png +0 -0
  33. package/editions/ae/lib/icons/png/status-ok@2x.png +0 -0
  34. package/editions/ae/lib/icons/png/status-rejected.png +0 -0
  35. package/editions/ae/lib/icons/png/status-rejected@2x.png +0 -0
  36. package/editions/ae/lib/icons/png/status-timeout.png +0 -0
  37. package/editions/ae/lib/icons/png/status-timeout@2x.png +0 -0
  38. package/editions/ae/lib/icons/png/status-unknown.png +0 -0
  39. package/editions/ae/lib/icons/png/status-unknown@2x.png +0 -0
  40. package/editions/ae/lib/icons/power-off.svg +4 -0
  41. package/editions/ae/lib/icons/power-on.svg +4 -0
  42. package/editions/ae/lib/icons/settings.svg +4 -0
  43. package/editions/ae/lib/icons/status-error.svg +4 -0
  44. package/editions/ae/lib/icons/status-ok.svg +4 -0
  45. package/editions/ae/lib/icons/status-rejected.svg +4 -0
  46. package/editions/ae/lib/icons/status-timeout.svg +4 -0
  47. package/editions/ae/lib/icons/status-unknown.svg +5 -0
  48. package/editions/ae/lib/panel-core.jsxinc +249 -0
  49. package/editions/ae/lib/update-key.pem +11 -0
  50. package/editions/ae/lib/updater.jsxinc +148 -0
  51. package/editions/ae/library-starter/INDEX.md +5 -0
  52. package/editions/ae/library-starter/README.md +161 -0
  53. package/editions/ae/library-starter/_common.jsx +187 -0
  54. package/editions/ae/library-starter/categories.json +9 -0
  55. package/editions/ae/library-starter/recipes/.gitkeep +0 -0
  56. package/editions/ae/package.json +7 -0
  57. package/editions/ae/panel/Playrig.jsx +39 -0
  58. package/editions/ae/panel/core.jsx +1026 -0
  59. package/editions/ae/skills/create-ae-recipe/SKILL.md +85 -0
  60. package/editions/ae/skills/create-ae-recipe/references/ae-patterns.md +88 -0
  61. package/editions/ae/skills/create-ae-recipe/references/video-analysis.md +72 -0
  62. package/editions/ae/skills/create-ae-recipe/scripts/media +5 -0
  63. package/editions/ae/skills/create-ae-recipe/scripts/media.py +253 -0
  64. package/editions/ae/skills/create-ae-recipe/scripts/setup.sh +17 -0
  65. package/editions/ae/skills/create-ae-video/SKILL.md +88 -0
  66. package/editions/ae/skills/create-ae-video/references/templates.md +90 -0
  67. package/editions/ae/skills/create-ae-video/scripts/preflight.js +99 -0
  68. package/editions/ae/skills/use-ae-recipes/SKILL.md +99 -0
  69. package/editions/ae/skills/use-ae-recipes/scripts/contact-sheet.jsx +10 -0
  70. package/editions/ae/skills/use-ae-recipes/scripts/edit-text.jsx +12 -0
  71. package/editions/ae/skills/use-ae-recipes/scripts/remove-comp.jsx +18 -0
  72. package/editions/pr/README.md +60 -0
  73. package/editions/pr/SCORE_PROCESS.md +41 -0
  74. package/editions/pr/client/aeb.js +9 -0
  75. package/editions/pr/examples/frame.js +6 -0
  76. package/editions/pr/examples/hello.js +4 -0
  77. package/editions/pr/organize.default.json +245 -0
  78. package/editions/pr/package.json +7 -0
  79. package/editions/pr/plugin/actions.js +79 -0
  80. package/editions/pr/plugin/dev.js +19 -0
  81. package/editions/pr/plugin/icons/README.md +4 -0
  82. package/editions/pr/plugin/icons/file-code.svg +6 -0
  83. package/editions/pr/plugin/icons/folder.svg +3 -0
  84. package/editions/pr/plugin/icons/power-off.svg +4 -0
  85. package/editions/pr/plugin/icons/power-on.svg +4 -0
  86. package/editions/pr/plugin/icons/settings.svg +4 -0
  87. package/editions/pr/plugin/icons/status-error.svg +4 -0
  88. package/editions/pr/plugin/icons/status-ok.svg +4 -0
  89. package/editions/pr/plugin/icons/status-rejected.svg +4 -0
  90. package/editions/pr/plugin/icons/status-timeout.svg +4 -0
  91. package/editions/pr/plugin/icons/status-unknown.svg +5 -0
  92. package/editions/pr/plugin/incremental.js +212 -0
  93. package/editions/pr/plugin/index.html +195 -0
  94. package/editions/pr/plugin/index.js +710 -0
  95. package/editions/pr/plugin/manifest.json +45 -0
  96. package/editions/pr/plugin/organize.js +223 -0
  97. package/editions/pr/skills/score-video/SKILL.md +69 -0
  98. package/editions/pr/skills/score-video/scripts/mix.py +112 -0
  99. package/editions/pr/skills/score-video/scripts/place.js +63 -0
  100. package/lib/ae.js +754 -0
  101. package/lib/common.js +132 -0
  102. package/lib/install.js +148 -0
  103. package/lib/main.js +107 -0
  104. package/lib/payload.js +60 -0
  105. package/lib/pr.js +239 -0
  106. package/lib/update.js +118 -0
  107. package/package.json +35 -4
  108. package/skills/create-video/SKILL.md +72 -0
  109. package/skills/create-video/references/lessons.md +31 -0
  110. package/skills/create-video/references/project-conventions.md +46 -0
  111. package/skills/create-video/scripts/ae/list-project.jsx +8 -0
  112. package/skills/create-video/scripts/ae/move-chips.jsx +14 -0
  113. package/skills/create-video/scripts/ae/organize-project.jsx +25 -0
  114. package/skills/create-video/scripts/ae/quadrant-chips.jsx +143 -0
  115. package/skills/create-video/scripts/ae/render-comps.jsx +16 -0
  116. package/skills/create-video/scripts/premiere/export-sequences.js +22 -0
  117. package/skills/create-video/scripts/premiere/make-sequences.js +24 -0
  118. package/skills/create-video/scripts/premiere/swap-media.js +43 -0
@@ -0,0 +1,1026 @@
1
+ // Playrig (core): runs ExtendScript jobs dropped into <root>/inbox and writes results to <root>/out/<id>/.
2
+ // ES3 only. Loaded by the stub (Playrig.jsx), which owns #targetengine "playrig" and passes in the panel and this folder.
3
+ // The only network use is the updater (lib/updater.jsxinc): it runs curl for the update check and downloads. See README.md.
4
+
5
+ (function (thisObj) {
6
+ var G = $.global;
7
+ var ID_RE = /^[A-Za-z0-9_\-]{1,64}$/;
8
+ var MAX_BYTES = 2 * 1024 * 1024;
9
+ var SETTINGS = "Playrig";
10
+ thisObj = G.__PLAYRIG_PANEL || thisObj;
11
+ var HERE = G.__PLAYRIG_HERE || new File($.fileName).parent.fsName;
12
+
13
+ // State survives panel reloads because of the persistent engine.
14
+ var VERSION = (function () { // from version.json beside this file (written by the payload builder)
15
+ var f = new File(HERE + "/version.json"), t, m;
16
+ try { if (f.exists) { f.encoding = "UTF-8"; f.open("r"); t = f.read(); f.close(); m = /"version"\s*:\s*"([^"]+)"/.exec(t); if (m) { return m[1]; } } } catch (e) { }
17
+ return "dev";
18
+ })();
19
+ var HISTORY_LIMIT = 50;
20
+ var S = G.__PLAYRIG_STATE;
21
+ if (!S) {
22
+ S = G.__PLAYRIG_STATE = { on: false, busy: false, taskId: null, watchId: null, session: [], ui: null, status: "Idle", seq: 0, loaded: false, projPath: null };
23
+ }
24
+ if (!S.session) { S.session = []; }
25
+ S.here = HERE;
26
+
27
+ // ------------------------------------------------------------ settings
28
+ // Settings saved under the panel's former names ("Playpilot", "AE Bridge") are still read, so an upgrade keeps its folder, lib path and options.
29
+ var LEGACY_SETTINGS = ["Playpilot", "AEBridge"];
30
+ function getSet(k, d) {
31
+ var i;
32
+ try { if (app.settings.haveSetting(SETTINGS, k)) { return app.settings.getSetting(SETTINGS, k); } } catch (e) { }
33
+ for (i = 0; i < LEGACY_SETTINGS.length; i++) {
34
+ try { if (app.settings.haveSetting(LEGACY_SETTINGS[i], k)) { return app.settings.getSetting(LEGACY_SETTINGS[i], k); } } catch (e2) { }
35
+ }
36
+ return d;
37
+ }
38
+ function setSet(k, v) { try { app.settings.saveSetting(SETTINGS, k, String(v)); } catch (e) { } }
39
+
40
+ function getRoot() {
41
+ var p = getSet("root", ""), docs, fresh, legacy;
42
+ if (p) { return new Folder(p); }
43
+ // Default <Documents>/Playrig/ae; an existing <Documents>/AE Bridge keeps being used until the new folder exists.
44
+ docs = Folder.myDocuments.fsName;
45
+ fresh = new Folder(docs + "/Playrig/ae");
46
+ legacy = new Folder(docs + "/AE Bridge");
47
+ return (!new Folder(fresh.fsName + "/inbox").exists && new Folder(legacy.fsName + "/inbox").exists) ? legacy : fresh;
48
+ }
49
+
50
+ function sub(name) { return new Folder(getRoot().fsName + "/" + name); }
51
+
52
+ function ensureDirs() {
53
+ var names = ["inbox", "running", "done", "out", "backups", "rejected"], i, f;
54
+ for (i = 0; i < names.length; i++) {
55
+ f = sub(names[i]);
56
+ if (!f.exists && !f.create()) {
57
+ throw new Error("Cannot create " + f.fsName + ". Enable Preferences > Scripting & Expressions > Allow Scripts to Write Files and Access Network.");
58
+ }
59
+ }
60
+ }
61
+
62
+ // ------------------------------------------------------------ files
63
+ function readText(f) {
64
+ var s;
65
+ f.encoding = "UTF-8";
66
+ if (!f.open("r")) { throw new Error("Cannot open " + f.fsName); }
67
+ s = f.read();
68
+ f.close();
69
+ return s;
70
+ }
71
+ function writeText(f, s) {
72
+ f.encoding = "UTF-8";
73
+ f.lineFeed = "Unix";
74
+ if (!f.open("w")) { throw new Error("Cannot write " + f.fsName); }
75
+ f.write(s);
76
+ f.close();
77
+ }
78
+ function writeAtomic(f, s) {
79
+ var tmp = new File(f.fsName + ".tmp");
80
+ writeText(tmp, s);
81
+ if (f.exists) { f.remove(); }
82
+ if (!tmp.rename(f.name)) { throw new Error("Cannot rename " + tmp.fsName); }
83
+ }
84
+ function moveFile(f, folder) {
85
+ var d = new File(folder.fsName + "/" + f.displayName);
86
+ if (d.exists) { d.remove(); }
87
+ if (!f.copy(d)) { throw new Error("Cannot move " + f.fsName + " to " + folder.fsName); }
88
+ f.remove();
89
+ return d;
90
+ }
91
+ function quote(s) {
92
+ return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\r\n\t]/g, " ").replace(/[^\x20-\x7e]/g, "?") + '"';
93
+ }
94
+ function stamp() {
95
+ var d = new Date();
96
+ function p(n) { return (n < 10 ? "0" : "") + n; }
97
+ return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + "-" + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
98
+ }
99
+ function isoNow(d) {
100
+ var off = -d.getTimezoneOffset(), sign = off < 0 ? "-" : "+";
101
+ function p(n) { return (n < 10 ? "0" : "") + n; }
102
+ off = Math.abs(off);
103
+ return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + "T" + p(d.getHours()) + ":" +
104
+ p(d.getMinutes()) + ":" + p(d.getSeconds()) + sign + p(Math.floor(off / 60)) + ":" + p(off % 60);
105
+ }
106
+
107
+ // Folders that may hold the lib files (bridge-api.jsxinc, panel-core.jsxinc, actions.json, dev.jsxinc, icons/).
108
+ function libDirs() {
109
+ var out = [getSet("lib", ""), HERE, HERE + "/Playrig lib", HERE + "/Playpilot lib", HERE + "/AE Bridge lib", HERE + "/lib", new Folder(HERE).parent.fsName + "/lib", getRoot().fsName + "/lib"], i, r = [], f;
110
+ for (i = 0; i < out.length; i++) {
111
+ if (!out[i]) { continue; }
112
+ f = new File(out[i]);
113
+ // the saved "lib" setting is the path of bridge-api.jsxinc; the others are folders
114
+ r.push(f instanceof File && /\.jsxinc$/i.test(out[i]) ? f.parent.fsName : out[i]);
115
+ }
116
+ return r;
117
+ }
118
+ function findLibFile(name) {
119
+ var d = libDirs(), i, f;
120
+ for (i = 0; i < d.length; i++) {
121
+ f = new File(d[i] + "/" + name);
122
+ if (f.exists) { return f; }
123
+ }
124
+ return null;
125
+ }
126
+ function findLib() { return findLibFile("bridge-api.jsxinc"); }
127
+
128
+ // Loads the UI-free core (actions, history reader) once. bridge-api is evaluated first: its JSON parser is used for results.
129
+ function ensureCore() {
130
+ var lib, core;
131
+ if (G.PlayrigCore && S.coreOk) { return true; }
132
+ lib = findLib();
133
+ if (!lib) { return false; }
134
+ try { if (!G.PLAYRIG) { $.evalFile(lib); } } catch (e) { return false; }
135
+ core = findLibFile("panel-core.jsxinc");
136
+ if (!core) { return false; }
137
+ try { $.evalFile(core); G.PlayrigCore.init(core.parent.fsName); S.coreOk = true; } catch (e2) { return false; }
138
+ try {
139
+ if (!G.PlayrigDev) { core = findLibFile("dev.jsxinc"); if (core) { $.evalFile(core); } }
140
+ } catch (e3) { }
141
+ try { core = findLibFile("updater.jsxinc"); if (core) { $.evalFile(core); } } catch (e4) { }
142
+ return true;
143
+ }
144
+
145
+ function iconFile(name) {
146
+ var f = findLibFile("icons/png/" + name + ".png");
147
+ return f;
148
+ }
149
+ var imgCache = {};
150
+ function icon(name) {
151
+ var f;
152
+ if (imgCache[name]) { return imgCache[name]; }
153
+ f = iconFile(name);
154
+ try { imgCache[name] = f ? ScriptUI.newImage(f) : null; } catch (e) { imgCache[name] = null; }
155
+ return imgCache[name];
156
+ }
157
+
158
+ // ------------------------------------------------------------ UI helpers
159
+ function ui(fn) {
160
+ // Panel widgets become invalid when the panel is closed; report that as false.
161
+ if (!S.ui) { return false; }
162
+ try { fn(S.ui); return true; } catch (e) { return false; }
163
+ }
164
+
165
+ function colorPen(g, rgb) { return g.newPen(g.PenType.SOLID_COLOR, rgb, 1); }
166
+
167
+ function setStatus(text) {
168
+ var dot;
169
+ S.status = text;
170
+ dot = /^Error/.test(text) ? "dot-error" : ((/^Stopped/.test(text) || !S.on) && !S.busy ? "dot-idle" : "dot-running");
171
+ ui(function (u) {
172
+ u.status.text = text;
173
+ if (icon(dot)) { u.dot.image = icon(dot); }
174
+ });
175
+ }
176
+
177
+ // One info line under the list. Info fades after 12 s; problems (sticky) stay until replaced.
178
+ function say(text, sticky) {
179
+ var n = (S.msgSeq = (S.msgSeq || 0) + 1);
180
+ S.msgSticky = !!sticky;
181
+ ui(function (u) { u.msg.text = text || ""; });
182
+ if (text && !sticky) { try { app.scheduleTask("Playrig.clearMsg(" + n + ")", 12000, false); } catch (e) { } }
183
+ }
184
+
185
+ function projectName() {
186
+ var f = null;
187
+ try { f = app.project.file; } catch (e) { }
188
+ return f ? f.displayName.replace(/\.aepx?$/i, "") : "Untitled project (not saved)";
189
+ }
190
+ function projectFile() { try { return app.project.file; } catch (e) { return null; } }
191
+
192
+ // Rows shown in the table: the open project's history from its log, plus this session's jobs of that project that are not in it yet
193
+ // (an unsaved project, a rejected job). Newest first.
194
+ function currentRows() {
195
+ var pf = projectFile(), h = { rows: [], total: 0 }, rows, seen = {}, i, s, path = pf ? pf.fsName : null;
196
+ if (!ensureCore()) { return { rows: [], total: 0, core: false }; }
197
+ if (pf) { try { h = G.PlayrigCore.loadHistory(pf, HISTORY_LIMIT); } catch (e) { h = { rows: [], total: 0 }; } }
198
+ rows = h.rows.slice(0);
199
+ for (i = 0; i < rows.length; i++) { seen[rows[i].id] = true; }
200
+ for (i = 0; i < S.session.length; i++) {
201
+ s = S.session[i];
202
+ if (!seen[s.id] && (s.projectPath || null) === path) { rows.push(s); seen[s.id] = true; }
203
+ }
204
+ rows.sort(function (a, b) { return a.startedAt < b.startedAt ? 1 : (a.startedAt > b.startedAt ? -1 : 0); });
205
+ return { rows: rows.slice(0, HISTORY_LIMIT), total: Math.max(h.total, rows.length), core: true, saved: !!pf };
206
+ }
207
+
208
+ function refreshHistory() {
209
+ var cur = currentRows();
210
+ S.rows = cur.rows;
211
+ ui(function (u) {
212
+ var i, r, item, h, n = cur.rows.length;
213
+ u.list.removeAll();
214
+ for (i = 0; i < n; i++) {
215
+ r = cur.rows[i];
216
+ // Row icons did not render in AE's multi-column listbox, so the status is a glyph plus the word.
217
+ item = u.list.add("item", G.PlayrigCore.statusGlyph(r.status) + " " + r.status);
218
+ item.subItems[0].text = G.PlayrigCore.actionLabel(r.action, r.actionLabel) + (r.actionSource === "inferred" ? " *" : "");
219
+ item.subItems[1].text = G.PlayrigCore.formatTime(r.startedAt);
220
+ }
221
+ u.count.text = n ? (cur.total > n ? "latest " + n + " of " + cur.total : n + (n === 1 ? " job" : " jobs")) : "";
222
+ // The list is only as tall as its rows (header + rows), never a blank block.
223
+ h = 44 + Math.max(1, Math.min(n, 12)) * 21;
224
+ u.list.preferredSize.height = h;
225
+ u.list.minimumSize.height = h;
226
+ u.summary.text = n ? "Select a job to see what it did. Double-click for details." : (cur.core ? "No jobs yet for this project." : "Panel files missing (lib folder not found). See README, Install.");
227
+ u.details.enabled = false;
228
+ u.win.layout.layout(true);
229
+ });
230
+ if (cur.core && !cur.saved) {
231
+ if (!S.unsavedMsg) { S.unsavedMsg = true; say("Save the project to keep its job history with it.", true); }
232
+ } else if (S.unsavedMsg) { S.unsavedMsg = false; say(""); }
233
+ }
234
+
235
+ function showProject() {
236
+ var pf = projectFile();
237
+ S.projPath = pf ? pf.fsName : null;
238
+ ui(function (u) { u.project.text = projectName(); });
239
+ }
240
+
241
+ function sessionPush(r, resultPath) {
242
+ S.session.unshift(G.PlayrigCore.rowFromResult(r, resultPath));
243
+ if (S.session.length > HISTORY_LIMIT) { S.session.length = HISTORY_LIMIT; }
244
+ }
245
+
246
+ function selectedRow() {
247
+ var out = null;
248
+ ui(function (u) { if (u.list.selection && S.rows) { out = S.rows[u.list.selection.index]; } });
249
+ return out;
250
+ }
251
+
252
+ // ------------------------------------------------------------ polling
253
+ function startPolling() {
254
+ if (S.on) { return; }
255
+ try { ensureDirs(); } catch (e) { setStatus("Error"); say("Playrig: " + e.message, true); return; }
256
+ if (!findLib()) {
257
+ var picked = File.openDialog("Locate bridge-api.jsxinc");
258
+ if (!picked) { setStatus("Error"); say("bridge-api.jsxinc not found. See README, Install.", true); return; }
259
+ setSet("lib", picked.fsName);
260
+ }
261
+ ensureCore();
262
+ S.on = true;
263
+ S.taskId = app.scheduleTask("Playrig.tick()", 1000, true);
264
+ setStatus("Idle");
265
+ ui(function (u) { u.power.image = icon("power-on"); u.power.helpTip = "Stop the bridge"; });
266
+ }
267
+
268
+ function stopPolling() {
269
+ if (S.taskId !== null) { try { app.cancelTask(S.taskId); } catch (e) { } }
270
+ S.taskId = null;
271
+ S.on = false;
272
+ setStatus(S.busy ? S.status : "Stopped");
273
+ ui(function (u) { u.power.image = icon("power-off"); u.power.helpTip = "Start the bridge"; });
274
+ }
275
+
276
+ // ------------------------------------------------------------ rejection
277
+ function simpleResult(id, status, message, extra) {
278
+ var f = new Folder(sub("out").fsName + "/" + id), p = projInfo(), file;
279
+ if (!f.exists) { f.create(); }
280
+ file = new File(f.fsName + "/result.json");
281
+ writeAtomic(file,
282
+ '{\n "id": ' + quote(id) + ',\n "status": ' + quote(status) + ',\n "startedAt": ' + quote(isoNow(new Date())) +
283
+ ',\n "project": {"path": ' + (p.path ? quote(p.path) : "null") + ', "dirty": ' + (p.dirty === null ? "null" : String(p.dirty)) + '}' +
284
+ ',\n "action": ' + quote(status === "rejected" ? "job.rejected" : "custom") + ',\n "actionSource": "declared"' +
285
+ ',\n "error": {"message": ' + quote(message) + '}' + (extra || "") + ',\n "log": [],\n "snapshots": []\n}\n');
286
+ return file;
287
+ }
288
+
289
+ function reject(f, reason) {
290
+ var base = f.displayName.replace(/\.jsx$/i, ""), id, dest, rf;
291
+ if (ID_RE.test(base)) { id = base; }
292
+ else { S.seq++; id = "rejected-" + stamp() + "-" + S.seq; }
293
+ rf = simpleResult(id, "rejected", reason, ',\n "originalName": ' + quote(f.displayName));
294
+ dest = sub("rejected");
295
+ try {
296
+ if (id !== base) { f.rename(id + ".jsx"); f = new File(f.parent.fsName + "/" + id + ".jsx"); }
297
+ moveFile(f, dest);
298
+ } catch (e) { try { f.remove(); } catch (e2) { } }
299
+ if (ensureCore()) {
300
+ sessionPush({ id: id, status: "rejected", startedAt: isoNow(new Date()), action: "job.rejected", actionSource: "declared", description: null,
301
+ error: { message: reason }, project: projInfo() }, rf.fsName);
302
+ refreshHistory();
303
+ }
304
+ }
305
+
306
+ // ------------------------------------------------------------ job pick
307
+ function pickJob() {
308
+ var files = sub("inbox").getFiles("*.jsx"), valid = [], i, f, base;
309
+ for (i = 0; i < files.length; i++) {
310
+ f = files[i];
311
+ if (!(f instanceof File)) { continue; }
312
+ base = f.displayName.replace(/\.jsx$/i, "");
313
+ if (!ID_RE.test(base)) { reject(f, "File name must match ^[A-Za-z0-9_-]{1,64}$"); continue; }
314
+ if (f.length > MAX_BYTES) { reject(f, "Job file is larger than 2 MB"); continue; }
315
+ valid.push(f);
316
+ }
317
+ if (!valid.length) { return null; }
318
+ valid.sort(function (a, b) { return a.modified.getTime() - b.modified.getTime(); });
319
+ return valid[0];
320
+ }
321
+
322
+ // ------------------------------------------------------------ run
323
+ function errInfo(e, file) {
324
+ var info = { message: e && e.message ? e.message : String(e), line: e && e.line ? Number(e.line) : null,
325
+ fileName: e && e.fileName ? String(e.fileName) : null, source: null }, lines;
326
+ if (file) { info.fileName = file.fsName; }
327
+ try {
328
+ if (file && info.line) {
329
+ lines = readText(file).split(/\r\n|\r|\n/);
330
+ info.source = lines[info.line - 1] !== undefined ? lines[info.line - 1] : null;
331
+ }
332
+ } catch (e2) { }
333
+ return info;
334
+ }
335
+
336
+ function projInfo() {
337
+ var p = { path: null, dirty: null };
338
+ try { if (app.project.file) { p.path = app.project.file.fsName; } } catch (e) { }
339
+ try { if (app.project.dirty !== undefined) { p.dirty = app.project.dirty; } } catch (e2) { }
340
+ return p;
341
+ }
342
+
343
+ // Copies the project file on disk. app.project.save(File) is "Save As" and would re-point the open project
344
+ // at the backup, so a plain file copy of the last-saved state is used instead.
345
+ function backupProject(id, result) {
346
+ var src = app.project.file, dest;
347
+ if (!src || !src.exists) { result.backup = { ok: false, reason: "project has never been saved" }; return; }
348
+ try {
349
+ dest = new File(sub("backups").fsName + "/" + src.displayName.replace(/\.aepx?$/i, "") + "-" + id + ".aep");
350
+ result.backup = { ok: src.copy(dest), file: dest.fsName, reflectsUnsavedChanges: false };
351
+ } catch (e) { result.backup = { ok: false, reason: e.message }; }
352
+ }
353
+
354
+ // ------------------------------------------------------------ project-folder sync
355
+ // Everything a job produces is mirrored into <project folder>/EDIT_LOGS/ (same folder name as Playrig for Premiere Pro; an older
356
+ // AE-BRIDGE/ folder is renamed to it on the first job) so the project can move to another machine and the next session can
357
+ // read what was done and which assets exist.
358
+ var WS_README = "# EDIT_LOGS\n\n" +
359
+ "Written automatically by Playrig for After Effects next to the .aep. Move this folder together with the project.\n\n" +
360
+ "- `JOURNAL.md`: chronological record of every job (newest last): what ran, status, errors, log, snapshots.\n" +
361
+ "- `jobs/<id>.jsx` (+ `<id>.json` options): the exact scripts that were run on this project.\n" +
362
+ "- `results/<id>/`: `result.json` and the PNG snapshots of each job.\n" +
363
+ "- `assets.ae.md` / `assets.ae.json`: footage, comps and fonts in the project as of the last job (paths relative to the project folder). " +
364
+ "(Named `.ae` so they do not overwrite Playrig for Premiere Pro's `assets.md` when both projects share a folder.)\n\n" +
365
+ "To continue on another system: open the .aep, read `JOURNAL.md` and `assets.ae.md`, re-link any footage marked MISSING, " +
366
+ "install the listed fonts, then submit new jobs with Playrig as usual. Re-running `jobs/*.jsx` in order rebuilds the scripted work.\n";
367
+
368
+ function projectDir() {
369
+ var f = null;
370
+ try { f = app.project.file; } catch (e) { }
371
+ return f ? f.parent : null;
372
+ }
373
+
374
+ function copyInto(src, folder) {
375
+ var d;
376
+ if (!folder.exists) { folder.create(); }
377
+ d = new File(folder.fsName + "/" + src.displayName);
378
+ if (d.exists) { d.remove(); }
379
+ return src.copy(d);
380
+ }
381
+
382
+ function appendText(f, s) {
383
+ f.encoding = "UTF-8";
384
+ f.lineFeed = "Unix";
385
+ if (!f.open("a")) { throw new Error("Cannot append to " + f.fsName); }
386
+ f.write(s);
387
+ f.close();
388
+ }
389
+
390
+ function assetsMarkdown(a) {
391
+ var s = "# Project assets\n\nGenerated " + a.generatedAt + " by Playrig. Paths are relative to the project folder unless marked external.\n\n", i, x;
392
+ s += "## Footage (" + a.footage.length + ")\n\n";
393
+ for (i = 0; i < a.footage.length; i++) {
394
+ x = a.footage[i];
395
+ s += "- " + x.name + (x.type === "solid" ? " (solid)" : "") + (x.file ? ": `" + x.file + "`" : "") +
396
+ (x.external ? " **EXTERNAL, will not move with the project**" : "") + (x.missing ? " **MISSING**" : "") + "\n";
397
+ }
398
+ s += "\n## Comps (" + a.comps.length + ")\n\n";
399
+ for (i = 0; i < a.comps.length; i++) {
400
+ x = a.comps[i];
401
+ s += "- " + x.name + ": " + x.width + "x" + x.height + ", " + x.fps + " fps, " + x.duration + " s, " + x.numLayers + " layers\n";
402
+ }
403
+ s += "\n## Fonts used by text layers\n\n";
404
+ for (i = 0; i < a.fonts.length; i++) { s += "- " + a.fonts[i] + "\n"; }
405
+ return s;
406
+ }
407
+
408
+ function journalEntry(result, jobFile) {
409
+ var s = "\n## " + result.id + " [" + result.status + "]\n\n" + (result.description ? "- what: " + result.description + "\n" : "") +
410
+ (result.action ? "- action: " + result.action + (result.actionSource === "inferred" ? " (inferred)" : "") + "\n" : "") + "- started: " + result.startedAt + " (" + result.durationMs + " ms), AE " + result.aeVersion + "\n", i, sn;
411
+ if (jobFile) { s += "- script: `jobs/" + jobFile + "`\n"; }
412
+ if (result.error) { s += "- error: " + result.error.message + (result.error.line ? " (line " + result.error.line + ")" : "") + "\n"; }
413
+ if (result.returnValue !== null && result.returnValue !== undefined) { s += "- return: `" + PLAYRIG.json.stringify(result.returnValue).substr(0, 500) + "`\n"; }
414
+ if (result.log && result.log.length) {
415
+ s += "- log:\n";
416
+ for (i = 0; i < result.log.length && i < 100; i++) { s += " " + String(result.log[i]).replace(/[\r\n]+/g, " ") + "\n"; }
417
+ }
418
+ for (i = 0; result.snapshots && i < result.snapshots.length; i++) {
419
+ sn = result.snapshots[i];
420
+ if (sn.ok) { s += "- snapshot: `results/" + result.id + "/" + sn.file + "` (" + sn.comp + " @ " + sn.time + ")\n"; }
421
+ }
422
+ return s;
423
+ }
424
+
425
+ function syncOne(ws, id, runFile, optFile, outDir) {
426
+ var res = new Folder(ws.fsName + "/results/" + id), files, i, f;
427
+ if (runFile && runFile.exists) { copyInto(runFile, new Folder(ws.fsName + "/jobs")); }
428
+ if (optFile && optFile.exists) { copyInto(optFile, new Folder(ws.fsName + "/jobs")); }
429
+ if (!res.exists) { res.create(); }
430
+ files = outDir.getFiles();
431
+ for (i = 0; i < files.length; i++) {
432
+ f = files[i];
433
+ if (f instanceof File && !/\.tmp$/i.test(f.name)) { copyInto(f, res); }
434
+ }
435
+ }
436
+
437
+ // Returns {ok, dir} or {ok:false, reason}. Never throws: a sync problem must not fail the job.
438
+ function syncToProject(id, result, runFile, optFile, outDir) {
439
+ var dir = projectDir(), ws, readme, assets, i, pend, p, legacy, migrated;
440
+ if (!dir) {
441
+ S.unsynced = S.unsynced || [];
442
+ S.unsynced.push(id);
443
+ return { ok: false, reason: "project has never been saved, so it has no folder; will sync after the next job once it is saved" };
444
+ }
445
+ try {
446
+ ws = new Folder(dir.fsName + "/" + G.PlayrigCore.logFolder());
447
+ legacy = new Folder(dir.fsName + "/" + G.PlayrigCore.legacyLogFolder());
448
+ migrated = false;
449
+ if (!ws.exists && legacy.exists) {
450
+ // One-time migration of the old AE-BRIDGE/ log folder; if the rename is refused, keep using the old folder.
451
+ if (legacy.rename(ws.name)) { migrated = true; ws = new Folder(dir.fsName + "/" + G.PlayrigCore.logFolder()); }
452
+ else { ws = legacy; }
453
+ }
454
+ if (!ws.exists && !ws.create()) { return { ok: false, reason: "cannot create " + ws.fsName }; }
455
+ readme = new File(ws.fsName + "/README.md");
456
+ if (!readme.exists || migrated) { writeText(readme, WS_README); }
457
+
458
+ // Jobs that ran before the project was first saved.
459
+ pend = S.unsynced || [];
460
+ S.unsynced = [];
461
+ for (i = 0; i < pend.length; i++) {
462
+ try {
463
+ p = new Folder(sub("out").fsName + "/" + pend[i]);
464
+ syncOne(ws, pend[i], new File(sub("done").fsName + "/" + pend[i] + ".jsx"), new File(sub("done").fsName + "/" + pend[i] + ".json"), p);
465
+ appendText(new File(ws.fsName + "/JOURNAL.md"), "\n## " + pend[i] + " (ran before the project was first saved; see results/" + pend[i] + "/result.json)\n");
466
+ } catch (e0) { }
467
+ }
468
+
469
+ result.sync = { ok: true, dir: ws.fsName };
470
+ if (migrated) { result.sync.migratedFrom = G.PlayrigCore.legacyLogFolder(); }
471
+ // result.json in the workspace must include the sync block, so rewrite it before copying.
472
+ writeAtomic(new File(outDir.fsName + "/result.json"), PLAYRIG.json.stringify(result, 2) + "\n");
473
+ syncOne(ws, id, runFile, optFile, outDir);
474
+
475
+ if (!new File(ws.fsName + "/JOURNAL.md").exists) { writeText(new File(ws.fsName + "/JOURNAL.md"), "# Playrig journal\n\nProject: " + app.project.file.displayName + "\n"); }
476
+ appendText(new File(ws.fsName + "/JOURNAL.md"), journalEntry(result, runFile ? runFile.displayName : null));
477
+
478
+ assets = PLAYRIG._assets(dir.fsName);
479
+ writeText(new File(ws.fsName + "/assets.ae.json"), PLAYRIG.json.stringify(assets, 2) + "\n");
480
+ writeText(new File(ws.fsName + "/assets.ae.md"), assetsMarkdown(assets));
481
+ if (migrated) { try { new File(ws.fsName + "/assets.json").remove(); new File(ws.fsName + "/assets.md").remove(); } catch (e11) { } } // the old names; this app regenerates them as assets.ae.*
482
+ return result.sync;
483
+ } catch (e) {
484
+ return { ok: false, reason: e.message };
485
+ }
486
+ }
487
+
488
+ function runJob(inboxFile) {
489
+ var id = inboxFile.displayName.replace(/\.jsx$/i, "");
490
+ var root = getRoot(), t0 = new Date().getTime(), opts = {}, result, runFile, optInbox, optFile, outDir, lib;
491
+ var suppressed = false, undoOpen = false, ready = false, rf;
492
+ var elapsed, list, i, j, o, sn, jobSrc = "", cls;
493
+
494
+ S.busy = true;
495
+ setStatus("Running " + id);
496
+ ensureCore();
497
+ try {
498
+ runFile = moveFile(inboxFile, sub("running"));
499
+ optInbox = new File(sub("inbox").fsName + "/" + id + ".json");
500
+ optFile = optInbox.exists ? moveFile(optInbox, sub("running")) : null;
501
+ outDir = new Folder(sub("out").fsName + "/" + id);
502
+ if (!outDir.exists) { outDir.create(); }
503
+ // Clear a stale result from an earlier job with the same id.
504
+ if (new File(outDir.fsName + "/result.json").exists) { new File(outDir.fsName + "/result.json").remove(); }
505
+
506
+ result = { id: id, status: "ok", startedAt: isoNow(new Date(t0)), durationMs: 0, aeVersion: String(app.version),
507
+ project: projInfo(), action: "custom", actionSource: "inferred", description: null, error: null, log: [], snapshots: [], inspect: null, returnValue: null };
508
+
509
+ G.__PLAYRIG_CTX = { root: root.fsName, outDir: outDir.fsName, id: id };
510
+ try {
511
+ lib = findLib();
512
+ if (!lib) { throw new Error("bridge-api.jsxinc not found"); }
513
+ $.evalFile(lib);
514
+ if (optFile) { opts = PLAYRIG.json.parse(readText(optFile)); }
515
+ jobSrc = readText(runFile);
516
+ if (jobSrc.charCodeAt(0) === 0xFEFF) { jobSrc = jobSrc.substr(1); }
517
+ try { cls = G.PlayrigCore.classify(opts, jobSrc, id); } catch (eC) { cls = { action: "custom", actionSource: "inferred", actionLabel: null, description: null }; }
518
+ result.action = cls.action;
519
+ result.actionSource = cls.actionSource;
520
+ if (cls.actionLabel) { result.actionLabel = cls.actionLabel; }
521
+ result.description = cls.description;
522
+ ready = true;
523
+ } catch (e0) {
524
+ result.status = optFile && G.PLAYRIG ? "rejected" : "error";
525
+ if (result.status === "rejected") { result.action = "job.rejected"; result.actionSource = "declared"; }
526
+ result.error = errInfo(e0, null);
527
+ }
528
+
529
+ if (ready) {
530
+ if (getSet("backup", "1") === "1") { backupProject(id, result); }
531
+ try {
532
+ app.beginSuppressDialogs();
533
+ suppressed = true;
534
+ if (opts.undoGroup !== false) { app.beginUndoGroup("Playrig: " + id); undoOpen = true; }
535
+ // Overriding $.global.alert has no effect on the job, so the job runs inside a function that
536
+ // shadows alert/confirm. The prefix has no newline, so reported line numbers match the file.
537
+ // ExtendScript's eval treats a comment such as "// @action x" as a preprocessor directive and throws a syntax error, so
538
+ // the comment marker is defused in the evaluated copy only (one extra character on the same line, so line numbers still match).
539
+ eval("(function (alert, confirm) {" + jobSrc.replace(/^([ \t]*\/\/)([ \t]*)@/gm, "$1$2~@") + "\n})(" +
540
+ "function (m) { PLAYRIG.log('[alert] ' + m); }, " +
541
+ "function (m) { PLAYRIG.log('[confirm -> false] ' + m); return false; });");
542
+ } catch (e1) {
543
+ result.status = "error";
544
+ result.error = errInfo(e1, runFile);
545
+ } finally {
546
+ if (undoOpen) { try { app.endUndoGroup(); } catch (e2) { } }
547
+ if (suppressed) { try { app.endSuppressDialogs(false); } catch (e3) { } }
548
+ }
549
+
550
+ elapsed = new Date().getTime() - t0;
551
+ // ExtendScript cannot be interrupted, so a timeout is only detected after the job returns.
552
+ if (result.status === "ok" && opts.timeoutSec && elapsed > opts.timeoutSec * 1000) {
553
+ result.status = "timeout";
554
+ result.error = { message: "Job ran " + Math.round(elapsed / 1000) + " s, past timeoutSec=" + opts.timeoutSec + " (not interrupted)", line: null, fileName: null, source: null };
555
+ }
556
+
557
+ // Snapshots: requested in options, plus any queued by the job.
558
+ list = [];
559
+ if (opts.snapshots) {
560
+ for (i = 0; i < opts.snapshots.length; i++) {
561
+ sn = opts.snapshots[i];
562
+ for (j = 0; j < (sn.times ? sn.times.length : 0); j++) { list.push({ comp: sn.comp, time: sn.times[j] }); }
563
+ }
564
+ }
565
+ o = PLAYRIG._snapQueue();
566
+ for (i = 0; i < o.length; i++) { list.push(o[i]); }
567
+ try { result.snapshots = PLAYRIG._takeSnapshots(list, outDir); }
568
+ catch (e4) { result.snapshots = [{ ok: false, error: e4.message }]; }
569
+
570
+ if (opts.inspect && opts.inspect.comp) {
571
+ try { result.inspect = PLAYRIG.inspect(opts.inspect.comp, opts.inspect.depth, opts.inspect.time); }
572
+ catch (e5) { result.inspect = { error: e5.message }; }
573
+ }
574
+
575
+ result.log = PLAYRIG._log();
576
+ result.returnValue = PLAYRIG._ret();
577
+ result.project = projInfo();
578
+ }
579
+
580
+ result.durationMs = new Date().getTime() - t0;
581
+ try {
582
+ writeAtomic(new File(outDir.fsName + "/result.json"), PLAYRIG.json.stringify(result, 2) + "\n");
583
+ } catch (e6) {
584
+ result.returnValue = "(not serialisable: " + e6.message + ")";
585
+ result.inspect = null;
586
+ writeAtomic(new File(outDir.fsName + "/result.json"), PLAYRIG.json.stringify(result, 2) + "\n");
587
+ }
588
+ // Mirror into the project folder (journal, job script, results, asset manifest) while the job files are
589
+ // still in running/, then rewrite result.json with the outcome so the root copy carries it too.
590
+ if (ready) {
591
+ result.sync = syncToProject(id, result, runFile, optFile, outDir);
592
+ try { writeAtomic(new File(outDir.fsName + "/result.json"), PLAYRIG.json.stringify(result, 2) + "\n"); } catch (e9) { }
593
+ if (result.sync.ok) {
594
+ try { copyInto(new File(outDir.fsName + "/result.json"), new Folder(result.sync.dir + "/results/" + id)); } catch (e10) { }
595
+ }
596
+ }
597
+ try { moveFile(runFile, sub("done")); if (optFile) { moveFile(optFile, sub("done")); } } catch (e7) { }
598
+ sessionPush(result, outDir.fsName + "/result.json");
599
+ refreshHistory();
600
+ setStatus(result.status === "error" ? "Error (" + id + ")" : "Idle");
601
+ } catch (fatal) {
602
+ try {
603
+ rf = simpleResult(id, "error", "Playrig failure: " + fatal.message);
604
+ sessionPush({ id: id, status: "error", startedAt: isoNow(new Date()), action: "custom", actionSource: "declared", error: { message: "Playrig failure: " + fatal.message }, project: projInfo() }, rf.fsName);
605
+ refreshHistory();
606
+ } catch (e8) { }
607
+ setStatus("Error");
608
+ } finally {
609
+ S.busy = false;
610
+ }
611
+ }
612
+
613
+ // ------------------------------------------------------------ public engine API
614
+ G.Playrig = {
615
+ tick: function () {
616
+ var job;
617
+ if (!S.on || S.busy) { return; }
618
+ // Panel closed: its widgets are invalid, so stop polling.
619
+ if (!ui(function (u) { var t = u.status.text; })) { stopPolling(); return; }
620
+ try {
621
+ job = pickJob();
622
+ if (job) { runJob(job); }
623
+ } catch (e) {
624
+ S.busy = false;
625
+ setStatus("Error");
626
+ }
627
+ },
628
+ // Runs even when the bridge is stopped (own task): follows the open project so the table always shows ITS jobs.
629
+ watch: function () {
630
+ var pf, p;
631
+ if (S.busy) { return; }
632
+ if (!ui(function (u) { var t = u.status.text; })) { stopWatching(); return; }
633
+ pf = projectFile();
634
+ p = pf ? pf.fsName : null;
635
+ if (p !== S.projPath) { showProject(); refreshHistory(); }
636
+ },
637
+ clearMsg: function (n) { if (n === S.msgSeq && !S.msgSticky) { ui(function (u) { u.msg.text = ""; }); } },
638
+ start: startPolling,
639
+ stop: stopPolling
640
+ };
641
+
642
+ function startWatching() {
643
+ if (S.watchId !== null && S.watchId !== undefined) { try { app.cancelTask(S.watchId); } catch (e) { } }
644
+ S.watchId = app.scheduleTask("Playrig.watch()", 1500, true);
645
+ }
646
+ function stopWatching() {
647
+ if (S.watchId !== null && S.watchId !== undefined) { try { app.cancelTask(S.watchId); } catch (e) { } }
648
+ S.watchId = null;
649
+ }
650
+
651
+ // ------------------------------------------------------------ links
652
+ function openUrl(url) {
653
+ try {
654
+ if ($.os.indexOf("Windows") >= 0) { system.callSystem('cmd /c start "" "' + url + '"'); }
655
+ else { system.callSystem('open "' + url + '"'); }
656
+ } catch (e) { say("Cannot open " + url, true); }
657
+ }
658
+
659
+ // ------------------------------------------------------------ job details window
660
+ function openDetails(row) {
661
+ var r = null, secs, dev = [], dlg, head, body, i, s, g, lines, et, w = 460, logDir = null, pf = projectFile();
662
+ if (!row || !ensureCore()) { return; }
663
+ try { r = G.PlayrigCore.readResult(row); } catch (e) { r = null; }
664
+ if (!r) {
665
+ // The live row of an unsaved project may only exist in memory.
666
+ r = { id: row.id, status: row.status, startedAt: row.startedAt, durationMs: row.durationMs, action: row.action, actionSource: row.actionSource,
667
+ actionLabel: row.actionLabel, description: row.description, error: row.error ? { message: row.error } : null, log: [], snapshots: [], project: { path: row.projectPath } };
668
+ }
669
+ secs = G.PlayrigCore.detail(r, row);
670
+ if (G.PlayrigDev && G.PlayrigDev.jobDetail) {
671
+ try {
672
+ logDir = G.PlayrigCore.logDir(pf);
673
+ dev = G.PlayrigDev.jobDetail({ logDir: logDir, id: row.id, readText: readText }) || [];
674
+ } catch (e2) { dev = []; }
675
+ }
676
+ secs = secs.concat(dev);
677
+
678
+ dlg = new Window("dialog", "Job details");
679
+ dlg.orientation = "column";
680
+ dlg.alignChildren = ["fill", "top"];
681
+ dlg.margins = 16;
682
+ dlg.spacing = 8;
683
+ head = dlg.add("group");
684
+ head.orientation = "row";
685
+ head.alignChildren = ["left", "center"];
686
+ head.spacing = 8;
687
+ if (icon(G.PlayrigCore.statusIconName(r.status))) { head.add("image", undefined, icon(G.PlayrigCore.statusIconName(r.status))); }
688
+ s = head.add("statictext", undefined, G.PlayrigCore.actionLabel(r.action || row.action, r.actionLabel || row.actionLabel));
689
+ try { s.graphics.font = ScriptUI.newFont("dialog", "BOLD", 13); } catch (e3) { }
690
+ head.add("statictext", undefined, " " + r.status);
691
+
692
+ body = dlg.add("group");
693
+ body.orientation = "column";
694
+ body.alignChildren = ["fill", "top"];
695
+ body.spacing = 6;
696
+ for (i = 0; i < secs.length; i++) {
697
+ g = body.add("group");
698
+ if (secs[i].multi) {
699
+ g.orientation = "column";
700
+ g.alignChildren = ["fill", "top"];
701
+ g.spacing = 2;
702
+ g.add("statictext", undefined, secs[i].title);
703
+ lines = String(secs[i].text).split(/\r\n|\r|\n/).length;
704
+ et = g.add("edittext", undefined, String(secs[i].text), { multiline: true, readonly: true, scrollable: true });
705
+ et.preferredSize = [w, Math.min(Math.max(lines, 1), 10) * 15 + 8];
706
+ } else {
707
+ g.orientation = "row";
708
+ g.alignChildren = ["left", "top"];
709
+ g.spacing = 8;
710
+ s = g.add("statictext", undefined, secs[i].title);
711
+ s.preferredSize.width = 96;
712
+ try { s.graphics.foregroundColor = colorPen(s.graphics, [0.55, 0.55, 0.55, 1]); } catch (e4) { }
713
+ s = g.add("statictext", undefined, String(secs[i].text), { multiline: true });
714
+ s.preferredSize.width = w - 104;
715
+ }
716
+ }
717
+ g = dlg.add("group");
718
+ g.alignment = ["right", "top"];
719
+ g.margins = [0, 8, 0, 0];
720
+ g.add("button", undefined, "Done", { name: "ok" });
721
+ dlg.show();
722
+ }
723
+
724
+ // ------------------------------------------------------------ updates
725
+ // Check: once a day while "Check for updates automatically" is on (default), or when the user presses the button.
726
+ // Install: only on consent (the banner / About button), unless "Install updates automatically" is on (default off).
727
+ var updCfg = false;
728
+ function updater() {
729
+ if (!ensureCore() || !G.PlayrigUpdater) { return null; }
730
+ if (!updCfg) {
731
+ G.PlayrigUpdater.configure({ here: HERE, getSet: getSet, setSet: setSet, libFile: findLibFile, parse: function (t) { return G.PLAYRIG.json.parse(t); } });
732
+ updCfg = true;
733
+ }
734
+ return G.PlayrigUpdater;
735
+ }
736
+ function showBanner(text, canInstall) {
737
+ S.updText = text;
738
+ S.updCanInstall = !!canInstall;
739
+ if (S.ui && S.ui.updRow) {
740
+ S.ui.updText.text = text || "";
741
+ S.ui.updBtn.visible = !!canInstall;
742
+ S.ui.updRow.visible = !!text;
743
+ try { S.ui.win.layout.layout(true); } catch (e) { }
744
+ }
745
+ }
746
+ // Returns { ok, text, info, canInstall }. Never throws.
747
+ function runUpdateCheck() {
748
+ var U = updater(), r, can;
749
+ if (!U) { return { ok: false, text: "The updater is not installed with this panel." }; }
750
+ try { r = U.check(15); } catch (e) { return { ok: false, text: "Update check failed: " + e.message }; }
751
+ if (!r.ok) { return { ok: false, text: r.error }; }
752
+ S.updInfo = r;
753
+ if (!r.newer) { return { ok: true, text: r.error || ("You are up to date (version " + r.current + ")."), info: r }; }
754
+ can = U.canVerify();
755
+ return { ok: true, info: r, canInstall: can, text: "Version " + r.latest + " is available (you have " + r.current + ")." + (r.notes ? " " + r.notes : "") + (can ? "" : " Run `playrig update ae` in a terminal to install it.") };
756
+ }
757
+ function runInstall() {
758
+ var U = updater(), r;
759
+ if (!U || !S.updInfo || !S.updInfo.entry) { return { ok: false, text: "Check for updates first." }; }
760
+ r = U.install(S.updInfo.entry);
761
+ if (!r.ok) { return { ok: false, text: r.error }; }
762
+ S.updInfo = null;
763
+ showBanner("Updated to " + r.version + ". Restart After Effects to use it.", false);
764
+ return { ok: true, text: "Updated to " + r.version + ". Restart After Effects to use it." };
765
+ }
766
+ function autoUpdateCheck() {
767
+ var U, r;
768
+ if (getSet("updateAutoCheck", "1") !== "1") { return; }
769
+ U = updater();
770
+ if (!U || !U.due()) { return; }
771
+ r = runUpdateCheck();
772
+ if (!r.ok || !r.info || !r.info.newer) { return; }
773
+ if (getSet("updateAutoInstall", "0") === "1" && r.canInstall) { r = runInstall(); showBanner(r.text, false); return; }
774
+ showBanner(r.text, r.canInstall);
775
+ }
776
+
777
+ // ------------------------------------------------------------ settings window
778
+ function isAbsPath(p) { return /^(\/|[A-Za-z]:[\\\/]|\\\\)/.test(p); }
779
+
780
+ function openSettings() {
781
+ var dlg = new Window("dialog", "Playrig Settings"), nav, stack, panes = [], p, g, row, rootEdit, browse, hint, cbBackup, cbAuto, msg, i, t, done, prev, updBtn, updInstall, updMsg, cbUpdCheck, cbUpdAuto;
782
+ dlg.orientation = "column";
783
+ dlg.alignChildren = ["fill", "top"];
784
+ dlg.margins = 16;
785
+ dlg.spacing = 12;
786
+
787
+ row = dlg.add("group");
788
+ row.orientation = "row";
789
+ row.alignChildren = ["fill", "top"];
790
+ row.spacing = 16;
791
+ nav = row.add("listbox", undefined, ["General", "About"]);
792
+ nav.preferredSize = [110, 70];
793
+ nav.alignment = ["left", "top"];
794
+ stack = row.add("group");
795
+ stack.orientation = "stack";
796
+ stack.alignment = ["fill", "top"];
797
+ stack.alignChildren = ["fill", "top"];
798
+
799
+ // General
800
+ p = stack.add("group");
801
+ p.orientation = "column";
802
+ p.alignChildren = ["fill", "top"];
803
+ p.spacing = 6;
804
+ p.add("statictext", undefined, "Playrig folder");
805
+ g = p.add("group");
806
+ g.orientation = "row";
807
+ g.alignChildren = ["fill", "center"];
808
+ g.spacing = 6;
809
+ rootEdit = g.add("edittext", undefined, getRoot().fsName);
810
+ rootEdit.alignment = ["fill", "center"];
811
+ rootEdit.preferredSize.width = 270;
812
+ browse = g.add("button", undefined, "Browse...");
813
+ browse.alignment = ["right", "center"];
814
+ hint = p.add("statictext", undefined, "Where jobs and results are exchanged: inbox, out, done, backups. Not your project folder. Keep it private: anything that can write to inbox can run code in the app.", { multiline: true });
815
+ hint.preferredSize = [380, 48];
816
+ try { hint.graphics.foregroundColor = colorPen(hint.graphics, [0.55, 0.55, 0.55, 1]); } catch (e0) { }
817
+ cbAuto = p.add("checkbox", undefined, "Start automatically");
818
+ cbBackup = p.add("checkbox", undefined, "Back up the project file before each job");
819
+ cbAuto.value = getSet("auto", "0") === "1";
820
+ cbBackup.value = getSet("backup", "1") === "1";
821
+ panes.push(p);
822
+
823
+ // About
824
+ p = stack.add("group");
825
+ p.orientation = "column";
826
+ p.alignChildren = ["left", "top"];
827
+ p.spacing = 6;
828
+ t = p.add("statictext", undefined, "Playrig");
829
+ try { t.graphics.font = ScriptUI.newFont("dialog", "BOLD", 13); } catch (e1) { }
830
+ p.add("statictext", undefined, "Version " + VERSION);
831
+ p.add("statictext", undefined, "Developed by Vivek Nigam");
832
+ g = p.add("group");
833
+ g.orientation = "row";
834
+ g.spacing = 6;
835
+ g.margins = [0, 8, 0, 0];
836
+ g.add("statictext", undefined, "Twitter");
837
+ g.add("button", undefined, "@viveknigam_").onClick = function () { openUrl("https://twitter.com/viveknigam_"); };
838
+ g = p.add("group");
839
+ g.orientation = "row";
840
+ g.spacing = 6;
841
+ g.add("statictext", undefined, "Instagram");
842
+ g.add("button", undefined, "@viveknigam3003").onClick = function () { openUrl("https://www.instagram.com/viveknigam3003/"); };
843
+ g = p.add("group");
844
+ g.orientation = "row";
845
+ g.spacing = 6;
846
+ g.margins = [0, 8, 0, 0];
847
+ updBtn = g.add("button", undefined, "Check for Updates");
848
+ updInstall = g.add("button", undefined, "Install update");
849
+ updInstall.visible = false;
850
+ updMsg = p.add("statictext", undefined, S.updText || "", { multiline: true });
851
+ updMsg.preferredSize = [330, 44];
852
+ cbUpdCheck = p.add("checkbox", undefined, "Check for updates automatically (once a day)");
853
+ cbUpdAuto = p.add("checkbox", undefined, "Install updates automatically");
854
+ cbUpdCheck.value = getSet("updateAutoCheck", "1") === "1";
855
+ cbUpdAuto.value = getSet("updateAutoInstall", "0") === "1";
856
+ t = p.add("statictext", undefined, "© 2026 Vivek Nigam. All rights reserved.");
857
+ t = p.add("statictext", undefined, "Icons by Tabler Icons (MIT License)");
858
+ panes.push(p);
859
+
860
+ function showPane(n) { var k; for (k = 0; k < panes.length; k++) { panes[k].visible = (k === n); } }
861
+ nav.onChange = function () { if (nav.selection) { showPane(nav.selection.index); } };
862
+ nav.selection = 0;
863
+ showPane(0);
864
+
865
+ // The modal gets its own message line: messages in the panel are not visible behind it.
866
+ msg = dlg.add("statictext", undefined, "", { truncate: "end" });
867
+ try { msg.graphics.foregroundColor = colorPen(msg.graphics, [0.9, 0.75, 0.2, 1]); } catch (e2) { }
868
+
869
+ function applyRoot(text) {
870
+ var v = text.replace(/^\s+|\s+$/g, ""), f, old = getRoot().fsName;
871
+ if (v === old) { msg.text = ""; return true; }
872
+ if (!isAbsPath(v)) { msg.text = "The bridge folder must be an absolute path."; rootEdit.text = old; return false; }
873
+ f = new Folder(v);
874
+ try {
875
+ if (!f.exists && !f.create()) { throw new Error("Cannot create " + f.fsName); }
876
+ } catch (e) { msg.text = "Cannot use that folder: " + e.message; rootEdit.text = old; return false; }
877
+ setSet("root", f.fsName);
878
+ rootEdit.text = f.fsName;
879
+ try { ensureDirs(); msg.text = ""; } catch (e5) { msg.text = e5.message; setSet("root", old); rootEdit.text = old; return false; }
880
+ return true;
881
+ }
882
+ rootEdit.onChange = function () { applyRoot(rootEdit.text); };
883
+ browse.onClick = function () {
884
+ var f = Folder.selectDialog("Choose the Playrig folder", getRoot());
885
+ if (f) { rootEdit.text = f.fsName; applyRoot(f.fsName); }
886
+ };
887
+ cbAuto.onClick = function () { setSet("auto", cbAuto.value ? "1" : "0"); };
888
+ cbBackup.onClick = function () { setSet("backup", cbBackup.value ? "1" : "0"); };
889
+ cbUpdCheck.onClick = function () { setSet("updateAutoCheck", cbUpdCheck.value ? "1" : "0"); };
890
+ cbUpdAuto.onClick = function () { setSet("updateAutoInstall", cbUpdAuto.value ? "1" : "0"); };
891
+ updBtn.onClick = function () {
892
+ var r;
893
+ updBtn.enabled = false; updMsg.text = "Checking..."; try { dlg.update(); } catch (eU) { }
894
+ r = runUpdateCheck();
895
+ updMsg.text = r.text || "";
896
+ updInstall.visible = !!(r.ok && r.canInstall);
897
+ if (r.ok && r.info && r.info.newer) { showBanner(r.text, r.canInstall); }
898
+ updBtn.enabled = true;
899
+ };
900
+ updInstall.onClick = function () {
901
+ var r;
902
+ updInstall.enabled = false; updMsg.text = "Downloading and verifying..."; try { dlg.update(); } catch (eU2) { }
903
+ r = runInstall();
904
+ updMsg.text = r.text;
905
+ updInstall.visible = false; updInstall.enabled = true;
906
+ };
907
+
908
+ g = dlg.add("group");
909
+ g.alignment = ["right", "top"];
910
+ done = g.add("button", undefined, "Done", { name: "ok" });
911
+ dlg.onClose = function () { if (rootEdit.text !== getRoot().fsName) { applyRoot(rootEdit.text); } return true; };
912
+ dlg.show();
913
+ }
914
+
915
+ // ------------------------------------------------------------ UI
916
+ var win = (thisObj instanceof Panel) ? thisObj : new Window("palette", "Playrig", undefined, { resizeable: true });
917
+ win.orientation = "column";
918
+ win.alignChildren = ["fill", "top"];
919
+ win.spacing = 6;
920
+ win.margins = 10;
921
+
922
+ // Header: title + project name (4 px apart) on the left; status dot, power and settings on the right.
923
+ var header = win.add("group");
924
+ header.orientation = "row";
925
+ header.alignChildren = ["fill", "top"];
926
+ header.spacing = 8;
927
+ var titleCol = header.add("group");
928
+ titleCol.orientation = "column";
929
+ titleCol.alignChildren = ["left", "top"];
930
+ titleCol.alignment = ["fill", "top"];
931
+ titleCol.spacing = 4;
932
+ var title = titleCol.add("statictext", undefined, "Playrig");
933
+ try { title.graphics.font = ScriptUI.newFont("dialog", "BOLD", 14); } catch (eT) { }
934
+ var projectText = titleCol.add("statictext", undefined, projectName(), { truncate: "middle" });
935
+ projectText.alignment = ["fill", "top"];
936
+ try { projectText.graphics.foregroundColor = colorPen(projectText.graphics, [0.55, 0.55, 0.55, 1]); } catch (eP) { }
937
+
938
+ var ctl = header.add("group");
939
+ ctl.orientation = "row";
940
+ ctl.alignChildren = ["right", "center"];
941
+ ctl.alignment = ["right", "top"];
942
+ ctl.spacing = 6;
943
+ var dotImg = ctl.add("image", undefined, icon("dot-idle"));
944
+ var status = ctl.add("statictext", undefined, "Idle", { truncate: "end" });
945
+ status.preferredSize.width = 60;
946
+ var power = ctl.add("iconbutton", undefined, icon(S.on ? "power-on" : "power-off"), { style: "toolbutton" });
947
+ power.preferredSize = [24, 24];
948
+ power.helpTip = S.on ? "Stop the bridge" : "Start the bridge";
949
+ var gear = ctl.add("iconbutton", undefined, icon("settings"), { style: "toolbutton" });
950
+ gear.preferredSize = [24, 24];
951
+ gear.helpTip = "Playrig Settings";
952
+
953
+ var listHead = win.add("group");
954
+ listHead.orientation = "row";
955
+ listHead.alignChildren = ["fill", "center"];
956
+ listHead.margins = [0, 10, 0, 0]; // + the window spacing = a 16 px gap under the project name
957
+ var listTitle = listHead.add("statictext", undefined, "Recent jobs");
958
+ listTitle.alignment = ["left", "center"];
959
+ var countText = listHead.add("statictext", undefined, "", { truncate: "end" });
960
+ countText.alignment = ["fill", "center"];
961
+ countText.justify = "right";
962
+ try { countText.graphics.foregroundColor = colorPen(countText.graphics, [0.55, 0.55, 0.55, 1]); } catch (eC) { }
963
+
964
+ var list = win.add("listbox", undefined, [], { numberOfColumns: 3, showHeaders: true, columnTitles: ["Status", "Action", "Time"], columnWidths: [74, 128, 98], multiselect: false });
965
+ list.preferredSize = [300, 47];
966
+ list.alignment = ["fill", "top"];
967
+
968
+ var sumRow = win.add("group");
969
+ sumRow.orientation = "row";
970
+ sumRow.alignChildren = ["fill", "top"];
971
+ sumRow.spacing = 6;
972
+ var summary = sumRow.add("statictext", undefined, "", { multiline: true });
973
+ summary.alignment = ["fill", "top"];
974
+ summary.preferredSize = [210, 44];
975
+ var detailsBtn = sumRow.add("button", undefined, "Details...");
976
+ detailsBtn.alignment = ["right", "top"];
977
+ detailsBtn.enabled = false;
978
+
979
+ var updRow = win.add("group");
980
+ updRow.orientation = "row";
981
+ updRow.alignChildren = ["fill", "center"];
982
+ updRow.spacing = 6;
983
+ var updText = updRow.add("statictext", undefined, "", { truncate: "end" });
984
+ updText.alignment = ["fill", "center"];
985
+ var updBtn = updRow.add("button", undefined, "Install");
986
+ updBtn.alignment = ["right", "center"];
987
+ updBtn.visible = false;
988
+ updRow.visible = false;
989
+ updBtn.onClick = function () { var r = runInstall(); showBanner(r.text, false); };
990
+
991
+ var msgText = win.add("statictext", undefined, "", { truncate: "end" });
992
+ msgText.alignment = ["fill", "top"];
993
+ try { msgText.graphics.foregroundColor = colorPen(msgText.graphics, [0.9, 0.75, 0.2, 1]); } catch (eM) { }
994
+
995
+ S.ui = { updRow: updRow, updText: updText, updBtn: updBtn, win: win, status: status, dot: dotImg, power: power, list: list, project: projectText, count: countText, summary: summary, details: detailsBtn, msg: msgText };
996
+ status.text = S.status;
997
+
998
+ power.onClick = function () { if (S.on) { stopPolling(); } else { startPolling(); } };
999
+ gear.onClick = function () { openSettings(); };
1000
+ list.onChange = function () {
1001
+ var r = selectedRow();
1002
+ summary.text = r ? G.PlayrigCore.summaryText(r) : "Select a job to see what it did. Double-click for details.";
1003
+ detailsBtn.enabled = !!r;
1004
+ };
1005
+ list.onDoubleClick = function () { var r = selectedRow(); if (r) { openDetails(r); } };
1006
+ detailsBtn.onClick = function () { var r = selectedRow(); if (r) { openDetails(r); } };
1007
+
1008
+ win.onResizing = win.onResize = function () { this.layout.resize(); };
1009
+ win.onClose = function () { stopPolling(); stopWatching(); S.ui = null; };
1010
+
1011
+ if (win instanceof Window) { win.center(); win.show(); } else { win.layout.layout(true); }
1012
+
1013
+ S.unsavedMsg = false;
1014
+ ensureCore();
1015
+ showProject();
1016
+ refreshHistory();
1017
+ setStatus(S.status);
1018
+ startWatching();
1019
+ if (!S.loaded) {
1020
+ S.loaded = true;
1021
+ if (getSet("auto", "0") === "1") { startPolling(); }
1022
+ try { autoUpdateCheck(); } catch (eAU) { }
1023
+ } else if (S.updText) {
1024
+ showBanner(S.updText, S.updCanInstall);
1025
+ }
1026
+ })(this);