-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathiterate.mjs
More file actions
114 lines (103 loc) · 5.54 KB
/
Copy pathiterate.mjs
File metadata and controls
114 lines (103 loc) · 5.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// The default loop: RENDER -> JUDGE -> REVISE -> re-render, until the gate passes
// or the round budget runs out.
//
// WHY THIS IS THE DEFAULT AND NOT AN EXTRA STEP.
//
// Every video this repo has produced was judged exactly once, by whoever
// remembered to run the judge, at the end, when the storyboard was already
// expensive to change. The predictable result: the judge's findings became
// release notes instead of edits. The TrialScope cut shipped at 20/40 -- craft
// 11, comprehension 9 -- and the comprehension half had never been measured at
// all, because nobody had thought to ask "did anyone understand it" as a
// separate question from "is it well made".
//
// A gate that runs once, after the work, is a grade. A gate that runs every
// round, before the work is called done, is a process. This file is the second
// thing. It runs judge-rubric.mjs; `npm run clip` gates itself with judge-video.mjs.
//
// WHAT IT DOES NOT DO, deliberately: it does not edit your storyboard for you.
// The judge's next_cut brief is written to disk as a revision brief and the
// process exits non-zero. A loop that auto-applied its own critic's notes would
// converge on whatever the critic likes, which is not the same as a good demo,
// and there would be no human or agent left holding the taste. Round N+1 starts
// when someone applies the brief.
//
// node iterate.mjs --comp WTC-TShero --out out/trialscope.mp4
// node iterate.mjs --comp WTC-TShero --out out/x.mp4 --for "a frontend engineer" --gate 30
//
import { execFileSync } from "node:child_process";
import { runRemotion } from "./run-remotion.mjs";
import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
const argv = process.argv.slice(2);
const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
const has = (n) => argv.includes(`--${n}`);
const comp = flag("comp");
const out = flag("out", "out/clip.mp4");
const audience = flag("for", "a smart newcomer who has never seen this product and does not know the domain jargon");
const gate = flag("gate", "28"); // 28/40 — see README for where this number came from
const rounds = Number(flag("rounds", "1"));
const entry = flag("entry", "src/index.js");
if (!comp) {
console.error(`usage: node iterate.mjs --comp <CompositionId> --out <file.mp4> [--for "<audience>"] [--gate 28] [--rounds N] [--no-render]`);
process.exit(1);
}
// Why there is no `shell` here, and why the render below is not spawned in this
// file: `npx` is a .cmd shim Node cannot exec without a shell, and a shell
// re-splits every argument on whitespace -- which once truncated the audience
// string to its first word, so a run that reported `--for "a non-technical
// person..."` was actually judged for an audience literally named "a". The
// scores looked plausible, which is what made it survive a read. Everything
// spawned from here is a real executable (`node`), so no shell is needed and
// nothing is re-split. The one command that does need the shim -- the Remotion
// render -- goes through run-remotion.mjs, which quotes for this same reason and
// is where the MAX_PATH explanation for a failed render comes from (defect D1).
const sh = (cmd, args) => execFileSync(cmd, args, { stdio: "inherit" });
const base = out.replace(/\.(mp4|webm|mov)$/i, "");
const LOG = `${base}.rounds.md`;
for (let r = 1; r <= rounds; r++) {
console.log(`\n=== round ${r}/${rounds} — ${comp} for "${audience}" ===\n`);
if (!has("no-render")) {
if (runRemotion(["render", entry, comp, out, "--concurrency=2"]).status !== 0) process.exit(1);
}
// The judge exits non-zero below the gate; that is the signal, not a crash.
let passed = true;
try {
sh("node", ["judge-rubric.mjs", out, "--for", audience, "--gate", gate]);
} catch {
passed = false;
}
const verdict = existsSync(`${base}.judge.json`) ? JSON.parse(readFileSync(`${base}.judge.json`, "utf8")) : null;
const total = verdict ? Object.values(verdict.scores).reduce((a, v) => a + v.score, 0) : 0;
// Append rather than overwrite: the ROUND HISTORY is the artefact worth having.
// A single final score cannot tell you whether the cut improved or whether the
// judge simply drifted, and "20 -> 31 after adding a premise beat" is the only
// form of that claim anyone should believe.
appendFileSync(LOG, [
`\n## Round ${r} — ${total}/40 — ${verdict?.verdict ?? "no verdict"}`,
`audience: ${audience}`,
`weakest: ${verdict?.weakest ?? "-"} · weakest comprehension: ${verdict?.weakest_comprehension ?? "-"}`,
...(verdict?.next_cut ?? []).map((n) => `- **${n.dimension}** @ ${n.where} — ${n.change}`),
``,
].join("\n"));
if (passed) {
console.log(`\n[iterate] round ${r}: ${total}/40 >= ${gate} — shippable. History in ${LOG}`);
process.exit(0);
}
console.log(`\n[iterate] round ${r}: ${total}/40 < ${gate}.`);
if (r === rounds) {
writeFileSync(`${base}.next-cut.md`, [
`# Revision brief — ${comp}, round ${r}`,
``,
`Scored ${total}/40 for: *${audience}*. Gate is ${gate}.`,
`Weakest comprehension dimension: **${verdict?.weakest_comprehension ?? "unknown"}**`,
``,
`Apply these to the storyboard spec, then re-run \`npm run clip\`:`,
``,
...(verdict?.next_cut ?? []).map((n, i) => `${i + 1}. **${n.dimension}** (${n.where}) — ${n.change}`),
``,
`Full scorecard: ${base}.judge.md · round history: ${LOG}`,
].join("\n") + "\n");
console.error(`[iterate] revision brief written to ${base}.next-cut.md — apply it and re-run.`);
process.exit(1);
}
}