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
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
// Playrig (UXP): runs JavaScript jobs dropped into <root>/inbox and writes results to <root>/out/<id>/.
|
|
2
|
+
// Mirrors every job into <project folder>/EDIT_LOGS/ so the project can move between systems.
|
|
3
|
+
// SPIKE: Premiere DOM calls marked "unverified" in README.md have not been run against a real Premiere yet.
|
|
4
|
+
const ppro = require("premierepro");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const os = require("os");
|
|
7
|
+
const uxp = require("uxp");
|
|
8
|
+
|
|
9
|
+
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
10
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
const MAX_JOBS_SHOWN = 50;
|
|
12
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
13
|
+
|
|
14
|
+
const S = { jobs: [], on: false, busy: false, timer: null, seq: 0, unsynced: [] };
|
|
15
|
+
const $ = (id) => document.getElementById(id);
|
|
16
|
+
|
|
17
|
+
// ------------------------------------------------------------ settings and paths
|
|
18
|
+
// Settings saved under the former name ("pb.*", Premiere Bridge) are still read, so an upgrade keeps its folder and options.
|
|
19
|
+
const getSet = (k, d) => { try { let v = localStorage.getItem("pp." + k); if (v === null) v = localStorage.getItem("pb." + k); return v === null ? d : v; } catch (e) { return d; } };
|
|
20
|
+
const setSet = (k, v) => { try { localStorage.setItem("pp." + k, String(v)); } catch (e) { } };
|
|
21
|
+
// UXP file stats expose the modified time as `mtime` (milliseconds), not `mtimeMs`.
|
|
22
|
+
const mtimeOf = (st) => Number(st.mtime !== undefined ? (st.mtime instanceof Date ? st.mtime.getTime() : st.mtime) : st.mtimeMs) || 0;
|
|
23
|
+
const norm = (p) => String(p).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
24
|
+
// Default <Documents>/Playrig/pr; an existing <Documents>/Premiere Bridge keeps being used until the new folder exists (see pickDefaultRoot).
|
|
25
|
+
const NEW_ROOT = () => os.homedir() + "/Documents/Playrig/pr", LEGACY_ROOT = () => os.homedir() + "/Documents/Premiere Bridge";
|
|
26
|
+
let defaultRoot = NEW_ROOT();
|
|
27
|
+
const root = () => norm(getSet("root", "") || defaultRoot);
|
|
28
|
+
async function pickDefaultRoot() { if (!(await exists(NEW_ROOT() + "/inbox")) && (await exists(LEGACY_ROOT() + "/inbox"))) defaultRoot = LEGACY_ROOT(); showRoot(); }
|
|
29
|
+
const sub = (n) => root() + "/" + n;
|
|
30
|
+
const dirname = (p) => norm(p).replace(/\/[^/]*$/, "");
|
|
31
|
+
const basename = (p) => norm(p).replace(/^.*\//, "");
|
|
32
|
+
|
|
33
|
+
function stamp() {
|
|
34
|
+
const d = new Date(), p = (n) => String(n).padStart(2, "0");
|
|
35
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ------------------------------------------------------------ files
|
|
39
|
+
async function exists(p) { try { await fs.lstat(p); return true; } catch (e) { return false; } }
|
|
40
|
+
async function mkdirs(p) {
|
|
41
|
+
const parts = norm(p).split("/");
|
|
42
|
+
let cur = "";
|
|
43
|
+
for (const part of parts) {
|
|
44
|
+
cur = cur ? cur + "/" + part : part || "/";
|
|
45
|
+
if (!part) continue;
|
|
46
|
+
if (!(await exists(cur))) { try { await fs.mkdir(cur); } catch (e) { if (!(await exists(cur))) throw e; } }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const readText = (p) => fs.readFile(p, { encoding: "utf-8" });
|
|
50
|
+
async function writeText(p, s) { await fs.writeFile(p, s, { encoding: "utf-8" }); }
|
|
51
|
+
async function writeAtomic(p, s) {
|
|
52
|
+
const tmp = p + ".tmp";
|
|
53
|
+
await writeText(tmp, s);
|
|
54
|
+
if (await exists(p)) await fs.unlink(p);
|
|
55
|
+
await fs.rename(tmp, p);
|
|
56
|
+
}
|
|
57
|
+
async function appendText(p, s) { await writeText(p, ((await exists(p)) ? await readText(p) : "") + s); }
|
|
58
|
+
async function moveFile(src, destDir) {
|
|
59
|
+
const dest = destDir + "/" + basename(src);
|
|
60
|
+
if (await exists(dest)) await fs.unlink(dest);
|
|
61
|
+
await fs.rename(src, dest);
|
|
62
|
+
return dest;
|
|
63
|
+
}
|
|
64
|
+
async function copyFile(src, destDir) {
|
|
65
|
+
await mkdirs(destDir);
|
|
66
|
+
const dest = destDir + "/" + basename(src);
|
|
67
|
+
if (await exists(dest)) await fs.unlink(dest);
|
|
68
|
+
await fs.copyFile(src, dest);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ------------------------------------------------------------ UI
|
|
72
|
+
function setStatus(t) {
|
|
73
|
+
$("status").textContent = t;
|
|
74
|
+
$("dot").className = "dot" + (/^Running/.test(t) ? " run" : /^Error/.test(t) ? " err" : S.on ? " on" : "");
|
|
75
|
+
$("status").style.color = /^Error/.test(t) ? "#f85149" : "#a0a0a0";
|
|
76
|
+
}
|
|
77
|
+
let msgTimer = null;
|
|
78
|
+
function uiLog(t) {
|
|
79
|
+
$("msg").textContent = t;
|
|
80
|
+
clearTimeout(msgTimer);
|
|
81
|
+
if (t && !/fail|cannot|could not|error/i.test(t)) msgTimer = setTimeout(() => { $("msg").textContent = ""; }, 12000); // info fades, problems stay
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ------------------------------------------------------------ jobs table (per project)
|
|
85
|
+
// The list shows the jobs of the project that is open now, read from <project folder>/EDIT_LOGS/results/ (the same files the project
|
|
86
|
+
// log keeps), so switching projects switches the list and a project's history travels with it. Rows open a detail window.
|
|
87
|
+
const STATUS_ICON = { ok: "status-ok", error: "status-error", rejected: "status-rejected", timeout: "status-timeout" };
|
|
88
|
+
const humanName = (id) => { const m = /^\d{8}-\d{6}-(.+)$/.exec(String(id)); return String(m ? m[1] : id).replace(/[-_]+/g, " ").trim() || String(id); };
|
|
89
|
+
function fmtWhen(iso) {
|
|
90
|
+
const d = new Date(iso); if (isNaN(d.getTime())) return "";
|
|
91
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
92
|
+
return p(d.getDate()) + "-" + p(d.getMonth() + 1) + "-" + String(d.getFullYear()).slice(2) + " " + p(d.getHours()) + ":" + p(d.getMinutes()); // DD-MM-YY HH:MM
|
|
93
|
+
}
|
|
94
|
+
const fmtDur = (ms) => (ms === undefined || ms === null) ? "" : (ms < 1000 ? ms + " ms" : (ms / 1000).toFixed(ms < 10000 ? 1 : 0) + " s");
|
|
95
|
+
function rowFromResult(r, src) {
|
|
96
|
+
const declared = r.action && ACTIONS[r.action] ? r.action : null;
|
|
97
|
+
const action = r.status === "rejected" ? "job.rejected" : (declared || inferAction(src, r.id));
|
|
98
|
+
const inferred = r.status !== "rejected" && !(declared && r.actionSource === "declared");
|
|
99
|
+
return { id: r.id, status: r.status || "unknown", startedAt: r.startedAt, durationMs: r.durationMs, action, actionInferred: inferred, name: actionInfo(action).label,
|
|
100
|
+
description: r.description || null, error: r.error && r.error.message ? r.error.message : null, firstLog: (r.log && r.log.length) ? String(r.log[0]).slice(0, 120) : null,
|
|
101
|
+
snapshots: (r.snapshots || []).filter((x) => x.ok).length, moved: r.organized && r.organized.moved ? r.organized.moved.length : 0 };
|
|
102
|
+
}
|
|
103
|
+
function tooltipFor(j) {
|
|
104
|
+
const info = actionInfo(j.action), t = [];
|
|
105
|
+
t.push(j.description ? j.description : info.summary);
|
|
106
|
+
t.push("Action: " + info.label + (j.actionInferred ? " (inferred from the script)" : ""));
|
|
107
|
+
t.push("Result: " + j.status + (j.durationMs !== undefined ? " in " + fmtDur(j.durationMs) : ""));
|
|
108
|
+
if (j.error) t.push("Error: " + j.error);
|
|
109
|
+
if (j.snapshots) t.push(j.snapshots + " frame" + (j.snapshots === 1 ? "" : "s") + " exported");
|
|
110
|
+
if (j.moved) t.push(j.moved + " item" + (j.moved === 1 ? "" : "s") + " filed into bins");
|
|
111
|
+
t.push("Click for details");
|
|
112
|
+
return t.join("\n");
|
|
113
|
+
}
|
|
114
|
+
function renderJobs() {
|
|
115
|
+
const box = $("jobs");
|
|
116
|
+
box.textContent = "";
|
|
117
|
+
$("jobsCount").textContent = S.jobTotal ? (S.jobTotal > S.jobs.length ? "latest " + S.jobs.length + " of " + S.jobTotal : S.jobTotal + (S.jobTotal === 1 ? " job" : " jobs")) : "";
|
|
118
|
+
if (!S.jobs.length) {
|
|
119
|
+
const e = document.createElement("div"); e.className = "empty";
|
|
120
|
+
e.textContent = S.projectPath === "" ? "Save the project to keep its job history with it." : "No jobs yet in this project.";
|
|
121
|
+
box.appendChild(e); return;
|
|
122
|
+
}
|
|
123
|
+
for (const j of S.jobs) {
|
|
124
|
+
const row = document.createElement("div"); row.className = "job"; row.setAttribute("title", tooltipFor(j));
|
|
125
|
+
const st = document.createElement("div"); st.className = "c-status";
|
|
126
|
+
const ic = document.createElement("img"); ic.setAttribute("src", "icons/" + (STATUS_ICON[j.status] || "status-unknown") + ".svg"); ic.setAttribute("width", "14"); ic.setAttribute("height", "14");
|
|
127
|
+
const word = document.createElement("span"); word.className = "st-" + (STATUS_ICON[j.status] ? j.status : "unknown"); word.textContent = j.status;
|
|
128
|
+
st.append(ic, word);
|
|
129
|
+
const nm = document.createElement("div"); nm.className = "c-name"; nm.textContent = j.name;
|
|
130
|
+
const wh = document.createElement("div"); wh.className = "c-when"; wh.textContent = fmtWhen(j.startedAt);
|
|
131
|
+
row.append(st, nm, wh);
|
|
132
|
+
row.addEventListener("click", () => openJobDetail(j));
|
|
133
|
+
box.appendChild(row);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// add a job that just ran (or was rejected) in the project that is open now
|
|
137
|
+
function pushJob(row) {
|
|
138
|
+
if (typeof row === "string") row = { id: row, status: arguments[1], startedAt: new Date().toISOString(), name: humanName(row) };
|
|
139
|
+
row.projectPath = S.projectPath;
|
|
140
|
+
S.sessionJobs = (S.sessionJobs || []).filter((x) => x.id !== row.id); S.sessionJobs.unshift(row); S.sessionJobs.length = Math.min(S.sessionJobs.length, 100);
|
|
141
|
+
const existed = S.jobs.some((x) => x.id === row.id);
|
|
142
|
+
S.jobs = S.jobs.filter((x) => x.id !== row.id); S.jobs.unshift(row); S.jobTotal = (S.jobTotal || 0) + (existed ? 0 : 1);
|
|
143
|
+
S.jobs.length = Math.min(S.jobs.length, MAX_JOBS_SHOWN);
|
|
144
|
+
renderJobs();
|
|
145
|
+
}
|
|
146
|
+
async function loadRecentJobs() {
|
|
147
|
+
const path = S.projectPath || "", rows = [];
|
|
148
|
+
if (path) {
|
|
149
|
+
const dir = dirname(path) + "/EDIT_LOGS/results";
|
|
150
|
+
try {
|
|
151
|
+
for (const name of await fs.readdir(dir)) {
|
|
152
|
+
if (!ID_RE.test(name)) continue;
|
|
153
|
+
try {
|
|
154
|
+
const r = JSON.parse(await readText(dir + "/" + name + "/result.json"));
|
|
155
|
+
let src = ""; if (!(r.action && ACTIONS[r.action])) { try { src = await readText(dirname(path) + "/EDIT_LOGS/jobs/" + name + ".js"); } catch (e) { } } // older jobs: infer from the script
|
|
156
|
+
rows.push(rowFromResult(r, src));
|
|
157
|
+
} catch (e) { }
|
|
158
|
+
}
|
|
159
|
+
} catch (e) { }
|
|
160
|
+
}
|
|
161
|
+
for (const j of (S.sessionJobs || [])) if (j.projectPath === path && !rows.some((x) => x.id === j.id)) rows.push(j); // e.g. rejected jobs, or an unsaved project
|
|
162
|
+
rows.sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
|
|
163
|
+
S.jobTotal = rows.length;
|
|
164
|
+
S.jobs = rows.slice(0, MAX_JOBS_SHOWN);
|
|
165
|
+
renderJobs();
|
|
166
|
+
}
|
|
167
|
+
// which project is open? Reload the list when it changes.
|
|
168
|
+
async function watchProject() {
|
|
169
|
+
try {
|
|
170
|
+
const p = await activeProject();
|
|
171
|
+
const path = p ? String(p.path || "") : "", name = p ? String(p.name || "").replace(/\.prproj$/i, "") : "";
|
|
172
|
+
$("projName").textContent = name || "No project open";
|
|
173
|
+
if (path !== S.projectPath) { S.projectPath = path; await loadRecentJobs(); }
|
|
174
|
+
} catch (e) { }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// job detail window
|
|
178
|
+
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; }
|
|
179
|
+
async function openJobDetail(j) {
|
|
180
|
+
const body = $("jobDetail"); body.textContent = "";
|
|
181
|
+
let r = j.result || null;
|
|
182
|
+
const ws = S.projectPath ? dirname(S.projectPath) + "/EDIT_LOGS" : null;
|
|
183
|
+
if (!r && ws) { try { r = JSON.parse(await readText(ws + "/results/" + j.id + "/result.json")); } catch (e) { } }
|
|
184
|
+
r = r || {};
|
|
185
|
+
const head = el("div", "jd-head"), ic = document.createElement("img");
|
|
186
|
+
ic.setAttribute("src", "icons/" + (STATUS_ICON[j.status] || "status-unknown") + ".svg"); ic.setAttribute("width", "18"); ic.setAttribute("height", "18");
|
|
187
|
+
head.append(ic, el("div", "jd-title", j.name), el("div", "jd-status st-" + (STATUS_ICON[j.status] ? j.status : "unknown"), j.status));
|
|
188
|
+
body.appendChild(head);
|
|
189
|
+
const kv = (k, v) => { if (v === undefined || v === null || v === "") return; const row = el("div", "jd-row"); row.append(el("div", "jd-k", k), el("div", "jd-v", v)); body.appendChild(row); };
|
|
190
|
+
const info = actionInfo(j.action);
|
|
191
|
+
kv("What happened", r.description || j.description || info.summary);
|
|
192
|
+
kv("Action", info.label + (j.actionInferred ? " (inferred from the script; a job can declare it with // @action)" : ""));
|
|
193
|
+
kv("Job id", j.id);
|
|
194
|
+
kv("Started", fmtWhen(j.startedAt) + (j.durationMs !== undefined ? " (" + fmtDur(j.durationMs) + ")" : ""));
|
|
195
|
+
kv("Project", r.project && r.project.path ? r.project.path : (S.projectPath || "(unsaved)"));
|
|
196
|
+
kv("Premiere", r.appVersion);
|
|
197
|
+
if (r.error) {
|
|
198
|
+
body.appendChild(el("div", "jd-h", "Error"));
|
|
199
|
+
body.appendChild(el("div", "jd-pre jd-err", r.error.message + (r.error.line ? "\n(line " + r.error.line + ")" : "") + (r.error.source ? "\n> " + String(r.error.source).trim() : "")));
|
|
200
|
+
}
|
|
201
|
+
if (r.log && r.log.length) { body.appendChild(el("div", "jd-h", "Log (" + r.log.length + " line" + (r.log.length === 1 ? "" : "s") + ")")); body.appendChild(el("div", "jd-pre", r.log.slice(0, 200).join("\n") + (r.log.length > 200 ? "\n... " + (r.log.length - 200) + " more" : ""))); }
|
|
202
|
+
const frames = (r.snapshots || []).filter((x) => x.ok);
|
|
203
|
+
if (frames.length) { body.appendChild(el("div", "jd-h", "Frames exported (" + frames.length + ")")); body.appendChild(el("div", "jd-pre", frames.map((x) => (x.sequence || x.comp) + " @ " + x.time + " s " + x.file).join("\n"))); }
|
|
204
|
+
if (r.organized && r.organized.moved && r.organized.moved.length) { body.appendChild(el("div", "jd-h", "Filed into bins")); body.appendChild(el("div", "jd-pre", r.organized.moved.join("\n"))); }
|
|
205
|
+
if (r.returnValue !== null && r.returnValue !== undefined) { let t = ""; try { t = JSON.stringify(r.returnValue, null, 1); } catch (e) { t = String(r.returnValue); } body.appendChild(el("div", "jd-h", "Returned")); body.appendChild(el("div", "jd-pre", t.length > 2500 ? t.slice(0, 2500) + "\n... (truncated; full value in result.json)" : t)); }
|
|
206
|
+
// Developer-only extras (job script, script path): provided by dev.js, which is not part of packaged builds.
|
|
207
|
+
if (typeof DEV !== "undefined" && DEV && typeof DEV.jobDetail === "function") { try { await DEV.jobDetail({ body, el, job: j, ws, readText }); } catch (e) { } }
|
|
208
|
+
if (ws) { body.appendChild(el("div", "jd-h", "Files")); body.appendChild(el("div", "jd-pre", ws + "/results/" + j.id + "/result.json")); }
|
|
209
|
+
const dlg = $("jobDialog");
|
|
210
|
+
if (typeof dlg.uxpShowModal === "function") {
|
|
211
|
+
try { await dlg.uxpShowModal({ title: "Job details", resize: "both", size: { width: 560, height: 410 }, minSize: { width: 560, height: 410 }, maxSize: { width: 560, height: 410 } }); return; } catch (e) { }
|
|
212
|
+
}
|
|
213
|
+
dlg.classList.add("fallback");
|
|
214
|
+
}
|
|
215
|
+
$("closeJob").addEventListener("click", () => { const d = $("jobDialog"); if (d.classList.contains("fallback")) d.classList.remove("fallback"); else { try { d.close("done"); } catch (e) { } } });
|
|
216
|
+
|
|
217
|
+
async function ensureDirs() {
|
|
218
|
+
for (const n of ["inbox", "running", "done", "out", "backups", "rejected"]) await mkdirs(sub(n));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ------------------------------------------------------------ BRIDGE API handed to jobs
|
|
222
|
+
async function activeProject() { try { return await ppro.Project.getActiveProject(); } catch (e) { return null; } }
|
|
223
|
+
|
|
224
|
+
async function findSequence(project, name) {
|
|
225
|
+
const seqs = await project.getSequences();
|
|
226
|
+
const hits = [];
|
|
227
|
+
for (const s of seqs) if (s.name === name) hits.push(s);
|
|
228
|
+
if (!hits.length) throw new Error(`PLAYRIG.sequence: no sequence named "${name}"`);
|
|
229
|
+
if (hits.length > 1) throw new Error(`PLAYRIG.sequence: ${hits.length} sequences are named "${name}"`);
|
|
230
|
+
return hits[0];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function makeBridge(id, project) {
|
|
234
|
+
const logLines = [], snapQueue = [];
|
|
235
|
+
let ret = null;
|
|
236
|
+
const B = {
|
|
237
|
+
root: root(), ppro, project,
|
|
238
|
+
log(m) { logLines.push(typeof m === "string" ? m : JSON.stringify(m)); },
|
|
239
|
+
ret(v) { ret = v; },
|
|
240
|
+
sequence: (name) => findSequence(project, name),
|
|
241
|
+
snap(sequence, time) { snapQueue.push({ sequence, time: Number(time) || 0 }); },
|
|
242
|
+
// Runs fn(compoundAction) as ONE undo step. UNVERIFIED: signature taken from the Premiere UXP docs.
|
|
243
|
+
transact(name, fn) {
|
|
244
|
+
return project.lockedAccess(() => project.executeTransaction((ca) => fn(ca), name));
|
|
245
|
+
},
|
|
246
|
+
// Minimal in v0.1: sequence names and sizes. Extend per-track/clip once the DOM is verified.
|
|
247
|
+
async inspect() {
|
|
248
|
+
const out = { project: project.name, sequences: [] };
|
|
249
|
+
for (const s of await project.getSequences()) out.sequences.push({ name: s.name });
|
|
250
|
+
return out;
|
|
251
|
+
},
|
|
252
|
+
_log: () => logLines, _snaps: () => snapQueue, _ret: () => ret
|
|
253
|
+
};
|
|
254
|
+
// Incremental editing: tag / owned / adopt / sync / place / remove / setLevelDb / levelKeys (see incremental.js).
|
|
255
|
+
Object.assign(B, makeIncremental(ppro, project, B));
|
|
256
|
+
// Project organization: fileStrays / organizePlan / ensureBin / moveItem / importInto (see organize.js).
|
|
257
|
+
Object.assign(B, makeOrganizer(ppro, project, B));
|
|
258
|
+
return B;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function takeSnapshots(project, list, outDir, defaults) {
|
|
262
|
+
const res = [];
|
|
263
|
+
for (const s of list) {
|
|
264
|
+
const r = { sequence: s.sequence, time: s.time, file: null, ok: false };
|
|
265
|
+
try {
|
|
266
|
+
const seq = await findSequence(project, s.sequence);
|
|
267
|
+
const name = `${s.sequence.replace(/[^A-Za-z0-9_-]+/g, "_")}_${Number(s.time).toFixed(3)}.png`;
|
|
268
|
+
const tick = ppro.TickTime.createWithSeconds(Number(s.time));
|
|
269
|
+
let w = defaults.width, h = defaults.height;
|
|
270
|
+
if (!w || !h) { try { const fsz = await seq.getFrameSize(); w = fsz.width; h = fsz.height; } catch (e) { w = 1920; h = 1080; } }
|
|
271
|
+
const ok = await ppro.Exporter.exportSequenceFrame(seq, tick, name, outDir + "/", w, h);
|
|
272
|
+
r.file = name;
|
|
273
|
+
r.ok = !!ok;
|
|
274
|
+
// Verify the PNG exists and is non-empty, polling briefly in case export returns early.
|
|
275
|
+
for (let i = 0; i < 100 && !(await pngReady(outDir + "/" + name)); i++) await new Promise((x) => setTimeout(x, 100));
|
|
276
|
+
r.ok = await pngReady(outDir + "/" + name);
|
|
277
|
+
if (!r.ok) r.error = "PNG not written within 10 s (exporter returned " + ok + ")";
|
|
278
|
+
} catch (e) { r.error = e.message; }
|
|
279
|
+
res.push(r);
|
|
280
|
+
}
|
|
281
|
+
return res;
|
|
282
|
+
}
|
|
283
|
+
async function pngReady(p) { try { return (await fs.lstat(p)).size > 0; } catch (e) { return false; } }
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
// ------------------------------------------------------------ asset manifest
|
|
287
|
+
function relPath(from, to) {
|
|
288
|
+
const a = norm(from).split("/"), b = norm(to).split("/");
|
|
289
|
+
let i = 0;
|
|
290
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
291
|
+
return a.slice(i).map(() => "..").concat(b.slice(i)).join("/");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function collectAssets(project, projDir, sequenceNames) {
|
|
295
|
+
const items = [];
|
|
296
|
+
async function walk(folder, path, depth) {
|
|
297
|
+
for (const it of await folder.getItems()) {
|
|
298
|
+
const o = { name: it.name, bin: path || "/" };
|
|
299
|
+
let isBin = false;
|
|
300
|
+
try { isBin = !!ppro.FolderItem.cast(it); } catch (e) { }
|
|
301
|
+
if (isBin) {
|
|
302
|
+
o.kind = "bin"; items.push(o);
|
|
303
|
+
if (depth < 8) await walk(ppro.FolderItem.cast(it), path + "/" + it.name, depth + 1);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const c = ppro.ClipProjectItem.cast(it);
|
|
308
|
+
const f = c ? await c.getMediaFilePath() : "";
|
|
309
|
+
if (f && f.charAt(0) === "/" || /^[A-Za-z]:[\\/]/.test(f || "")) {
|
|
310
|
+
o.kind = "media"; o.file = norm(f); o.relative = relPath(projDir, f);
|
|
311
|
+
o.offline = !!(await c.isOffline()) || !(await exists(f));
|
|
312
|
+
} else { o.kind = sequenceNames.indexOf(it.name) >= 0 ? "sequence" : "generated"; }
|
|
313
|
+
} catch (e) { o.kind = "unknown"; o.error = e.message; }
|
|
314
|
+
items.push(o);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
await walk(await project.getRootItem(), "", 0);
|
|
318
|
+
return { generatedAt: new Date().toISOString(), projectFolder: projDir, items };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function assetsMarkdown(a) {
|
|
322
|
+
const media = a.items.filter((x) => x.kind === "media"), seqs = a.items.filter((x) => x.kind === "sequence");
|
|
323
|
+
let s = `# Project assets\n\nGenerated ${a.generatedAt} by Playrig for Premiere Pro. Relative paths are from the .prproj folder (\`${a.projectFolder}\`).\n\n`;
|
|
324
|
+
s += `## Sequences (${seqs.length})\n\n` + seqs.map((x) => `- ${x.name} (bin ${x.bin})`).join("\n") + "\n\n";
|
|
325
|
+
s += `## Media (${media.length})\n\n`;
|
|
326
|
+
for (const x of media) s += `- ${x.name} (bin ${x.bin}): \`${x.relative}\`${x.offline ? " **OFFLINE/MISSING**" : ""}\n`;
|
|
327
|
+
s += "\n## Bins\n\n" + a.items.filter((x) => x.kind === "bin").map((x) => `- ${x.bin === "/" ? "" : x.bin}/${x.name}`).join("\n") + "\n";
|
|
328
|
+
return s;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ------------------------------------------------------------ project-folder sync
|
|
332
|
+
const WS_README = `# EDIT_LOGS
|
|
333
|
+
|
|
334
|
+
Written automatically by the Playrig for Premiere Pro next to the .prproj. Move this folder with the project.
|
|
335
|
+
|
|
336
|
+
- \`JOURNAL.md\`: chronological record of every job (newest last).
|
|
337
|
+
- \`jobs/<id>.js\` (+ \`<id>.json\`): the exact scripts run on this project.
|
|
338
|
+
- \`results/<id>/\`: \`result.json\` and PNG frames of each job.
|
|
339
|
+
- \`assets.md\` / \`assets.json\`: bins, sequences and media files in the project as of the last job (OFFLINE / EXTERNAL flagged, with paths relative to the .prproj folder).
|
|
340
|
+
|
|
341
|
+
To continue elsewhere: open the project, read \`JOURNAL.md\` and \`assets.md\`, relink any missing media, then submit new jobs as usual.
|
|
342
|
+
`;
|
|
343
|
+
|
|
344
|
+
function journalEntry(r, jobFile) {
|
|
345
|
+
let s = `\n## ${r.id} [${r.status}]\n\n- started: ${r.startedAt} (${r.durationMs} ms), Premiere ${r.appVersion}\n`;
|
|
346
|
+
if (jobFile) s += `- script: \`jobs/${jobFile}\`\n`;
|
|
347
|
+
if (r.error) s += `- error: ${r.error.message}${r.error.line ? ` (line ${r.error.line})` : ""}\n`;
|
|
348
|
+
if (r.returnValue !== null && r.returnValue !== undefined) s += `- return: \`${JSON.stringify(r.returnValue).slice(0, 500)}\`\n`;
|
|
349
|
+
if (r.log.length) s += "- log:\n" + r.log.slice(0, 100).map((l) => " " + String(l).replace(/[\r\n]+/g, " ")).join("\n") + "\n";
|
|
350
|
+
for (const sn of r.snapshots) if (sn.ok) s += `- frame: \`results/${r.id}/${sn.file}\` (${sn.sequence} @ ${sn.time})\n`;
|
|
351
|
+
return s;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function syncOne(ws, id, runFile, optFile, outDir) {
|
|
355
|
+
if (runFile && (await exists(runFile))) await copyFile(runFile, ws + "/jobs");
|
|
356
|
+
if (optFile && (await exists(optFile))) await copyFile(optFile, ws + "/jobs");
|
|
357
|
+
await mkdirs(ws + "/results/" + id);
|
|
358
|
+
for (const name of await fs.readdir(outDir)) if (!/\.tmp$/i.test(name)) await copyFile(outDir + "/" + name, ws + "/results/" + id);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Never throws: a sync problem must not fail the job.
|
|
362
|
+
async function syncToProject(project, id, result, runFile, optFile, outDir) {
|
|
363
|
+
let projPath = null;
|
|
364
|
+
try { projPath = project && project.path; } catch (e) { }
|
|
365
|
+
if (!projPath) {
|
|
366
|
+
S.unsynced.push(id);
|
|
367
|
+
return { ok: false, reason: "project has no saved path; will sync after the next job once it is saved" };
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
const ws = dirname(projPath) + "/EDIT_LOGS";
|
|
371
|
+
await mkdirs(ws);
|
|
372
|
+
if (!(await exists(ws + "/README.md"))) await writeText(ws + "/README.md", WS_README);
|
|
373
|
+
const pend = S.unsynced; S.unsynced = [];
|
|
374
|
+
for (const pid of pend) {
|
|
375
|
+
try {
|
|
376
|
+
await syncOne(ws, pid, sub("done") + "/" + pid + ".js", sub("done") + "/" + pid + ".json", sub("out") + "/" + pid);
|
|
377
|
+
await appendText(ws + "/JOURNAL.md", `\n## ${pid} (ran before the project was first saved; see results/${pid}/result.json)\n`);
|
|
378
|
+
} catch (e) { }
|
|
379
|
+
}
|
|
380
|
+
result.sync = { ok: true, dir: ws };
|
|
381
|
+
await writeAtomic(outDir + "/result.json", JSON.stringify(result, null, 2) + "\n");
|
|
382
|
+
await syncOne(ws, id, runFile, optFile, outDir);
|
|
383
|
+
if (!(await exists(ws + "/JOURNAL.md"))) await writeText(ws + "/JOURNAL.md", `# Playrig journal\n\nProject: ${basename(projPath)}\n`);
|
|
384
|
+
await appendText(ws + "/JOURNAL.md", journalEntry(result, basename(runFile)));
|
|
385
|
+
try {
|
|
386
|
+
const names = (await project.getSequences()).map((q) => q.name);
|
|
387
|
+
const assets = await collectAssets(project, dirname(projPath), names);
|
|
388
|
+
await writeText(ws + "/assets.json", JSON.stringify(assets, null, 2) + "\n");
|
|
389
|
+
await writeText(ws + "/assets.md", assetsMarkdown(assets));
|
|
390
|
+
} catch (e) { result.sync.assetsError = e.message; }
|
|
391
|
+
return result.sync;
|
|
392
|
+
} catch (e) { return { ok: false, reason: e.message }; }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Copies the saved .prproj (reflects the last save, not unsaved edits).
|
|
396
|
+
async function backupProject(project, id) {
|
|
397
|
+
try {
|
|
398
|
+
const p = project && project.path;
|
|
399
|
+
if (!p || !(await exists(p))) return { ok: false, reason: "project has never been saved" };
|
|
400
|
+
await mkdirs(sub("backups"));
|
|
401
|
+
const dest = sub("backups") + "/" + basename(p).replace(/\.prproj$/i, "") + "-" + id + ".prproj";
|
|
402
|
+
await fs.copyFile(p, dest);
|
|
403
|
+
return { ok: true, file: dest, reflectsUnsavedChanges: false };
|
|
404
|
+
} catch (e) { return { ok: false, reason: e.message }; }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ------------------------------------------------------------ job handling
|
|
408
|
+
async function simpleResult(id, status, message, extra) {
|
|
409
|
+
await mkdirs(sub("out") + "/" + id);
|
|
410
|
+
await writeAtomic(sub("out") + "/" + id + "/result.json", JSON.stringify(Object.assign({
|
|
411
|
+
id, status, startedAt: new Date().toISOString(), error: { message }, log: [], snapshots: []
|
|
412
|
+
}, extra || {}), null, 2) + "\n");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function reject(file, reason) {
|
|
416
|
+
const base = basename(file).replace(/\.js$/i, "");
|
|
417
|
+
let id = base, dest = file;
|
|
418
|
+
if (!ID_RE.test(base)) {
|
|
419
|
+
id = `rejected-${stamp()}-${++S.seq}`;
|
|
420
|
+
dest = dirname(file) + "/" + id + ".js";
|
|
421
|
+
try { await fs.rename(file, dest); } catch (e) { try { await fs.unlink(file); } catch (e2) { } dest = null; }
|
|
422
|
+
}
|
|
423
|
+
await simpleResult(id, "rejected", reason, { originalName: basename(file) });
|
|
424
|
+
if (dest && (await exists(dest))) await moveFile(dest, sub("rejected"));
|
|
425
|
+
pushJob({ id, status: "rejected", startedAt: new Date().toISOString(), action: "job.rejected", name: actionInfo("job.rejected").label, error: reason, description: "Refused before running: " + reason });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function pickJob() {
|
|
429
|
+
const valid = [];
|
|
430
|
+
for (const name of await fs.readdir(sub("inbox"))) {
|
|
431
|
+
if (!/\.js$/i.test(name)) continue; // ignores *.tmp and the options *.json
|
|
432
|
+
const p = sub("inbox") + "/" + name;
|
|
433
|
+
if (!ID_RE.test(name.replace(/\.js$/i, ""))) { await reject(p, "File name must match ^[A-Za-z0-9_-]{1,64}$"); continue; }
|
|
434
|
+
const st = await fs.lstat(p);
|
|
435
|
+
if (st.size > MAX_BYTES) { await reject(p, "Job file is larger than 2 MB"); continue; }
|
|
436
|
+
valid.push({ p, t: mtimeOf(st) });
|
|
437
|
+
}
|
|
438
|
+
valid.sort((a, b) => a.t - b.t);
|
|
439
|
+
return valid.length ? valid[0].p : null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function errInfo(e, src) {
|
|
443
|
+
const info = { message: e && e.message ? e.message : String(e), line: null, fileName: null, source: null };
|
|
444
|
+
// UXP error stacks look like "...<anonymous>:LINE:COL"; the AsyncFunction wrapper adds 2 header lines.
|
|
445
|
+
const m = e && e.stack ? /<anonymous>:(\d+):\d+/.exec(e.stack) : null;
|
|
446
|
+
if (m && src) {
|
|
447
|
+
info.line = Math.max(1, Number(m[1]) - 2);
|
|
448
|
+
info.source = src.split(/\r\n|\r|\n/)[info.line - 1] || null;
|
|
449
|
+
}
|
|
450
|
+
return info;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function runJob(inboxFile) {
|
|
454
|
+
const id = basename(inboxFile).replace(/\.js$/i, "");
|
|
455
|
+
const t0 = Date.now();
|
|
456
|
+
S.busy = true;
|
|
457
|
+
setStatus("Running " + id);
|
|
458
|
+
let result;
|
|
459
|
+
try {
|
|
460
|
+
const runFile = await moveFile(inboxFile, sub("running"));
|
|
461
|
+
const optInbox = sub("inbox") + "/" + id + ".json";
|
|
462
|
+
const optFile = (await exists(optInbox)) ? await moveFile(optInbox, sub("running")) : null;
|
|
463
|
+
const outDir = sub("out") + "/" + id;
|
|
464
|
+
await mkdirs(outDir);
|
|
465
|
+
if (await exists(outDir + "/result.json")) await fs.unlink(outDir + "/result.json");
|
|
466
|
+
|
|
467
|
+
const project = await activeProject();
|
|
468
|
+
result = { id, status: "ok", startedAt: new Date(t0).toISOString(), durationMs: 0, appVersion: String(await safeVersion()),
|
|
469
|
+
project: { path: safe(() => project.path), name: safe(() => project.name) }, error: null, log: [], snapshots: [], inspect: null, returnValue: null };
|
|
470
|
+
|
|
471
|
+
let opts = {};
|
|
472
|
+
try { if (optFile) opts = JSON.parse(await readText(optFile)); } catch (e) { result.status = "rejected"; result.error = errInfo(e); }
|
|
473
|
+
|
|
474
|
+
if (getSet("backup", "1") === "1") result.backup = await backupProject(project, id);
|
|
475
|
+
const B = makeBridge(id, project);
|
|
476
|
+
if (!project) { result.status = "error"; result.error = { message: "No active project", line: null, fileName: null, source: null }; }
|
|
477
|
+
|
|
478
|
+
if (result.status === "ok") {
|
|
479
|
+
const src = (await readText(runFile)).replace(/^/, "");
|
|
480
|
+
const hdr = parseJobHeader(src), declared = opts.action || hdr.action;
|
|
481
|
+
result.description = opts.description || hdr.description || null;
|
|
482
|
+
result.action = declared || inferAction(src, id);
|
|
483
|
+
result.actionSource = declared ? "declared" : "inferred";
|
|
484
|
+
const timeoutMs = (opts.timeoutSec || 120) * 1000;
|
|
485
|
+
let timer;
|
|
486
|
+
try {
|
|
487
|
+
const fn = new AsyncFunction("PLAYRIG", "PLAYPILOT", "BRIDGE", "ppro", "alert", "confirm", src); // PLAYPILOT, BRIDGE: legacy aliases for older job scripts
|
|
488
|
+
const job = fn(B, B, B, ppro, (m) => B.log("[alert] " + m), (m) => { B.log("[confirm -> false] " + m); return false; });
|
|
489
|
+
const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(Object.assign(new Error(`Job exceeded timeoutSec=${opts.timeoutSec || 120}; it keeps running in the background`), { isTimeout: true })), timeoutMs); });
|
|
490
|
+
await Promise.race([job, timeout]);
|
|
491
|
+
} catch (e) {
|
|
492
|
+
result.status = e && e.isTimeout ? "timeout" : "error";
|
|
493
|
+
result.error = e && e.isTimeout ? { message: e.message, line: null, fileName: null, source: null } : errInfo(e, src);
|
|
494
|
+
} finally { clearTimeout(timer); }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (project && result.status !== "rejected") {
|
|
498
|
+
const list = [];
|
|
499
|
+
for (const sn of opts.snapshots || []) for (const t of sn.times || []) list.push({ sequence: sn.sequence, time: t });
|
|
500
|
+
list.push(...B._snaps());
|
|
501
|
+
result.snapshots = await takeSnapshots(project, list, outDir, { width: opts.width, height: opts.height });
|
|
502
|
+
if (opts.inspect) { try { result.inspect = await B.inspect(opts.inspect.sequence, opts.inspect.depth); } catch (e) { result.inspect = { error: e.message }; } }
|
|
503
|
+
}
|
|
504
|
+
if (result.actionSource === "inferred" && result.action === "custom" && result.snapshots.some((x) => x.ok)) result.action = "frame.export";
|
|
505
|
+
result.log = B._log();
|
|
506
|
+
result.returnValue = B._ret();
|
|
507
|
+
// Keep the project organized: file NEW loose items into their bins (never moves items already in a bin, never renames).
|
|
508
|
+
if (project && getSet("organize", "1") === "1") {
|
|
509
|
+
try { const o = await B.fileStrays(); result.organized = { moved: o.moved, needsDecision: o.decisions }; if (o.moved.length) result.log.push("[organize] filed " + o.moved.join("; ")); o.decisions.forEach((d) => result.log.push("[organize] needs a decision: " + d.name + ": " + d.why)); }
|
|
510
|
+
catch (e) { result.organized = { error: e.message }; }
|
|
511
|
+
}
|
|
512
|
+
result.durationMs = Date.now() - t0;
|
|
513
|
+
|
|
514
|
+
await writeAtomic(outDir + "/result.json", JSON.stringify(result, null, 2) + "\n");
|
|
515
|
+
result.sync = await syncToProject(project, id, result, runFile, optFile, outDir);
|
|
516
|
+
await writeAtomic(outDir + "/result.json", JSON.stringify(result, null, 2) + "\n");
|
|
517
|
+
if (result.sync.ok) await copyFile(outDir + "/result.json", result.sync.dir + "/results/" + id);
|
|
518
|
+
await moveFile(runFile, sub("done"));
|
|
519
|
+
if (optFile) await moveFile(optFile, sub("done"));
|
|
520
|
+
pushJob(Object.assign(rowFromResult(result), { result }));
|
|
521
|
+
setStatus(result.status === "error" ? "Error (" + id + ")" : "Idle");
|
|
522
|
+
} catch (fatal) {
|
|
523
|
+
try { await simpleResult(id, "error", "Playrig failure: " + fatal.message); } catch (e) { }
|
|
524
|
+
pushJob({ id, status: "error", startedAt: new Date().toISOString(), action: "custom", name: actionInfo("custom").label, error: "Playrig failure: " + fatal.message });
|
|
525
|
+
uiLog("Playrig failure: " + fatal.message);
|
|
526
|
+
setStatus("Error");
|
|
527
|
+
} finally { S.busy = false; }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const safe = (fn) => { try { const v = fn(); return v === undefined ? null : v; } catch (e) { return null; } };
|
|
531
|
+
async function safeVersion() { try { return require("uxp").host.version; } catch (e) { return "unknown"; } }
|
|
532
|
+
|
|
533
|
+
async function tick() {
|
|
534
|
+
if (!S.on || S.busy) return;
|
|
535
|
+
try {
|
|
536
|
+
const job = await pickJob();
|
|
537
|
+
if (job) await runJob(job);
|
|
538
|
+
} catch (e) { setStatus("Error"); uiLog("tick: " + e.message); }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ------------------------------------------------------------ organize (button)
|
|
542
|
+
async function organizeNow() {
|
|
543
|
+
if (S.busy) { uiLog("A job is running; try again in a moment."); return; }
|
|
544
|
+
S.busy = true;
|
|
545
|
+
const wasStatus = $("status").textContent;
|
|
546
|
+
setStatus("Organizing...");
|
|
547
|
+
try {
|
|
548
|
+
const project = await activeProject();
|
|
549
|
+
if (!project) { uiLog("No project is open."); return; }
|
|
550
|
+
const B = makeBridge("organize", project);
|
|
551
|
+
const r = await B.organizeProject({ fix: true, scheme: true });
|
|
552
|
+
const moved = r.moved.length, bins = r.createdBins.length, dec = r.decisions.length;
|
|
553
|
+
const parts = [];
|
|
554
|
+
if (moved) parts.push("moved " + moved + " item" + (moved === 1 ? "" : "s"));
|
|
555
|
+
if (bins) parts.push("created " + bins + " bin" + (bins === 1 ? "" : "s"));
|
|
556
|
+
if (dec) parts.push(dec + " need" + (dec === 1 ? "s" : "") + " a decision: " + r.decisions.map((d) => d.name).slice(0, 3).join(", ") + (dec > 3 ? "..." : ""));
|
|
557
|
+
uiLog((parts.length ? "Organized: " + parts.join(", ") + "." : "Already organized.") + (r.warnings.length ? " Warning: " + r.warnings[0] : "") + " (structure: " + r.source + ")");
|
|
558
|
+
// journal entry next to the project, like a job
|
|
559
|
+
const pp = project.path;
|
|
560
|
+
if (pp && (moved || bins || dec)) {
|
|
561
|
+
try {
|
|
562
|
+
const ws = dirname(pp) + "/EDIT_LOGS"; await mkdirs(ws);
|
|
563
|
+
let t = "\n## organize-" + stamp() + " [manual]\n\n- structure: " + r.source + "\n";
|
|
564
|
+
if (bins) t += "- created bins: " + r.createdBins.join(", ") + "\n";
|
|
565
|
+
r.moved.forEach((m) => { t += "- moved " + m + "\n"; });
|
|
566
|
+
r.decisions.forEach((d) => { t += "- needs a decision: " + d.name + ": " + d.why + "\n"; });
|
|
567
|
+
await appendText(ws + "/JOURNAL.md", t);
|
|
568
|
+
} catch (e) { }
|
|
569
|
+
}
|
|
570
|
+
} catch (e) { uiLog("Organize failed: " + e.message); }
|
|
571
|
+
finally { S.busy = false; setStatus(S.on ? "Idle" : "Stopped"); }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Writes the effective structure to <project folder>/EDIT_LOGS/organize.json when there is none, then tries to open it.
|
|
575
|
+
async function editStructure() {
|
|
576
|
+
const say = (t) => { uiLog(t); const el = $("structMsg"); if (el) el.textContent = t; }; // the dialog is modal, so show it there too
|
|
577
|
+
say("Working...");
|
|
578
|
+
try {
|
|
579
|
+
const project = await activeProject();
|
|
580
|
+
if (!project || !project.path) { say("Open and save a project first; the structure file lives in its EDIT_LOGS folder."); return; }
|
|
581
|
+
await mkdirs(dirname(project.path) + "/EDIT_LOGS");
|
|
582
|
+
const B = makeBridge("organize", project), w = await B.writeStructure();
|
|
583
|
+
let how = "";
|
|
584
|
+
try { await uxp.shell.openPath(w.file); how = (w.created ? "Created and opened " : "Opened ") + w.file; }
|
|
585
|
+
catch (e) { how = (w.created ? "Created " : "Structure file: ") + w.file + ". Could not open it automatically (" + (e && e.message ? e.message : e) + "). Unload and load the plugin in the UXP Developer Tool so the new permission applies, or open the file in any text editor."; }
|
|
586
|
+
say(how + (w.created ? "" : "") + " Edit it, then press Organize Project.");
|
|
587
|
+
} catch (e) { say("Edit structure failed: " + (e && e.message ? e.message : e)); }
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ------------------------------------------------------------ start / stop
|
|
591
|
+
async function start() {
|
|
592
|
+
if (S.on) return;
|
|
593
|
+
try { await ensureDirs(); } catch (e) { setStatus("Error"); uiLog("Cannot create folders: " + e.message); return; }
|
|
594
|
+
S.on = true;
|
|
595
|
+
S.timer = setInterval(tick, 1000);
|
|
596
|
+
$("powerIcon").setAttribute("src", "icons/power-on.svg");
|
|
597
|
+
uiLog("");
|
|
598
|
+
setStatus("Idle");
|
|
599
|
+
}
|
|
600
|
+
function stop() {
|
|
601
|
+
clearInterval(S.timer);
|
|
602
|
+
S.on = false;
|
|
603
|
+
$("powerIcon").setAttribute("src", "icons/power-off.svg");
|
|
604
|
+
setStatus("Stopped");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function showRoot() { $("root").value = root(); }
|
|
608
|
+
showRoot();
|
|
609
|
+
$("auto").checked = getSet("auto", "0") === "1";
|
|
610
|
+
$("backup").checked = getSet("backup", "1") === "1";
|
|
611
|
+
$("auto").addEventListener("change", () => setSet("auto", $("auto").checked ? "1" : "0"));
|
|
612
|
+
$("backup").addEventListener("change", () => setSet("backup", $("backup").checked ? "1" : "0"));
|
|
613
|
+
$("organize").checked = getSet("organize", "1") === "1";
|
|
614
|
+
$("organize").addEventListener("change", () => setSet("organize", $("organize").checked ? "1" : "0"));
|
|
615
|
+
$("organizeNow").addEventListener("click", organizeNow);
|
|
616
|
+
// Settings: a modal dialog (gear icon). Falls back to an in-panel overlay if this build cannot show modal dialogs.
|
|
617
|
+
async function openSettings() {
|
|
618
|
+
const dlg = $("settingsDialog");
|
|
619
|
+
showRoot();
|
|
620
|
+
if (typeof dlg.uxpShowModal === "function") {
|
|
621
|
+
try { await dlg.uxpShowModal({ title: "Playrig Settings", resize: "both", size: { width: 560, height: 440 }, minSize: { width: 560, height: 440 }, maxSize: { width: 560, height: 440 } }); return; }
|
|
622
|
+
catch (e) { if (e && /cancel|close|dismiss/i.test(String(e.message || e))) return; } // closed by the user
|
|
623
|
+
}
|
|
624
|
+
dlg.classList.add("fallback");
|
|
625
|
+
}
|
|
626
|
+
function closeSettings() {
|
|
627
|
+
const dlg = $("settingsDialog");
|
|
628
|
+
if (dlg.classList.contains("fallback")) { dlg.classList.remove("fallback"); return; }
|
|
629
|
+
try { dlg.close("done"); } catch (e) { dlg.classList.remove("fallback"); }
|
|
630
|
+
}
|
|
631
|
+
// left list switches the right pane
|
|
632
|
+
document.querySelectorAll(".nav-item").forEach((n) => n.addEventListener("click", () => {
|
|
633
|
+
document.querySelectorAll(".nav-item").forEach((m) => m.classList.toggle("active", m === n));
|
|
634
|
+
document.querySelectorAll(".pane").forEach((p) => { p.style.display = p.id === "pane-" + n.getAttribute("data-pane") ? "block" : "none"; });
|
|
635
|
+
}));
|
|
636
|
+
// About: version from the manifest, links open in the default browser (needs the https scheme in launchProcess).
|
|
637
|
+
try { const v = (uxp.versions && uxp.versions.plugin) || "0.1.0"; $("versionText").textContent = "Version " + v + " (preview)"; } catch (e) { }
|
|
638
|
+
function openLink(url) {
|
|
639
|
+
uxp.shell.openExternal(url).then(() => { $("aboutMsg").textContent = ""; }, (e) => { $("aboutMsg").textContent = "Could not open " + url + " (" + (e && e.message ? e.message : e) + "). Unload and load the plugin in the UXP Developer Tool so the new permission applies."; });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ------------------------------------------------------------ updates
|
|
643
|
+
// This panel cannot replace its own files (UXP plugins are installed and updated by Creative Cloud), so it only reports: it reads the
|
|
644
|
+
// public feed (needs the "network" permission in manifest.json), compares versions and links to the release. Checked once a day
|
|
645
|
+
// while "Check for updates automatically" is on (default); the check sends nothing about you or your project.
|
|
646
|
+
const UPDATE_FEED = "https://raw.githubusercontent.com/viveknigam3003/playrig-releases/main/versions.json";
|
|
647
|
+
const UPDATE_PAGE_PREFIX = "https://github.com/viveknigam3003/playrig-releases/";
|
|
648
|
+
const pluginVersion = () => { try { return (uxp.versions && uxp.versions.plugin) || "0.1.0"; } catch (e) { return "0.1.0"; } };
|
|
649
|
+
function isNewerVersion(a, b) {
|
|
650
|
+
const pa = /(\d+)\.(\d+)\.(\d+)/.exec(String(a || "")), pb = /(\d+)\.(\d+)\.(\d+)/.exec(String(b || ""));
|
|
651
|
+
if (!pa || !pb) return false;
|
|
652
|
+
for (let i = 1; i <= 3; i++) if (Number(pa[i]) !== Number(pb[i])) return Number(pa[i]) > Number(pb[i]);
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
let updatePage = null;
|
|
656
|
+
function showUpdate(entry) {
|
|
657
|
+
updatePage = entry && typeof entry.page === "string" && entry.page.startsWith(UPDATE_PAGE_PREFIX) ? entry.page : UPDATE_PAGE_PREFIX + "releases";
|
|
658
|
+
const text = "Playrig " + entry.version + " is available (you have " + pluginVersion() + ")." + (entry.notes ? " " + entry.notes : "");
|
|
659
|
+
$("updateBanner").textContent = text + " Click to download."; $("updateBanner").style.display = "block";
|
|
660
|
+
$("updateMsg").textContent = text; $("updateLink").style.display = "block";
|
|
661
|
+
}
|
|
662
|
+
async function checkForUpdate(manual) {
|
|
663
|
+
setSet("updateCheckedAt", String(Date.now()));
|
|
664
|
+
let feed;
|
|
665
|
+
try { const r = await fetch(UPDATE_FEED, { cache: "no-store" }); if (!r.ok) throw new Error(r.status === 404 ? "No update feed is published yet." : "HTTP " + r.status); feed = await r.json(); }
|
|
666
|
+
catch (e) { if (manual) $("updateMsg").textContent = "Could not check for updates: " + (e && e.message ? e.message : e); return; }
|
|
667
|
+
const entry = feed && feed.pr;
|
|
668
|
+
if (entry && isNewerVersion(entry.version, pluginVersion())) { showUpdate(entry); return; }
|
|
669
|
+
if (manual) { $("updateMsg").textContent = entry ? "You are up to date (version " + pluginVersion() + ")." : "No release has been published yet."; $("updateLink").style.display = "none"; }
|
|
670
|
+
}
|
|
671
|
+
const updateDue = () => Date.now() - (Number(getSet("updateCheckedAt", "0")) || 0) > 24 * 60 * 60 * 1000;
|
|
672
|
+
$("checkUpdate").addEventListener("click", async () => { $("updateMsg").textContent = "Checking..."; await checkForUpdate(true); });
|
|
673
|
+
$("updateLink").addEventListener("click", () => openLink(updatePage));
|
|
674
|
+
$("updateBanner").addEventListener("click", () => { openSettings(); document.querySelectorAll(".nav-item").forEach((m) => { if (m.getAttribute("data-pane") === "about") m.click(); }); });
|
|
675
|
+
$("updateAuto").checked = getSet("updateAuto", "1") === "1";
|
|
676
|
+
$("updateAuto").addEventListener("change", () => setSet("updateAuto", $("updateAuto").checked ? "1" : "0"));
|
|
677
|
+
|
|
678
|
+
$("linkTwitter").addEventListener("click", () => openLink("https://twitter.com/viveknigam_"));
|
|
679
|
+
$("linkInstagram").addEventListener("click", () => openLink("https://www.instagram.com/viveknigam3003/"));
|
|
680
|
+
$("openSettings").addEventListener("click", openSettings);
|
|
681
|
+
$("closeSettings").addEventListener("click", closeSettings);
|
|
682
|
+
$("editStructure").addEventListener("click", editStructure);
|
|
683
|
+
$("toggle").addEventListener("click", () => (S.on ? stop() : start()));
|
|
684
|
+
// Playrig folder: type or paste a path (applied on Enter / leaving the field), or Browse. A native folder picker cannot be used on top of a
|
|
685
|
+
// modal window (nothing is clickable), so Browse closes Settings first and reopens it afterwards.
|
|
686
|
+
const folderSay = (t) => { const el = $("folderMsg"); if (el) el.textContent = t; };
|
|
687
|
+
async function applyRoot(raw) {
|
|
688
|
+
const p = String(raw || "").trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
689
|
+
if (!p) { showRoot(); folderSay("Enter a folder path."); return false; }
|
|
690
|
+
if (!/^(\/|[A-Za-z]:\/)/.test(p)) { showRoot(); folderSay("Use a full path, starting with / (or a drive letter on Windows)."); return false; }
|
|
691
|
+
if (p === root()) { folderSay(""); return true; }
|
|
692
|
+
const prev = getSet("root", "");
|
|
693
|
+
setSet("root", p);
|
|
694
|
+
try { await ensureDirs(); await loadRecentJobs(); showRoot(); folderSay("Playrig folder set. Jobs are now read from " + p + "/inbox."); return true; }
|
|
695
|
+
catch (e) { setSet("root", prev); showRoot(); folderSay("Cannot use that folder: " + (e && e.message ? e.message : e)); return false; }
|
|
696
|
+
}
|
|
697
|
+
$("root").addEventListener("change", () => applyRoot($("root").value));
|
|
698
|
+
$("choose").addEventListener("click", async () => {
|
|
699
|
+
closeSettings();
|
|
700
|
+
let picked = null;
|
|
701
|
+
try { picked = await uxp.storage.localFileSystem.getFolder(); } catch (e) { uiLog("Folder: " + (e && e.message ? e.message : e)); }
|
|
702
|
+
if (picked) await applyRoot(picked.nativePath);
|
|
703
|
+
openSettings();
|
|
704
|
+
});
|
|
705
|
+
uxp.entrypoints.setup({ panels: { playrig: { show() { } } } });
|
|
706
|
+
renderJobs();
|
|
707
|
+
watchProject();
|
|
708
|
+
setInterval(watchProject, 1500);
|
|
709
|
+
pickDefaultRoot().then(() => { if ($("auto").checked) start(); });
|
|
710
|
+
if ($("updateAuto").checked && updateDue()) checkForUpdate(false);
|