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.
- package/AGENTS.md +106 -0
- package/LICENSE +37 -0
- package/README.md +77 -2
- package/bin/playrig.js +4 -0
- package/editions/ae/README.md +144 -0
- package/editions/ae/client/aeb.js +9 -0
- package/editions/ae/examples/hello.jsx +3 -0
- package/editions/ae/examples/inspect.jsx +7 -0
- package/editions/ae/examples/snap.jsx +15 -0
- package/editions/ae/lib/actions.json +159 -0
- package/editions/ae/lib/bridge-api.jsxinc +474 -0
- package/editions/ae/lib/dev.jsxinc +21 -0
- package/editions/ae/lib/icons/README.md +4 -0
- package/editions/ae/lib/icons/dot-error.svg +3 -0
- package/editions/ae/lib/icons/dot-idle.svg +3 -0
- package/editions/ae/lib/icons/dot-running.svg +3 -0
- package/editions/ae/lib/icons/make-png.js +25 -0
- package/editions/ae/lib/icons/png/dot-error.png +0 -0
- package/editions/ae/lib/icons/png/dot-error@2x.png +0 -0
- package/editions/ae/lib/icons/png/dot-idle.png +0 -0
- package/editions/ae/lib/icons/png/dot-idle@2x.png +0 -0
- package/editions/ae/lib/icons/png/dot-running.png +0 -0
- package/editions/ae/lib/icons/png/dot-running@2x.png +0 -0
- package/editions/ae/lib/icons/png/power-off.png +0 -0
- package/editions/ae/lib/icons/png/power-off@2x.png +0 -0
- package/editions/ae/lib/icons/png/power-on.png +0 -0
- package/editions/ae/lib/icons/png/power-on@2x.png +0 -0
- package/editions/ae/lib/icons/png/settings.png +0 -0
- package/editions/ae/lib/icons/png/settings@2x.png +0 -0
- package/editions/ae/lib/icons/png/status-error.png +0 -0
- package/editions/ae/lib/icons/png/status-error@2x.png +0 -0
- package/editions/ae/lib/icons/png/status-ok.png +0 -0
- package/editions/ae/lib/icons/png/status-ok@2x.png +0 -0
- package/editions/ae/lib/icons/png/status-rejected.png +0 -0
- package/editions/ae/lib/icons/png/status-rejected@2x.png +0 -0
- package/editions/ae/lib/icons/png/status-timeout.png +0 -0
- package/editions/ae/lib/icons/png/status-timeout@2x.png +0 -0
- package/editions/ae/lib/icons/png/status-unknown.png +0 -0
- package/editions/ae/lib/icons/png/status-unknown@2x.png +0 -0
- package/editions/ae/lib/icons/power-off.svg +4 -0
- package/editions/ae/lib/icons/power-on.svg +4 -0
- package/editions/ae/lib/icons/settings.svg +4 -0
- package/editions/ae/lib/icons/status-error.svg +4 -0
- package/editions/ae/lib/icons/status-ok.svg +4 -0
- package/editions/ae/lib/icons/status-rejected.svg +4 -0
- package/editions/ae/lib/icons/status-timeout.svg +4 -0
- package/editions/ae/lib/icons/status-unknown.svg +5 -0
- package/editions/ae/lib/panel-core.jsxinc +249 -0
- package/editions/ae/lib/update-key.pem +11 -0
- package/editions/ae/lib/updater.jsxinc +148 -0
- package/editions/ae/library-starter/INDEX.md +5 -0
- package/editions/ae/library-starter/README.md +161 -0
- package/editions/ae/library-starter/_common.jsx +187 -0
- package/editions/ae/library-starter/categories.json +9 -0
- package/editions/ae/library-starter/recipes/.gitkeep +0 -0
- package/editions/ae/package.json +7 -0
- package/editions/ae/panel/Playrig.jsx +39 -0
- package/editions/ae/panel/core.jsx +1026 -0
- package/editions/ae/skills/create-ae-recipe/SKILL.md +85 -0
- package/editions/ae/skills/create-ae-recipe/references/ae-patterns.md +88 -0
- package/editions/ae/skills/create-ae-recipe/references/video-analysis.md +72 -0
- package/editions/ae/skills/create-ae-recipe/scripts/media +5 -0
- package/editions/ae/skills/create-ae-recipe/scripts/media.py +253 -0
- package/editions/ae/skills/create-ae-recipe/scripts/setup.sh +17 -0
- package/editions/ae/skills/create-ae-video/SKILL.md +88 -0
- package/editions/ae/skills/create-ae-video/references/templates.md +90 -0
- package/editions/ae/skills/create-ae-video/scripts/preflight.js +99 -0
- package/editions/ae/skills/use-ae-recipes/SKILL.md +99 -0
- package/editions/ae/skills/use-ae-recipes/scripts/contact-sheet.jsx +10 -0
- package/editions/ae/skills/use-ae-recipes/scripts/edit-text.jsx +12 -0
- package/editions/ae/skills/use-ae-recipes/scripts/remove-comp.jsx +18 -0
- package/editions/pr/README.md +60 -0
- package/editions/pr/SCORE_PROCESS.md +41 -0
- package/editions/pr/client/aeb.js +9 -0
- package/editions/pr/examples/frame.js +6 -0
- package/editions/pr/examples/hello.js +4 -0
- package/editions/pr/organize.default.json +245 -0
- package/editions/pr/package.json +7 -0
- package/editions/pr/plugin/actions.js +79 -0
- package/editions/pr/plugin/dev.js +19 -0
- package/editions/pr/plugin/icons/README.md +4 -0
- package/editions/pr/plugin/icons/file-code.svg +6 -0
- package/editions/pr/plugin/icons/folder.svg +3 -0
- package/editions/pr/plugin/icons/power-off.svg +4 -0
- package/editions/pr/plugin/icons/power-on.svg +4 -0
- package/editions/pr/plugin/icons/settings.svg +4 -0
- package/editions/pr/plugin/icons/status-error.svg +4 -0
- package/editions/pr/plugin/icons/status-ok.svg +4 -0
- package/editions/pr/plugin/icons/status-rejected.svg +4 -0
- package/editions/pr/plugin/icons/status-timeout.svg +4 -0
- package/editions/pr/plugin/icons/status-unknown.svg +5 -0
- package/editions/pr/plugin/incremental.js +212 -0
- package/editions/pr/plugin/index.html +195 -0
- package/editions/pr/plugin/index.js +710 -0
- package/editions/pr/plugin/manifest.json +45 -0
- package/editions/pr/plugin/organize.js +223 -0
- package/editions/pr/skills/score-video/SKILL.md +69 -0
- package/editions/pr/skills/score-video/scripts/mix.py +112 -0
- package/editions/pr/skills/score-video/scripts/place.js +63 -0
- package/lib/ae.js +754 -0
- package/lib/common.js +132 -0
- package/lib/install.js +148 -0
- package/lib/main.js +107 -0
- package/lib/payload.js +60 -0
- package/lib/pr.js +239 -0
- package/lib/update.js +118 -0
- package/package.json +35 -4
- package/skills/create-video/SKILL.md +72 -0
- package/skills/create-video/references/lessons.md +31 -0
- package/skills/create-video/references/project-conventions.md +46 -0
- package/skills/create-video/scripts/ae/list-project.jsx +8 -0
- package/skills/create-video/scripts/ae/move-chips.jsx +14 -0
- package/skills/create-video/scripts/ae/organize-project.jsx +25 -0
- package/skills/create-video/scripts/ae/quadrant-chips.jsx +143 -0
- package/skills/create-video/scripts/ae/render-comps.jsx +16 -0
- package/skills/create-video/scripts/premiere/export-sequences.js +22 -0
- package/skills/create-video/scripts/premiere/make-sequences.js +24 -0
- package/skills/create-video/scripts/premiere/swap-media.js +43 -0
package/lib/ae.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Playrig for After Effects client: submits ExtendScript (.jsx) jobs to the panel, reads results, runs the recipe library.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const C = require('./common');
|
|
7
|
+
const { ID_RE, USAGE, die, writeAtomic, defaultId, sleep, readResult } = C;
|
|
8
|
+
const APP = C.APPS.ae;
|
|
9
|
+
const rootDir = (flags) => C.rootDir(APP, flags);
|
|
10
|
+
const EDITION = C.editionDir('ae');
|
|
11
|
+
|
|
12
|
+
// Shared with the panel (and a later UXP port): the defined action list and its labels.
|
|
13
|
+
const ACTIONS_FILE = path.join(EDITION || __dirname, 'lib', 'actions.json');
|
|
14
|
+
const ACTION_DATA = (() => { try { return JSON.parse(fs.readFileSync(ACTIONS_FILE, 'utf8')); } catch (e) { return { actions: {}, recipePrefix: 'recipe.' }; } })();
|
|
15
|
+
const isRecipeAction = (id) => id.startsWith(ACTION_DATA.recipePrefix || 'recipe.') && id.length > (ACTION_DATA.recipePrefix || 'recipe.').length;
|
|
16
|
+
const knownAction = (id) => !!(ACTION_DATA.actions[id] || isRecipeAction(id));
|
|
17
|
+
const actionLabel = (r) => r.actionLabel || (ACTION_DATA.actions[r.action] && ACTION_DATA.actions[r.action].label) || (r.action && isRecipeAction(r.action) ? `Recipe: ${r.action.slice((ACTION_DATA.recipePrefix || 'recipe.').length)}` : 'Custom action');
|
|
18
|
+
|
|
19
|
+
// First \`// ...\` lines of a job: \`// @action <id>\` declares the action, the first other comment line is the description (same rule as the panel).
|
|
20
|
+
function parseJobHeader(src) {
|
|
21
|
+
const out = { action: null, description: null };
|
|
22
|
+
for (const line of String(src || '').split(/\r\n|\r|\n/).slice(0, 10)) {
|
|
23
|
+
const m = /^\s*\/\/\s?(.*)$/.exec(line);
|
|
24
|
+
if (!m) { if (line.trim()) break; else continue; }
|
|
25
|
+
const t = m[1].trim(); const a = /^@action\s+([A-Za-z0-9_.-]+)/.exec(t);
|
|
26
|
+
if (a) out.action = a[1]; else if (t && !out.description) out.description = t.slice(0, 240);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseSnap(spec) {
|
|
32
|
+
const at = spec.lastIndexOf('@');
|
|
33
|
+
if (at < 1) die(`Bad --snap "${spec}". Expected "<comp>@t1,t2,..."`);
|
|
34
|
+
const times = spec.slice(at + 1).split(',').map((s) => Number(s.trim()));
|
|
35
|
+
if (!times.length || times.some((t) => !Number.isFinite(t))) die(`Bad times in --snap "${spec}"`);
|
|
36
|
+
return { comp: spec.slice(0, at), times };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseInspect(spec) {
|
|
40
|
+
const colon = spec.lastIndexOf(':');
|
|
41
|
+
if (colon > 0 && /^\d+$/.test(spec.slice(colon + 1))) return { comp: spec.slice(0, colon), depth: Number(spec.slice(colon + 1)) };
|
|
42
|
+
return { comp: spec, depth: 1 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function printResult(root, r, opts = {}) {
|
|
46
|
+
const outDir = path.join(root, 'out', r.id);
|
|
47
|
+
const lines = [];
|
|
48
|
+
lines.push(`[${r.status}] ${r.id}${r.durationMs != null ? ` (${r.durationMs} ms)` : ''}${r.aeVersion ? ` AE ${r.aeVersion}` : ''}`);
|
|
49
|
+
if (r.action) lines.push(`action: ${actionLabel(r)} [${r.action}${r.actionSource === 'inferred' ? ', inferred: declare it with --action or a // @action header' : ''}]`);
|
|
50
|
+
if (r.description) lines.push(`about: ${r.description}`);
|
|
51
|
+
lines.push(r.project && r.project.path ? `project: ${r.project.path}${r.project.dirty ? ' (unsaved changes)' : ''}` : 'project: (unsaved project)');
|
|
52
|
+
if (r.error) {
|
|
53
|
+
lines.push(`error: ${r.error.message}${r.error.line ? ` (line ${r.error.line}${r.error.fileName ? ' of ' + r.error.fileName : ''})` : ''}`);
|
|
54
|
+
if (r.error.source != null) lines.push(` > ${String(r.error.source).trim()}`);
|
|
55
|
+
}
|
|
56
|
+
if (r.sync) lines.push(`project log: ${r.sync.ok ? r.sync.dir : 'NOT SYNCED (' + r.sync.reason + ')'}`);
|
|
57
|
+
if (r.backup) lines.push(`backup: ${r.backup.ok ? r.backup.file : 'none (' + r.backup.reason + ')'}`);
|
|
58
|
+
if (r.log && r.log.length) { lines.push('log:'); r.log.forEach((l) => lines.push(` ${l}`)); }
|
|
59
|
+
if (r.snapshots && r.snapshots.length) {
|
|
60
|
+
lines.push('snapshots:');
|
|
61
|
+
r.snapshots.forEach((s) => {
|
|
62
|
+
lines.push(s.ok ? ` ${path.join(outDir, s.file)}` : ` FAILED ${s.comp} @ ${s.time}: ${s.error || 'unknown'}`);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (r.returnValue !== null && r.returnValue !== undefined) lines.push(`return: ${JSON.stringify(r.returnValue)}`);
|
|
66
|
+
if (r.inspect && opts.showInspect) lines.push(`inspect: ${JSON.stringify(r.inspect, null, 2)}`);
|
|
67
|
+
lines.push(`result: ${path.join(outDir, 'result.json')}`);
|
|
68
|
+
console.log(lines.join('\n'));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Writes options + job into the inbox and waits for result.json. `source` is the job text (string or Buffer).
|
|
72
|
+
async function submitJob(root, id, source, flags, extraOptions) {
|
|
73
|
+
if (!ID_RE.test(id)) die(`Bad id "${id}". Must match ^[A-Za-z0-9_-]{1,64}$`);
|
|
74
|
+
if (Buffer.byteLength(source) > 2 * 1024 * 1024) die('Job file is larger than 2 MB');
|
|
75
|
+
|
|
76
|
+
const inbox = path.join(root, 'inbox');
|
|
77
|
+
fs.mkdirSync(inbox, { recursive: true });
|
|
78
|
+
fs.mkdirSync(path.join(root, 'out'), { recursive: true });
|
|
79
|
+
if (fs.existsSync(path.join(root, 'out', id, 'result.json'))) die(`Job id "${id}" already has a result. Use a new --id.`);
|
|
80
|
+
|
|
81
|
+
const timeoutSec = flags.timeout !== undefined ? Number(flags.timeout) : 120;
|
|
82
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) die('--timeout must be a positive number of seconds');
|
|
83
|
+
const options = { timeoutSec, ...extraOptions };
|
|
84
|
+
if (flags['no-undo']) options.undoGroup = false;
|
|
85
|
+
if (flags.action) {
|
|
86
|
+
if (!knownAction(flags.action)) console.error(`warning: unknown action "${flags.action}"; it will show as "Custom action". Known: ${Object.keys(ACTION_DATA.actions).join(', ')}, recipe.<id>`);
|
|
87
|
+
options.action = flags.action;
|
|
88
|
+
}
|
|
89
|
+
if (flags.desc) options.description = String(flags.desc).slice(0, 240);
|
|
90
|
+
const snaps = [...(options.snapshots || []), ...flags.snap.map(parseSnap)];
|
|
91
|
+
if (snaps.length) options.snapshots = snaps;
|
|
92
|
+
if (flags.inspect) options.inspect = parseInspect(flags.inspect);
|
|
93
|
+
|
|
94
|
+
// Options first, then the job. The panel only reacts to <id>.jsx.
|
|
95
|
+
writeAtomic(path.join(inbox, `${id}.json`), JSON.stringify(options, null, 2));
|
|
96
|
+
writeAtomic(path.join(inbox, `${id}.jsx`), source);
|
|
97
|
+
|
|
98
|
+
const deadline = Date.now() + (timeoutSec + 60) * 1000;
|
|
99
|
+
let r = null;
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
r = readResult(root, id);
|
|
102
|
+
if (r) break;
|
|
103
|
+
await sleep(250);
|
|
104
|
+
}
|
|
105
|
+
if (!r) {
|
|
106
|
+
die(`No result for "${id}" after ${timeoutSec + 60} s. Is After Effects open with the Playrig panel started (press Start)?\n` +
|
|
107
|
+
`Job is still in ${inbox} or ${path.join(root, 'running')}.`, 3);
|
|
108
|
+
}
|
|
109
|
+
await settleSnapshots(root, r);
|
|
110
|
+
return r;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// saveFrameToPng can finish writing only after the job returns (the job's own wait blocks AE's thread), so a
|
|
114
|
+
// snapshot reported as "not written" is re-checked here for up to 30 s and flipped to ok if the file appears.
|
|
115
|
+
async function settleSnapshots(root, r) {
|
|
116
|
+
const late = (r.snapshots || []).filter((s) => !s.ok && s.file && /not written/.test(s.error || ''));
|
|
117
|
+
const deadline = Date.now() + 30000;
|
|
118
|
+
for (const s of late) {
|
|
119
|
+
const f = path.join(root, 'out', r.id, s.file);
|
|
120
|
+
while (Date.now() < deadline) {
|
|
121
|
+
if (fs.existsSync(f) && fs.statSync(f).size > 0) { s.ok = true; delete s.error; break; }
|
|
122
|
+
await sleep(250);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function finish(root, r, flags) {
|
|
128
|
+
if (flags.json) console.log(JSON.stringify(r, null, 2));
|
|
129
|
+
else printResult(root, r, { showInspect: !!flags.inspect });
|
|
130
|
+
process.exit(r.status === 'ok' ? 0 : 1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Parses --params '{json}' and repeated --param k=v into a plain object of raw values.
|
|
134
|
+
function rawParams(flags) {
|
|
135
|
+
let out = {};
|
|
136
|
+
if (flags.params) {
|
|
137
|
+
try { out = JSON.parse(flags.params); } catch (e) { die(`--params is not valid JSON: ${e.message}`); }
|
|
138
|
+
}
|
|
139
|
+
for (const kv of flags.param) {
|
|
140
|
+
const eq = kv.indexOf('=');
|
|
141
|
+
if (eq < 1) die(`Bad --param "${kv}". Expected key=value`);
|
|
142
|
+
out[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function cmdRun(pos, flags) {
|
|
148
|
+
const root = rootDir(flags);
|
|
149
|
+
const jsxPath = pos[0];
|
|
150
|
+
if (!jsxPath) die(`Missing <file.jsx>\n\n${USAGE}`);
|
|
151
|
+
if (!fs.existsSync(jsxPath)) die(`File not found: ${jsxPath}`);
|
|
152
|
+
let source = fs.readFileSync(jsxPath);
|
|
153
|
+
const header = parseJobHeader(source.toString('utf8'));
|
|
154
|
+
if (!flags.action && header.action) flags.action = header.action;
|
|
155
|
+
if (!flags.desc && header.description) flags.desc = header.description;
|
|
156
|
+
const raw = rawParams(flags);
|
|
157
|
+
// Optional parameters for plain jobs: exposed to the job as `var P = {...}`. Same line, so line numbers still match.
|
|
158
|
+
if (Object.keys(raw).length) source = Buffer.concat([Buffer.from(`var P = ${JSON.stringify(raw)}; `), source]);
|
|
159
|
+
const r = await submitJob(root, flags.id || defaultId(jsxPath), source, flags, {});
|
|
160
|
+
finish(root, r, flags);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cmdStatus(pos, flags) {
|
|
164
|
+
const root = rootDir(flags);
|
|
165
|
+
const id = pos[0];
|
|
166
|
+
if (!id || !ID_RE.test(id)) die(`Missing or bad <id>\n\n${USAGE}`);
|
|
167
|
+
const r = readResult(root, id);
|
|
168
|
+
if (r) { printResult(root, r); process.exit(r.status === 'ok' ? 0 : 1); }
|
|
169
|
+
if (fs.existsSync(path.join(root, 'running', `${id}.jsx`))) { console.log(`[running] ${id}`); return; }
|
|
170
|
+
if (fs.existsSync(path.join(root, 'inbox', `${id}.jsx`))) { console.log(`[queued] ${id}`); return; }
|
|
171
|
+
die(`Unknown job "${id}"`, 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Latest jobs of ONE project: by default the project of the most recent job (so a log is never read for the wrong project).
|
|
175
|
+
function cmdTail(flags) {
|
|
176
|
+
const root = rootDir(flags);
|
|
177
|
+
const out = path.join(root, 'out');
|
|
178
|
+
if (!fs.existsSync(out)) die(`No out folder at ${out}`, 1);
|
|
179
|
+
const limit = flags.limit ? Math.max(1, Number(flags.limit) || 10) : 10;
|
|
180
|
+
const rows = fs.readdirSync(out)
|
|
181
|
+
.map((d) => ({ d, f: path.join(out, d, 'result.json') }))
|
|
182
|
+
.filter((x) => fs.existsSync(x.f))
|
|
183
|
+
.map((x) => { try { return { ...x, t: fs.statSync(x.f).mtimeMs, r: JSON.parse(fs.readFileSync(x.f, 'utf8')) }; } catch (e) { return null; } })
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.sort((a, b) => b.t - a.t);
|
|
186
|
+
if (!rows.length) { console.log('No results yet.'); return; }
|
|
187
|
+
const projOf = (x) => (x.r.project && x.r.project.path) || null;
|
|
188
|
+
let shown = rows; let label;
|
|
189
|
+
if (flags.all) label = 'all projects';
|
|
190
|
+
else if (flags.project) {
|
|
191
|
+
const q = String(flags.project).toLowerCase();
|
|
192
|
+
shown = rows.filter((x) => projOf(x) && projOf(x).toLowerCase().includes(q));
|
|
193
|
+
label = `projects matching "${flags.project}"`;
|
|
194
|
+
if (!shown.length) { console.log(`No jobs for a project matching "${flags.project}".`); return; }
|
|
195
|
+
} else {
|
|
196
|
+
const cur = projOf(rows[0]);
|
|
197
|
+
shown = rows.filter((x) => projOf(x) === cur);
|
|
198
|
+
label = cur || '(unsaved project)';
|
|
199
|
+
}
|
|
200
|
+
console.log(`Jobs for: ${label}${flags.all ? '' : ` (--all for every project, --project <name> to pick one; ${shown.length} job${shown.length === 1 ? '' : 's'})`}`);
|
|
201
|
+
shown.slice(0, limit).forEach((x) => {
|
|
202
|
+
const r = x.r;
|
|
203
|
+
const err = r.error ? ` - ${r.error.message}` : '';
|
|
204
|
+
const what = r.description ? ` | ${r.description}` : '';
|
|
205
|
+
const proj = flags.all ? ` {${projOf(x) ? path.basename(projOf(x)) : 'unsaved'}}` : '';
|
|
206
|
+
console.log(`${new Date(x.t).toISOString()} [${r.status}] ${r.id} (${actionLabel(r)}${r.actionSource === 'inferred' ? ', inferred' : ''})${what}${err}${proj}`);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
// ------------------------------------------------------------------ recipe library
|
|
212
|
+
// One Markdown file per recipe: YAML frontmatter (metadata + params) + body (docs) + a ```jsx script block.
|
|
213
|
+
// See library/README.md. Frontmatter is a small YAML subset (see parseFrontmatter), parsed here without dependencies.
|
|
214
|
+
|
|
215
|
+
const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
216
|
+
const SCRIPT_RE = /```jsx[^\n]*\r?\n([\s\S]*?)\r?\n```/;
|
|
217
|
+
const STATUSES = new Set(['ready', 'experimental', 'planned']);
|
|
218
|
+
const PARAM_TYPES = new Set(['string', 'number', 'boolean', 'color', 'list']);
|
|
219
|
+
const INDEX_SPLIT_THRESHOLD = 40; // above this many recipes, INDEX.md lists categories only
|
|
220
|
+
|
|
221
|
+
function libDir(flags) {
|
|
222
|
+
return C.libraryDir(flags);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function walk(dir) {
|
|
226
|
+
if (!fs.existsSync(dir)) return [];
|
|
227
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((d) => {
|
|
228
|
+
const p = path.join(dir, d.name);
|
|
229
|
+
return d.isDirectory() ? walk(p) : [p];
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- minimal YAML subset: top-level `key: value`, `key:` + indented `sub: value` map, `key:` + `- item` list.
|
|
234
|
+
// Values: "quoted", 'quoted', numbers, true/false/null, plain text, flow lists [a, b] and flow maps {a: 1, b: "x"}.
|
|
235
|
+
function stripComment(line) {
|
|
236
|
+
let q = null;
|
|
237
|
+
for (let i = 0; i < line.length; i++) {
|
|
238
|
+
const c = line[i];
|
|
239
|
+
if (q) { if (c === q && line[i - 1] !== '\\') q = null; }
|
|
240
|
+
else if (c === '"' || c === "'") q = c;
|
|
241
|
+
else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
|
|
242
|
+
}
|
|
243
|
+
return line;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function plainScalar(t) {
|
|
247
|
+
if (t === 'true') return true;
|
|
248
|
+
if (t === 'false') return false;
|
|
249
|
+
if (t === 'null' || t === '~') return null;
|
|
250
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
251
|
+
return t;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseValue(text) {
|
|
255
|
+
let i = 0;
|
|
256
|
+
const s = text.trim();
|
|
257
|
+
const fail = (m) => { throw new Error(`${m} in "${s.length > 60 ? s.slice(0, 57) + '...' : s}"`); };
|
|
258
|
+
const ws = () => { while (i < s.length && /\s/.test(s[i])) i++; };
|
|
259
|
+
function quoted() {
|
|
260
|
+
const q = s[i++]; let out = '';
|
|
261
|
+
while (i < s.length && s[i] !== q) {
|
|
262
|
+
if (s[i] === '\\' && q === '"') { i++; const e = s[i++]; out += ({ n: '\n', t: '\t', '"': '"', '\\': '\\', '/': '/' })[e] ?? e; }
|
|
263
|
+
else out += s[i++];
|
|
264
|
+
}
|
|
265
|
+
if (s[i] !== q) fail('unterminated string');
|
|
266
|
+
i++;
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
function plain(stops) {
|
|
270
|
+
const start = i;
|
|
271
|
+
while (i < s.length && !stops.includes(s[i])) i++;
|
|
272
|
+
return plainScalar(s.slice(start, i).trim());
|
|
273
|
+
}
|
|
274
|
+
function value(stops) {
|
|
275
|
+
ws();
|
|
276
|
+
if (s[i] === '[') {
|
|
277
|
+
i++; const a = []; ws();
|
|
278
|
+
if (s[i] === ']') { i++; return a; }
|
|
279
|
+
for (;;) { a.push(value(',]')); ws(); if (s[i] === ',') { i++; continue; } if (s[i] === ']') { i++; return a; } fail('expected , or ]'); }
|
|
280
|
+
}
|
|
281
|
+
if (s[i] === '{') {
|
|
282
|
+
i++; const o = {}; ws();
|
|
283
|
+
if (s[i] === '}') { i++; return o; }
|
|
284
|
+
for (;;) {
|
|
285
|
+
ws();
|
|
286
|
+
const k = (s[i] === '"' || s[i] === "'") ? quoted() : String(plain(':'));
|
|
287
|
+
ws(); if (s[i] !== ':') fail('expected :'); i++;
|
|
288
|
+
o[k] = value(',}'); ws();
|
|
289
|
+
if (s[i] === ',') { i++; continue; }
|
|
290
|
+
if (s[i] === '}') { i++; return o; }
|
|
291
|
+
fail('expected , or }');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (s[i] === '"' || s[i] === "'") return quoted();
|
|
295
|
+
return plain(stops);
|
|
296
|
+
}
|
|
297
|
+
if (!s) return '';
|
|
298
|
+
const v = value('');
|
|
299
|
+
ws();
|
|
300
|
+
if (i < s.length) fail('unexpected trailing text');
|
|
301
|
+
return v;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseFrontmatter(text) {
|
|
305
|
+
const lines = text.split(/\r?\n/).map(stripComment);
|
|
306
|
+
const out = {};
|
|
307
|
+
let i = 0;
|
|
308
|
+
while (i < lines.length) {
|
|
309
|
+
if (!lines[i].trim()) { i++; continue; }
|
|
310
|
+
const m = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i]);
|
|
311
|
+
if (!m || /^\s/.test(lines[i])) throw new Error(`cannot parse line "${lines[i].trim()}"`);
|
|
312
|
+
const key = m[1]; const rest = m[2].trim();
|
|
313
|
+
i++;
|
|
314
|
+
if (rest !== '') { out[key] = parseValue(rest); continue; }
|
|
315
|
+
const block = [];
|
|
316
|
+
while (i < lines.length && (/^\s+\S/.test(lines[i]) || !lines[i].trim())) { if (lines[i].trim()) block.push(lines[i]); i++; }
|
|
317
|
+
if (!block.length) { out[key] = null; continue; }
|
|
318
|
+
if (/^\s*-\s/.test(block[0])) out[key] = block.map((l) => parseValue(l.replace(/^\s*-\s+/, '')));
|
|
319
|
+
else {
|
|
320
|
+
const o = {};
|
|
321
|
+
for (const l of block) {
|
|
322
|
+
const mm = /^\s+([A-Za-z_][\w-]*):\s*(.*)$/.exec(l);
|
|
323
|
+
if (!mm) throw new Error(`cannot parse line "${l.trim()}"`);
|
|
324
|
+
o[mm[1]] = parseValue(mm[2]);
|
|
325
|
+
}
|
|
326
|
+
out[key] = o;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function readRecipe(file) {
|
|
333
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
334
|
+
const m = FM_RE.exec(text);
|
|
335
|
+
if (!m) throw new Error('missing --- frontmatter --- block at the top of the file');
|
|
336
|
+
const meta = parseFrontmatter(m[1]);
|
|
337
|
+
const sm = SCRIPT_RE.exec(m[2]);
|
|
338
|
+
return { ...meta, file, body: m[2], script: sm ? sm[1] : null };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function loadRecipes(dir) {
|
|
342
|
+
return walk(path.join(dir, 'recipes')).filter((f) => f.endsWith('.md')).sort().map((file) => {
|
|
343
|
+
try { return readRecipe(file); } catch (e) { return die(`${path.relative(dir, file)}: ${e.message}\nRun: playrig ae lib check`); }
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function findRecipe(dir, id) {
|
|
348
|
+
const r = loadRecipes(dir).find((x) => x.id === id);
|
|
349
|
+
if (!r) die(`No recipe "${id}". Try: playrig ae lib list`, 1);
|
|
350
|
+
return r;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function coerce(name, spec, raw) {
|
|
354
|
+
switch (spec.type) {
|
|
355
|
+
case 'number': {
|
|
356
|
+
const n = Number(raw);
|
|
357
|
+
if (raw === '' || !Number.isFinite(n)) die(`Parameter ${name} must be a number (got "${raw}")`);
|
|
358
|
+
if (spec.min !== undefined && n < spec.min) die(`Parameter ${name} must be >= ${spec.min}`);
|
|
359
|
+
if (spec.max !== undefined && n > spec.max) die(`Parameter ${name} must be <= ${spec.max}`);
|
|
360
|
+
return n;
|
|
361
|
+
}
|
|
362
|
+
case 'boolean':
|
|
363
|
+
if (typeof raw === 'boolean') return raw;
|
|
364
|
+
if (/^(true|1|yes|on)$/i.test(raw)) return true;
|
|
365
|
+
if (/^(false|0|no|off)$/i.test(raw)) return false;
|
|
366
|
+
return die(`Parameter ${name} must be true or false (got "${raw}")`);
|
|
367
|
+
case 'color':
|
|
368
|
+
if (!/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(raw)) die(`Parameter ${name} must be a hex colour like #F7F7F5 (got "${raw}")`);
|
|
369
|
+
return raw;
|
|
370
|
+
case 'list': return Array.isArray(raw) ? raw : String(raw).split('|').map((x) => x.trim()).filter(Boolean);
|
|
371
|
+
default: return String(raw);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function resolveParams(recipe, flags) {
|
|
376
|
+
const specs = recipe.params || {};
|
|
377
|
+
const raw = rawParams(flags);
|
|
378
|
+
for (const k of Object.keys(raw)) {
|
|
379
|
+
if (!specs[k]) die(`Unknown parameter "${k}" for ${recipe.id}. Valid: ${Object.keys(specs).join(', ')}`);
|
|
380
|
+
}
|
|
381
|
+
const out = {};
|
|
382
|
+
for (const [k, spec] of Object.entries(specs)) out[k] = k in raw ? coerce(k, spec, raw[k]) : spec.default;
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function recipeCommand(r) {
|
|
387
|
+
const eg = Object.entries(r.params || {}).slice(0, 3).map(([k, v]) => `--param ${k}=${String(v.default).replace(/\s/g, '') === String(v.default) ? v.default : JSON.stringify(String(v.default))}`);
|
|
388
|
+
return `playrig ae lib run ${r.id} ${eg.join(' ')}`.trim();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const rel = (dir, f) => path.relative(dir, f).split(path.sep).join('/');
|
|
392
|
+
const oneLine = (s) => String(s).replace(/\s+/g, ' ').trim();
|
|
393
|
+
// Preview images live next to the recipe file as <id>--t<seconds>.png and are embedded in its "Preview" section.
|
|
394
|
+
const PREVIEW_START = '<!-- previews:start -->';
|
|
395
|
+
const PREVIEW_END = '<!-- previews:end -->';
|
|
396
|
+
function previewFiles(r) {
|
|
397
|
+
const d = path.dirname(r.file);
|
|
398
|
+
const re = new RegExp(`^${r.id}--t(\\d+(?:\\.\\d+)?)\\.png$`);
|
|
399
|
+
return fs.readdirSync(d).map((f) => ({ f, m: re.exec(f) })).filter((x) => x.m).sort((a, b) => Number(a.m[1]) - Number(b.m[1])).map((x) => x.f);
|
|
400
|
+
}
|
|
401
|
+
function writePreviewSection(r, files) {
|
|
402
|
+
const block = `${PREVIEW_START}\n${files.map((f) => `<img src="${f}" width="240" alt="${f.replace(/^.*--t/, 't=').replace(/\.png$/, 's')}">`).join(' ')}\n${PREVIEW_END}`;
|
|
403
|
+
let text = fs.readFileSync(r.file, 'utf8');
|
|
404
|
+
if (text.includes(PREVIEW_START)) text = text.replace(new RegExp(`${PREVIEW_START}[\\s\\S]*?${PREVIEW_END}`), () => block);
|
|
405
|
+
else text = text.replace(/^(# .+)$/m, (h) => `${h}\n\n${block}`);
|
|
406
|
+
fs.writeFileSync(r.file, text);
|
|
407
|
+
}
|
|
408
|
+
const bodyWithoutScript = (r) => r.body.replace(SCRIPT_RE, '_(script omitted: use `playrig ae lib show <id> --full`, or open the file)_');
|
|
409
|
+
|
|
410
|
+
function loadCategories(dir) {
|
|
411
|
+
const f = path.join(dir, 'categories.json');
|
|
412
|
+
return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : {};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---- INDEX.md: the level-0 router agents read first. Compact on purpose; splits per category when large.
|
|
416
|
+
function recipeLine(dir, r, base) {
|
|
417
|
+
const link = path.relative(path.dirname(base), r.file).split(path.sep).join('/');
|
|
418
|
+
const aka = (r.aliases || []).length ? ` (aka ${r.aliases.slice(0, 3).join(', ')})` : '';
|
|
419
|
+
const tag = r.status === 'experimental' ? ' *[experimental]*' : '';
|
|
420
|
+
return `- [\`${r.id}\`](${link}): ${oneLine(r.summary)}${aka}${tag}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function buildIndex(dir) {
|
|
424
|
+
const recipes = loadRecipes(dir);
|
|
425
|
+
const cats = loadCategories(dir);
|
|
426
|
+
const names = [...new Set(recipes.map((x) => x.category))].sort();
|
|
427
|
+
const split = recipes.length > INDEX_SPLIT_THRESHOLD;
|
|
428
|
+
const ready = recipes.filter((r) => r.status !== 'planned').length;
|
|
429
|
+
|
|
430
|
+
const head = [
|
|
431
|
+
'# Motion Library: Index', '',
|
|
432
|
+
`${ready} usable recipes, ${recipes.length - ready} planned. Generated by \`playrig ae lib index\`: edit the recipe files, not this one.`, '',
|
|
433
|
+
'## For agents: how to use this library', '',
|
|
434
|
+
'1. **Read only this file** to find candidates (match the request against the summaries, aliases and tags).',
|
|
435
|
+
'2. **Open one recipe file** (link below) once you have chosen it. It has parameters, what each setting changes, gotchas and what it combines with. Do not read other recipes or the scripts unless you need to.',
|
|
436
|
+
'3. **Run it:** `playrig ae lib run <id> --param key=value ...` (After Effects open, Playrig panel started). Chain recipes into one comp with the shared `comp` parameter.',
|
|
437
|
+
'4. **Look at the preview frames** it prints before reporting success.',
|
|
438
|
+
'5. Nothing fits? Build it with a normal bridge job, then offer to add it as a recipe (`library/README.md`, "Add a recipe").', '',
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
const lines = [...head];
|
|
442
|
+
const usable = recipes.filter((x) => x.status !== 'planned');
|
|
443
|
+
const planned = recipes.filter((x) => x.status === 'planned');
|
|
444
|
+
if (!split) {
|
|
445
|
+
for (const c of names) {
|
|
446
|
+
const inCat = usable.filter((x) => x.category === c);
|
|
447
|
+
if (!inCat.length) continue;
|
|
448
|
+
lines.push(`## ${c}`, cats[c] ? `*${cats[c]}*` : '', '');
|
|
449
|
+
for (const r of inCat) lines.push(recipeLine(dir, r, path.join(dir, 'INDEX.md')));
|
|
450
|
+
lines.push('');
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
lines.push('## Categories', '', 'Open a category index, then one recipe.', '');
|
|
454
|
+
fs.mkdirSync(path.join(dir, 'categories'), { recursive: true });
|
|
455
|
+
for (const c of names) {
|
|
456
|
+
const inCat = recipes.filter((x) => x.category === c);
|
|
457
|
+
const f = path.join(dir, 'categories', `${c}.md`);
|
|
458
|
+
const body = [`# ${c}`, '', cats[c] || '', '', '[Back to index](../INDEX.md)', '',
|
|
459
|
+
...inCat.filter((r) => r.status !== 'planned').map((r) => recipeLine(dir, r, f)), '',
|
|
460
|
+
...(inCat.some((r) => r.status === 'planned') ? ['## Planned (no script yet)', '', ...inCat.filter((r) => r.status === 'planned').map((r) => recipeLine(dir, r, f)), ''] : [])];
|
|
461
|
+
fs.writeFileSync(f, body.join('\n'));
|
|
462
|
+
lines.push(`- [\`${c}\`](categories/${c}.md) (${inCat.filter((r) => r.status !== 'planned').length} usable): ${cats[c] || ''}`);
|
|
463
|
+
}
|
|
464
|
+
lines.push('');
|
|
465
|
+
}
|
|
466
|
+
if (planned.length && !split) {
|
|
467
|
+
lines.push('## Planned (documented, no script yet)', '', 'Tell the user these exist as ideas; build with a normal bridge job if asked, using the recipe file as the brief.', '');
|
|
468
|
+
for (const r of planned) lines.push(recipeLine(dir, r, path.join(dir, 'INDEX.md')).replace(/ \(aka [^)]*\)$/, ''));
|
|
469
|
+
lines.push('');
|
|
470
|
+
}
|
|
471
|
+
fs.writeFileSync(path.join(dir, 'INDEX.md'), lines.join('\n'));
|
|
472
|
+
|
|
473
|
+
const json = recipes.map((r) => { const { file, body, script, ...meta } = r; return { ...meta, path: rel(dir, file), previews: previewFiles(r).map((f) => rel(dir, path.join(path.dirname(file), f))) }; });
|
|
474
|
+
fs.writeFileSync(path.join(dir, 'index.json'), JSON.stringify(json, null, 2) + '\n');
|
|
475
|
+
console.log(`Wrote INDEX.md${split ? ` + categories/ (${names.length})` : ''} and index.json: ${ready} usable, ${recipes.length - ready} planned`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---- lint: keeps the library consistent as it grows
|
|
479
|
+
function cmdLibCheck(dir) {
|
|
480
|
+
const errors = []; const warns = [];
|
|
481
|
+
const files = walk(path.join(dir, 'recipes')).filter((f) => f.endsWith('.md')).sort();
|
|
482
|
+
const seen = new Map(); const all = [];
|
|
483
|
+
for (const file of files) {
|
|
484
|
+
const where = rel(dir, file);
|
|
485
|
+
const err = (m) => errors.push(`${where}: ${m}`);
|
|
486
|
+
const warn = (m) => warns.push(`${where}: ${m}`);
|
|
487
|
+
let r;
|
|
488
|
+
try { r = readRecipe(file); } catch (e) { err(e.message); continue; }
|
|
489
|
+
all.push(r);
|
|
490
|
+
const id = path.basename(file, '.md');
|
|
491
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(String(r.id || ''))) err(`id must be lowercase-kebab (got "${r.id}")`);
|
|
492
|
+
if (r.id !== id) err(`id "${r.id}" must equal the file name "${id}"`);
|
|
493
|
+
if (seen.has(r.id)) err(`duplicate id (also in ${seen.get(r.id)})`); else seen.set(r.id, where);
|
|
494
|
+
const folder = path.basename(path.dirname(file));
|
|
495
|
+
if (r.category !== folder) err(`category "${r.category}" must equal its folder "${folder}"`);
|
|
496
|
+
for (const k of ['name', 'summary', 'category', 'status']) if (!r[k]) err(`missing "${k}"`);
|
|
497
|
+
if (r.status && !STATUSES.has(r.status)) err(`status must be ready, experimental or planned (got "${r.status}")`);
|
|
498
|
+
if (r.summary && oneLine(r.summary).length > 150) warn(`summary is ${oneLine(r.summary).length} chars; keep it to one short sentence (<= 150): it is read for every routing decision`);
|
|
499
|
+
if (!Array.isArray(r.tags) || !r.tags.length) err('tags must be a non-empty list');
|
|
500
|
+
if (r.aliases !== undefined && !Array.isArray(r.aliases)) err('aliases must be a list');
|
|
501
|
+
if (r.composes_with !== undefined && !Array.isArray(r.composes_with)) err('composes_with must be a list');
|
|
502
|
+
if (r.idempotent !== undefined && typeof r.idempotent !== 'boolean') err('idempotent must be true or false');
|
|
503
|
+
if (r.idempotent === false && !r.replace_note) err('idempotent: false needs a replace_note (how to change it instead)');
|
|
504
|
+
if (!/^#\s/m.test(r.body)) warn('body has no "# Title" heading');
|
|
505
|
+
for (const m of r.body.matchAll(/src="([^"]+\.png)"/g)) if (!fs.existsSync(path.join(path.dirname(file), m[1]))) err(`embedded image ${m[1]} does not exist`);
|
|
506
|
+
if (r.status !== 'planned') {
|
|
507
|
+
if (!r.script) err('ready/experimental recipes need a ```jsx script block');
|
|
508
|
+
if (!Array.isArray(r.preview_times) || !r.preview_times.length) warn('no preview_times');
|
|
509
|
+
if (!r.params || !r.params.comp) err('params must include "comp" (the target comp)');
|
|
510
|
+
for (const [k, s] of Object.entries(r.params || {})) {
|
|
511
|
+
if (!PARAM_TYPES.has(s.type)) { err(`param ${k}: unknown type "${s.type}"`); continue; }
|
|
512
|
+
if (s.default === undefined) err(`param ${k}: missing default`);
|
|
513
|
+
if (!s.desc) warn(`param ${k}: missing desc`);
|
|
514
|
+
if (s.default !== undefined) {
|
|
515
|
+
const ok = { number: typeof s.default === 'number', boolean: typeof s.default === 'boolean', string: typeof s.default === 'string', color: typeof s.default === 'string' && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s.default), list: Array.isArray(s.default) }[s.type];
|
|
516
|
+
if (!ok) err(`param ${k}: default ${JSON.stringify(s.default)} is not a valid ${s.type}`);
|
|
517
|
+
if (s.type === 'number' && typeof s.default === 'number' && ((s.min !== undefined && s.default < s.min) || (s.max !== undefined && s.default > s.max))) err(`param ${k}: default is outside min/max`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (r.script) {
|
|
521
|
+
try { new (require('vm').Script)(`(function(P){${r.script}\n})`); } catch (e) { err(`script does not parse: ${e.message}`); }
|
|
522
|
+
const es3 = [[/=>/, 'arrow function'], [/\blet\s/, 'let'], [/\bconst\s/, 'const'], [/`/, 'template literal'], [/\bJSON\./, 'JSON (not in ExtendScript)'], [/\.forEach\(|\.map\(|\.filter\(/, 'Array.forEach/map/filter']];
|
|
523
|
+
for (const [re, what] of es3) if (re.test(r.script.replace(/(["'])(?:\\.|(?!\1).)*\1/g, '""'))) err(`script uses ${what}; ExtendScript is ES3`);
|
|
524
|
+
const used = [...r.script.matchAll(/\bP\.([A-Za-z_]\w*)/g)].map((x) => x[1]);
|
|
525
|
+
for (const u of new Set(used)) if (!(r.params || {})[u]) err(`script uses P.${u} which is not declared in params`);
|
|
526
|
+
for (const k of Object.keys(r.params || {})) if (!used.includes(k) && !['width', 'height', 'duration', 'fps'].includes(k) && k !== 'comp') warn(`param ${k} is declared but never used`);
|
|
527
|
+
}
|
|
528
|
+
if (r.status === 'ready' && !previewFiles(r).length) warn('no previews yet (run with --save-preview)');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const ids = new Set(all.map((x) => x.id));
|
|
532
|
+
for (const r of all) for (const c of r.composes_with || []) if (!ids.has(c)) errors.push(`${rel(dir, r.file)}: composes_with "${c}" does not exist`);
|
|
533
|
+
warns.forEach((w) => console.log(`warn ${w}`));
|
|
534
|
+
errors.forEach((e) => console.log(`ERROR ${e}`));
|
|
535
|
+
console.log(`\n${all.length} recipes checked: ${errors.length} errors, ${warns.length} warnings`);
|
|
536
|
+
process.exit(errors.length ? 1 : 0);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function cmdLibShow(dir, id, flags) {
|
|
540
|
+
const r = findRecipe(dir, id);
|
|
541
|
+
const out = [];
|
|
542
|
+
out.push(`${r.id}: ${r.name} [${r.category}, ${r.status}]`);
|
|
543
|
+
out.push(`tags: ${(r.tags || []).join(', ')}${(r.aliases || []).length ? `\nalso known as: ${r.aliases.join(', ')}` : ''}`);
|
|
544
|
+
out.push(`\n${oneLine(r.summary)}`);
|
|
545
|
+
if (r.use_when) out.push(`use when: ${oneLine(r.use_when)}`);
|
|
546
|
+
if (r.composes_with && r.composes_with.length) out.push(`combines with: ${r.composes_with.join(', ')}`);
|
|
547
|
+
if (r.status !== 'planned') out.push(`edit in place (--replace): ${r.idempotent === false ? 'NO: ' + oneLine(r.replace_note || '') : 'yes (replaces only its own layers, same stacking position)'}`);
|
|
548
|
+
if (r.origin) out.push(`origin: ${r.origin}`);
|
|
549
|
+
if (r.params) {
|
|
550
|
+
out.push('\nparameters:');
|
|
551
|
+
for (const [k, v] of Object.entries(r.params)) {
|
|
552
|
+
const range = v.min !== undefined || v.max !== undefined ? ` [${v.min ?? ''}..${v.max ?? ''}]` : '';
|
|
553
|
+
out.push(` ${k} (${v.type}${range}) = ${JSON.stringify(v.default)} ${v.desc || ''}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
out.push(`\nfile: ${r.file}`);
|
|
557
|
+
const pv = previewFiles(r);
|
|
558
|
+
if (pv.length) out.push(`previews: ${pv.map((f) => path.join(path.dirname(r.file), f)).join('\n ')}`);
|
|
559
|
+
if (r.status !== 'planned') out.push(`run: ${recipeCommand(r)}`);
|
|
560
|
+
console.log(out.join('\n'));
|
|
561
|
+
if (flags.full) console.log(`\n${r.body}`);
|
|
562
|
+
else console.log(`\n(open the file for the full notes: how it works, tweaks, gotchas. Add --full to print them here.)`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function cmdLibList(dir, query, flags) {
|
|
566
|
+
const q = (query || '').toLowerCase();
|
|
567
|
+
const rows = loadRecipes(dir).filter((x) =>
|
|
568
|
+
(!flags.category || x.category === flags.category) &&
|
|
569
|
+
(!flags.status || x.status === flags.status) &&
|
|
570
|
+
(!q || [x.id, x.name, x.category, x.summary, ...(x.tags || []), ...(x.aliases || [])].join(' ').toLowerCase().includes(q)));
|
|
571
|
+
if (!rows.length) { console.log('No matches.'); return; }
|
|
572
|
+
const w = Math.max(...rows.map((x) => x.id.length));
|
|
573
|
+
for (const x of rows) console.log(`${x.id.padEnd(w)} ${x.status === 'ready' ? ' ' : '·'} [${x.category}] ${oneLine(x.summary)}`);
|
|
574
|
+
console.log('\n(· = planned or experimental)');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Copies the starter (helpers, README, empty recipes/) so you can add your own recipes.
|
|
578
|
+
function cmdLibInit(target) {
|
|
579
|
+
const starter = EDITION && path.join(EDITION, 'library-starter');
|
|
580
|
+
if (!starter || !fs.existsSync(starter)) die('The starter library was not found next to this CLI.', 1);
|
|
581
|
+
const dest = path.resolve(target);
|
|
582
|
+
if (fs.existsSync(path.join(dest, 'recipes'))) die(`${dest} already has a library; leaving it alone.`, 1);
|
|
583
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
584
|
+
fs.cpSync(starter, dest, { recursive: true });
|
|
585
|
+
buildIndex(dest);
|
|
586
|
+
console.log(`Created your recipe library at ${dest}\nAdd recipes with the create-ae-recipe skill or: playrig ae lib new <category>/<id> then: playrig ae lib index`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function cmdLibNew(dir, spec) {
|
|
590
|
+
if (!spec || !/^[a-z0-9-]+\/[a-z0-9-]+$/.test(spec)) die('Usage: playrig ae lib new <category>/<id> (lowercase, hyphens)');
|
|
591
|
+
const [cat, id] = spec.split('/');
|
|
592
|
+
const file = path.join(dir, 'recipes', cat, `${id}.md`);
|
|
593
|
+
if (fs.existsSync(file)) die(`${file} already exists`);
|
|
594
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
595
|
+
fs.writeFileSync(file, `---
|
|
596
|
+
id: ${id}
|
|
597
|
+
name: Human-readable name
|
|
598
|
+
category: ${cat}
|
|
599
|
+
status: experimental # ready | experimental | planned
|
|
600
|
+
summary: One short sentence: what it looks like and what it builds.
|
|
601
|
+
use_when: When the user wants ...
|
|
602
|
+
tags: [tag1, tag2]
|
|
603
|
+
aliases: [other name, industry term]
|
|
604
|
+
composes_with: []
|
|
605
|
+
origin: Where this came from (project / scene)
|
|
606
|
+
preview_times: [0.5]
|
|
607
|
+
params:
|
|
608
|
+
comp: {type: string, default: "Scene", desc: "Target comp (created if missing)"}
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
# Human-readable name
|
|
612
|
+
|
|
613
|
+
## What it looks like
|
|
614
|
+
Describe the result in plain words, as you would brief an artist.
|
|
615
|
+
|
|
616
|
+
## How it works
|
|
617
|
+
The After Effects mechanics (effects, animators, expressions, rigs) in a few lines.
|
|
618
|
+
|
|
619
|
+
## Tweaks that matter
|
|
620
|
+
| Setting | Effect |
|
|
621
|
+
|---|---|
|
|
622
|
+
| \`param\` | what changes visually, with useful values |
|
|
623
|
+
|
|
624
|
+
## Gotchas
|
|
625
|
+
- Anything that bit you.
|
|
626
|
+
|
|
627
|
+
## Script
|
|
628
|
+
Parameters arrive as \`P.<name>\`. ES3 only. Helpers from \`_common.jsx\` (\`lib*\`) are available.
|
|
629
|
+
|
|
630
|
+
\`\`\`jsx
|
|
631
|
+
var comp = libTargetComp();
|
|
632
|
+
PLAYRIG.log("TODO: build something in " + comp.name);
|
|
633
|
+
\`\`\`
|
|
634
|
+
`);
|
|
635
|
+
console.log(`Created ${file}\nEdit it, then: playrig ae lib check && playrig ae lib run ${id} --param comp=TEST --save-preview && playrig ae lib index`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function cmdLib(pos, flags) {
|
|
639
|
+
const dir = libDir(flags);
|
|
640
|
+
const [sub, arg] = pos;
|
|
641
|
+
if (sub === 'init') return cmdLibInit(arg || flags.lib || C.userLibrary());
|
|
642
|
+
if (sub !== 'new' && !fs.existsSync(path.join(dir, 'recipes'))) die(`No recipe library at ${dir}.\nPlayrig does not ship recipes. Create your own library there with: playrig ae lib init\n(or point to an existing one with --lib <folder> or $PLAYRIG_LIBRARY).`, 1);
|
|
643
|
+
if (sub === 'list') return cmdLibList(dir, pos.slice(1).join(' '), flags);
|
|
644
|
+
if (sub === 'show') return cmdLibShow(dir, arg, flags);
|
|
645
|
+
if (sub === 'index') return buildIndex(dir);
|
|
646
|
+
if (sub === 'check') return cmdLibCheck(dir);
|
|
647
|
+
if (sub === 'new') return cmdLibNew(dir, arg);
|
|
648
|
+
if (sub !== 'run') die(USAGE);
|
|
649
|
+
|
|
650
|
+
const recipe = findRecipe(dir, arg);
|
|
651
|
+
if (recipe.status === 'planned' || !recipe.script) die(`"${recipe.id}" is planned and has no script yet. See ${rel(dir, recipe.file)} for how it is built.`, 1);
|
|
652
|
+
const P = resolveParams(recipe, flags);
|
|
653
|
+
if (flags.replace && recipe.idempotent === false) {
|
|
654
|
+
die(`"${recipe.id}" can't be replaced in place: ${oneLine(recipe.replace_note || 'it changes existing layers')}`, 1);
|
|
655
|
+
}
|
|
656
|
+
const instance = flags.instance || 'default';
|
|
657
|
+
if (!/^[A-Za-z0-9_-]{1,32}$/.test(instance)) die('--instance must match ^[A-Za-z0-9_-]{1,32}$');
|
|
658
|
+
const common = fs.readFileSync(path.join(dir, '_common.jsx'), 'utf8');
|
|
659
|
+
// `var P` goes on the first line (no newline) so recipe line numbers match the script block; helpers go at the end (hoisted).
|
|
660
|
+
const recipeMeta = JSON.stringify({ recipe: recipe.id, instance, replace: !!flags.replace });
|
|
661
|
+
const source = `var P = ${JSON.stringify(P)}; var __AEB = ${recipeMeta}; __aebPre(); ${recipe.script}\n__aebPost();\n${common}`;
|
|
662
|
+
const root = rootDir(flags);
|
|
663
|
+
const options = { action: `recipe.${recipe.id}`, actionLabel: recipe.name, description: oneLine(recipe.summary).slice(0, 240) };
|
|
664
|
+
if (flags.desc) options.description = String(flags.desc).slice(0, 240);
|
|
665
|
+
if (!flags['no-snap'] && recipe.preview_times && recipe.preview_times.length) {
|
|
666
|
+
options.snapshots = [{ comp: P.comp, times: recipe.preview_times }];
|
|
667
|
+
}
|
|
668
|
+
const stamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14);
|
|
669
|
+
const id = (flags.id || `${recipe.id}-${stamp}`).slice(0, 64);
|
|
670
|
+
const r = await submitJob(root, id, source, { ...flags, action: undefined, desc: undefined }, options);
|
|
671
|
+
|
|
672
|
+
if (flags['save-preview'] && r.status === 'ok') {
|
|
673
|
+
const rdir = path.dirname(recipe.file);
|
|
674
|
+
for (const f of previewFiles(recipe)) fs.rmSync(path.join(rdir, f));
|
|
675
|
+
for (const s of r.snapshots || []) {
|
|
676
|
+
if (s.ok) fs.copyFileSync(path.join(root, 'out', r.id, s.file), path.join(rdir, `${recipe.id}--t${Number(s.time).toFixed(2)}.png`));
|
|
677
|
+
}
|
|
678
|
+
const files = previewFiles(recipe);
|
|
679
|
+
writePreviewSection(recipe, files);
|
|
680
|
+
console.log(`saved ${files.length} previews next to ${rel(dir, recipe.file)} and embedded them in its Preview section`);
|
|
681
|
+
}
|
|
682
|
+
finish(root, r, flags);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ------------------------------------------------------------------ scratch: track and remove experimental items
|
|
686
|
+
function scratchFile(root) { return path.join(root, 'scratch-baseline.json'); }
|
|
687
|
+
|
|
688
|
+
async function scratchJob(root, label, source) {
|
|
689
|
+
const stamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14);
|
|
690
|
+
const clean = label === 'clean';
|
|
691
|
+
const r = await submitJob(root, `scratch-${label}-${stamp}`, source, { snap: [], 'no-undo': true }, {
|
|
692
|
+
action: clean ? 'scratch.cleanup' : 'scratch.baseline',
|
|
693
|
+
description: label === 'start' ? 'Recorded the items in the project before experimenting' : clean ? 'Removed the temporary items created while experimenting' : 'Listed the temporary items created while experimenting' });
|
|
694
|
+
if (r.status !== 'ok') { printResult(root, r); process.exit(1); }
|
|
695
|
+
return r;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function cmdScratch(pos, flags) {
|
|
699
|
+
const root = rootDir(flags);
|
|
700
|
+
const sub = pos[0];
|
|
701
|
+
if (sub === 'start') {
|
|
702
|
+
const r = await scratchJob(root, 'start', 'var ids = [], i; for (i = 1; i <= app.project.numItems; i++) { ids.push(app.project.item(i).id); } PLAYRIG.ret({ project: app.project.file ? app.project.file.fsName : null, ids: ids });');
|
|
703
|
+
fs.writeFileSync(scratchFile(root), JSON.stringify(r.returnValue));
|
|
704
|
+
console.log(`Baseline recorded: ${r.returnValue.ids.length} items in ${r.returnValue.project || '(unsaved project)'}. Run \`playrig ae scratch clean --yes\` when done experimenting.`);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (sub !== 'status' && sub !== 'clean') die(USAGE);
|
|
708
|
+
if (!fs.existsSync(scratchFile(root))) die('No baseline. Run: playrig ae scratch start', 1);
|
|
709
|
+
const base = JSON.parse(fs.readFileSync(scratchFile(root), 'utf8'));
|
|
710
|
+
const remove = sub === 'clean' && flags.yes;
|
|
711
|
+
const job = `var keep = {}, ids = ${JSON.stringify(base.ids)}, i, it, found = [], out = [];
|
|
712
|
+
for (i = 0; i < ids.length; i++) { keep["i" + ids[i]] = true; }
|
|
713
|
+
if (${JSON.stringify(base.project)} !== (app.project.file ? app.project.file.fsName : null)) { throw new Error("A different project is open than when the baseline was recorded. Run: playrig ae scratch start"); }
|
|
714
|
+
for (i = 1; i <= app.project.numItems; i++) { it = app.project.item(i); if (!keep["i" + it.id]) { found.push(it); } }
|
|
715
|
+
for (i = 0; i < found.length; i++) { out.push((found[i] instanceof CompItem ? "comp" : (found[i] instanceof FolderItem ? "folder" : "footage")) + ": " + found[i].name); }
|
|
716
|
+
${remove ? 'for (i = 0; i < found.length; i++) { try { found[i].remove(); } catch (e) { } }' : ''}
|
|
717
|
+
PLAYRIG.ret({ items: out, removed: ${remove ? 'true' : 'false'}, remaining: app.project.numItems });`;
|
|
718
|
+
const r = await scratchJob(root, remove ? 'clean' : 'status', job);
|
|
719
|
+
const v = r.returnValue;
|
|
720
|
+
const counts = {};
|
|
721
|
+
for (const l of v.items) counts[l] = (counts[l] || 0) + 1;
|
|
722
|
+
console.log(`${v.items.length} item(s) created since the baseline:`);
|
|
723
|
+
Object.entries(counts).forEach(([k, n]) => console.log(` ${n > 1 ? n + 'x ' : ''}${k}`));
|
|
724
|
+
if (v.removed) console.log(`\nRemoved. ${v.remaining} items remain (baseline had ${base.ids.length}).`);
|
|
725
|
+
else if (sub === 'clean') console.log('\nDry run only. Check the list is all yours, then re-run with --yes.');
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Subcommands for `playrig ae <sub>`. Returns a promise (or exits).
|
|
729
|
+
async function main(sub, pos, flags) {
|
|
730
|
+
if (sub === 'run') return cmdRun(pos, flags);
|
|
731
|
+
if (sub === 'status') return cmdStatus(pos, flags);
|
|
732
|
+
if (sub === 'tail') return cmdTail(flags);
|
|
733
|
+
if (sub === 'lib') return cmdLib(pos, flags);
|
|
734
|
+
if (sub === 'scratch') return cmdScratch(pos, flags);
|
|
735
|
+
if (sub === 'snap') return cmdSnap(pos, flags);
|
|
736
|
+
if (sub === 'inspect') return cmdInspect(pos, flags);
|
|
737
|
+
return die(USAGE);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// snap / inspect: a no-op job that only carries the snapshot / inspect request.
|
|
741
|
+
async function cmdSnap(pos, flags) {
|
|
742
|
+
if (!pos.length) die('Usage: playrig ae snap "<comp>@t1,t2,..." [more specs]');
|
|
743
|
+
const root = rootDir(flags);
|
|
744
|
+
const r = await submitJob(root, flags.id || defaultId('snap'), 'PLAYRIG.log("snapshot");', { ...flags, snap: [...flags.snap, ...pos], action: 'frame.export', desc: flags.desc || 'Exported frames', 'no-undo': true }, {});
|
|
745
|
+
finish(root, r, flags);
|
|
746
|
+
}
|
|
747
|
+
async function cmdInspect(pos, flags) {
|
|
748
|
+
if (!pos[0]) die('Usage: playrig ae inspect "<comp>:<depth>"');
|
|
749
|
+
const root = rootDir(flags);
|
|
750
|
+
const r = await submitJob(root, flags.id || defaultId('inspect'), 'PLAYRIG.log("inspect");', { ...flags, inspect: pos[0], action: 'project.inspect', desc: flags.desc || 'Inspected a comp', 'no-undo': true }, {});
|
|
751
|
+
finish(root, r, { ...flags, inspect: pos[0] });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
module.exports = { main };
|