@kaydash9999/banker-plugins 0.22.0 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +68 -0
  4. package/PRIVACY.md +1 -0
  5. package/README.md +39 -13
  6. package/bin/banker.js +118 -22
  7. package/bin/lib/codex-hooks.mjs +116 -18
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +93 -1
  10. package/bin/lib/command-skill.cjs +37 -6
  11. package/codex/command-skills/front-qa/SKILL.md +1 -1
  12. package/codex/command-skills/setup/SKILL.md +4 -4
  13. package/codex/hooks.json +5 -5
  14. package/codex/manifest.json +18 -15
  15. package/codex/skills/progress/scripts/progress.mjs +44 -14
  16. package/codex/skills/teamwork/SKILL.md +143 -0
  17. package/codex/skills/teamwork/scripts/teamwork.mjs +1007 -0
  18. package/codex/transform-matrix.md +5 -3
  19. package/hooks/obsidize-hook.mjs +75 -32
  20. package/hooks/telemetry-count-codex.mjs +238 -57
  21. package/hooks/update-fetch.mjs +2 -1
  22. package/hooks/update-notify.mjs +82 -20
  23. package/package.json +3 -1
  24. package/skills/3d-intro-build/SKILL.md +101 -20
  25. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  26. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  27. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  28. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  29. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  30. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  31. package/skills/3d-intro-build/references/prompts.md +51 -4
  32. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  33. package/skills/3d-intro-build/references/step-nav.css +29 -0
  34. package/skills/3d-intro-build/references/step-nav.js +284 -106
  35. package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
  36. package/skills/3d-intro-build/references/verify.md +6 -0
  37. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  38. package/skills/3d-intro-setup/SKILL.md +20 -7
  39. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  40. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  41. package/skills/all-in-one/SKILL.md +125 -86
  42. package/skills/omc-patch/SKILL.md +1 -1
  43. package/skills/payload-mon/SKILL.md +1 -1
  44. package/skills/play-qa/SKILL.md +6 -4
  45. package/skills/progress-mon/README.md +164 -0
  46. package/skills/progress-mon/SKILL.md +165 -0
  47. package/skills/progress-mon/assets/fixture/README.md +1 -0
  48. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  49. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  50. package/skills/progress-mon/assets/fixture/config.json +30 -0
  51. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  52. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  53. package/skills/progress-mon/assets/fixture/items.json +14 -0
  54. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  55. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  56. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  57. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  58. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  59. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  60. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  61. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  62. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  63. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  64. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  65. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  66. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  67. package/skills/progress-mon/references/arch.md +280 -0
  68. package/skills/progress-mon/references/authoring.md +199 -0
  69. package/skills/progress-mon/references/config.md +115 -0
  70. package/skills/progress-mon/references/flow.md +75 -0
  71. package/skills/progress-mon/references/inventory.md +58 -0
  72. package/skills/progress-mon/references/items.md +74 -0
  73. package/skills/progress-mon/references/req-spec.md +74 -0
  74. package/skills/progress-mon/references/reqs.md +30 -0
  75. package/skills/progress-mon/scripts/index.js +31 -0
  76. package/skills/progress-mon/scripts/pm-check.mjs +549 -0
  77. package/skills/progress-mon/scripts/pm-config.mjs +343 -0
  78. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  79. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  80. package/skills/progress-mon/scripts/pm-memory.mjs +333 -0
  81. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  82. package/skills/progress-mon/scripts/pm-server.mjs +781 -0
  83. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  84. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  85. package/skills/ralph-qa/SKILL.md +107 -17
  86. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  87. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  88. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  89. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  90. package/skills/ralph-qa/references/seat-run.mjs +200 -159
  91. package/skills/remains/SKILL.md +2 -0
  92. package/skills/remains/scripts/boxes.mjs +6 -2
  93. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  94. package/skills/setup-mcp/SKILL.md +1 -1
  95. package/skills/setup-omc-hud/SKILL.md +2 -1
  96. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  97. package/skills/ultra-chain/SKILL.md +32 -14
  98. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  99. package/skills/ultra-init/SKILL.md +30 -22
  100. package/skills/ultraqa/NOTICE.md +34 -0
  101. package/skills/ultraqa/SKILL.md +197 -0
  102. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  103. package/skills/ultraqa/references/run-folder.mjs +299 -0
  104. package/skills/ultraqa/references/tmux-drive.md +52 -0
  105. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  106. package/skills/update-banker/SKILL.md +1 -1
@@ -0,0 +1,299 @@
1
+ /*
2
+ * dive mode for 3d-intro-build (CLIP_MODE = dive): the default on every provider, without exits. Measured on
3
+ * sora-2 (2025-12-08, one scene set): each scene shows, arc turns little, the finale lands only through
4
+ * landOnStill. dive on WAN is not measured.
5
+ *
6
+ * Every scene but the last gets a dive that starts from that scene's approved still (so each scene
7
+ * is entered from outside, with a MOVE: push-in, arc or crane). An optional exit clip leaves the
8
+ * scene from the dive's last frame. The last scene is the finale: a clip that pulls back from the
9
+ * closing still, played reversed, so it ends on that still. The seam to the next scene does not
10
+ * rely on the model: when the next clip's actual first frame has a background border (at least
11
+ * `bgShare` of its border band within `bgTolerance` of the band's most common colour, so a roof or a
12
+ * plume touching the edge does not count), the seam side (the exit, or the dive without one) lands on
13
+ * a plate of that background colour (clip-post landOnStill). One frame decides and gives the colour.
14
+ *
15
+ * planDiveLegs the legs in order (pure)
16
+ * runDiveLegs makes them with generateClip (injectable), post-processes, writes intro.json;
17
+ * { only: 'first' } makes scene 1's dive (and its exit only with exit: true, choice c) and
18
+ * stops at the first pair gate: the dive landed, and with an exit both versions to compare
19
+ *
20
+ * A generated clip is kept as <name>.raw.mp4 beside <name>.raw.json, the fingerprint of what made it
21
+ * (prompt, seed frame, seconds, size, MOVE, provider order). A run after the gate (or after a stop) reuses
22
+ * it only when that fingerprint is the same, so it does not pay for a leg twice; a changed leg (a new MOVE,
23
+ * length or seed) is made again ('stale' event). A raw clip with no record stops with kind 'reuse_unknown'.
24
+ * The clip is written to a temporary name and renamed, so a cut-off write leaves no raw clip.
25
+ * Events go to onEvent; none carries a key.
26
+ * Runtime: Node >=18 builtins and ffmpeg.
27
+ */
28
+ import crypto from 'node:crypto';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { estimateCost, extractFirstFrame, extractLastFrame, probeDims, resolveFfmpeg } from './azure-adapter.mjs';
32
+ import { generateClip, parseProviderOrder } from './video-pool.mjs';
33
+ import { borderColour, cameraMotion, postClip, resizePng, seamSsim, solidPlate } from './clip-post.mjs';
34
+
35
+ export const DIVE_DEFAULTS = { diveSeconds: 8, exitSeconds: 4, finaleSeconds: 8, border: 0.04, bgShare: 0.85, bgTolerance: 12, landSec: 0.6, finaleLandSec: 0.4 };
36
+ export const MOVES = ['push-in', 'arc', 'crane'];
37
+
38
+ function moveOf(scene, i) {
39
+ const m = scene?.move || 'push-in';
40
+ if (!MOVES.includes(m)) throw new Error(`planDiveLegs: scene ${i + 1} MOVE "${m}" is not one of ${MOVES.join(', ')}`);
41
+ return m;
42
+ }
43
+
44
+ /**
45
+ * The legs of a dive-mode intro, in the order they are made. `scenes` are the manifest sections
46
+ * (with an optional `move`); `exit` adds one exit after each dive. Seeds: a dive and the finale start
47
+ * from their scene's still, an exit from its dive's last frame.
48
+ */
49
+ export function planDiveLegs({ scenes, exit = false, diveSeconds = DIVE_DEFAULTS.diveSeconds, exitSeconds = DIVE_DEFAULTS.exitSeconds, finaleSeconds = DIVE_DEFAULTS.finaleSeconds } = {}) {
50
+ const n = Array.isArray(scenes) ? scenes.length : 0;
51
+ if (n < 2) throw new Error('planDiveLegs: dive mode needs two scenes or more (dives, then the finale)');
52
+ const legs = [];
53
+ for (let i = 0; i < n - 1; i++) {
54
+ legs.push({ kind: 'dive', scene: i, name: `dive-${i + 1}`, seed: { from: 'still', scene: i }, move: moveOf(scenes[i], i), seconds: diveSeconds });
55
+ if (exit) legs.push({ kind: 'exit', scene: i, name: `exit-${i + 1}`, seed: { from: 'last-frame', leg: `dive-${i + 1}` }, seconds: exitSeconds });
56
+ }
57
+ legs.push({ kind: 'finale', scene: n - 1, name: 'finale', seed: { from: 'still', scene: n - 1 }, seconds: finaleSeconds, reverse: true });
58
+ return legs;
59
+ }
60
+
61
+ /**
62
+ * Whether a frame has a background border to land a seam on: at least `bgShare` of the band along its
63
+ * edges (`border` of each side) within `bgTolerance` of the band's most common colour. `rgb` is that
64
+ * background colour (the plate's). Returns the measured share either way.
65
+ */
66
+ export async function judgeBackground(png, { border = DIVE_DEFAULTS.border, bgShare = DIVE_DEFAULTS.bgShare, bgTolerance = DIVE_DEFAULTS.bgTolerance, ffmpeg } = {}) {
67
+ const m = await borderColour(png, { border, tolerance: bgTolerance, ffmpeg });
68
+ const share = Math.round(m.share * 1000) / 1000;
69
+ return { on: m.share >= bgShare, share, rgb: m.rgb.map((x) => Math.round(x * 10) / 10), threshold: bgShare, tolerance: bgTolerance };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // runDiveLegs
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Make the dive-mode clips and write intro.json.
78
+ * projectDir the project folder (stills are paths relative to it, as in intro.json)
79
+ * manifest the intro.json to fill: sections (still, copy, optional move and zoom) and the rest
80
+ * size ORIENTATION ('720x1280'); a still of another size is scaled before it seeds a clip
81
+ * promptFor(leg, section) the prompt of each leg (prompts.md: dive, exit, finale)
82
+ * exit make exit clips (connectors); default false (dive without exits is the default mode)
83
+ * only 'first': scene 1's dive, and its exit only when exit is true (no exit is paid for in choice b), then stop
84
+ * generate the clip maker (default generateClip); creds and clipOptions are passed on
85
+ * bgShare, bgTolerance, border, landSec, finaleLandSec, diveSeconds, exitSeconds, finaleSeconds, reuse (default true), ffmpeg, onEvent
86
+ * Returns { stage: 'first-pair', ... } at the gate, else { stage: 'done', manifest, file, seams } (seams: each gap's judgeBackground).
87
+ */
88
+ export async function runDiveLegs(opts = {}) {
89
+ const o = { ...DIVE_DEFAULTS, exit: false, reuse: true, generate: generateClip, clipsDir: 'clips', ...opts };
90
+ if (!o.projectDir || !o.manifest || !o.size || typeof o.promptFor !== 'function') throw new Error('runDiveLegs: projectDir, manifest, size and promptFor are required');
91
+ const ctx = await prepare(o);
92
+ const first = o.only === 'first';
93
+ const legs = planDiveLegs({ scenes: o.manifest.sections, exit: o.exit, diveSeconds: o.diveSeconds, exitSeconds: o.exitSeconds, finaleSeconds: o.finaleSeconds });
94
+ for (const leg of first ? legs.filter((l) => l.scene === 0 && l.kind !== 'finale') : legs) await makeLeg(ctx, leg);
95
+ if (first) return firstPair(ctx);
96
+ await finish(ctx);
97
+ return writeManifest(ctx);
98
+ }
99
+
100
+ async function prepare(o) {
101
+ const emit = (e) => { if (typeof o.onEvent === 'function') o.onEvent(e); };
102
+ const dir = path.join(o.projectDir, o.clipsDir);
103
+ fs.mkdirSync(dir, { recursive: true });
104
+ const bin = o.ffmpeg || await resolveFfmpeg();
105
+ const ctx = { o, emit, dir, bin, n: o.manifest.sections.length, seeds: [], seams: [], raw: {}, shown: {} };
106
+ for (let i = 0; i < ctx.n; i++) ctx.seeds.push(await sizedStill(ctx, i));
107
+ return ctx;
108
+ }
109
+
110
+ const abs = (ctx, rel) => path.join(ctx.o.projectDir, ...String(rel).split(/[\\/]/));
111
+ const relOf = (ctx, file) => path.relative(ctx.o.projectDir, file).split(path.sep).join('/');
112
+ const sizeOf = (size) => String(size).split('x').map(Number);
113
+
114
+ // The scene's still as a seed: as it is when its size is ORIENTATION, else scaled (the classic image path gives 1024x1536).
115
+ async function sizedStill(ctx, i) {
116
+ const still = abs(ctx, ctx.o.manifest.sections[i].still);
117
+ const { width, height } = probeDims(still);
118
+ const [w, h] = sizeOf(ctx.o.size);
119
+ if (width === w && height === h) return still;
120
+ ctx.emit({ type: 'resize', scene: i + 1, from: `${width}x${height}`, to: ctx.o.size });
121
+ return resizePng(still, ctx.o.size, path.join(ctx.dir, `still-${i + 1}.sized.png`), { ffmpeg: ctx.bin });
122
+ }
123
+
124
+ // Judge gap i on `png` (the frame the seam meets) and keep the result; `what` names that frame in the events.
125
+ async function seamPlan(ctx, i, png, what) {
126
+ const j = await judgeBackground(png, { border: ctx.o.border, bgShare: ctx.o.bgShare, bgTolerance: ctx.o.bgTolerance, ffmpeg: ctx.bin });
127
+ ctx.emit({ type: 'seam', gap: i + 1, background: j.on, share: j.share, threshold: j.threshold, frame: what });
128
+ if (!j.on) {
129
+ ctx.emit({ type: 'note', gap: i + 1, message: `${what} has no background border (${j.share} of the band near its most common colour, under ${j.threshold}): this seam is the engine's dissolve, no background plate` });
130
+ }
131
+ ctx.seams[i] = { gap: i + 1, frame: what, ...j };
132
+ return ctx.seams[i];
133
+ }
134
+
135
+ // Generate one leg unless its raw clip is already there (reuse), then keep the version the next leg sees.
136
+ async function makeLeg(ctx, leg) {
137
+ const raw = path.join(ctx.dir, `${leg.name}.raw.mp4`);
138
+ ctx.raw[leg.name] = raw;
139
+ const prompt = ctx.o.promptFor(leg, ctx.o.manifest.sections[leg.scene]);
140
+ const seed = fs.readFileSync(await seedFor(ctx, leg));
141
+ const fp = legPrint(ctx, leg, prompt, seed);
142
+ if (ctx.o.reuse && reusable(ctx, leg, raw, fp)) ctx.emit({ type: 'leg', name: leg.name, kind: leg.kind, reused: true });
143
+ else {
144
+ const r = await ctx.o.generate({ creds: ctx.o.creds, prompt, size: ctx.o.size, seconds: leg.seconds, firstFramePng: seed, onEvent: ctx.emit, ...ctx.o.clipOptions });
145
+ writeAtomic(raw, r.mp4);
146
+ writeAtomic(`${raw.slice(0, -4)}.json`, `${JSON.stringify(fp, null, 2)}\n`);
147
+ ctx.emit({ type: 'leg', name: leg.name, kind: leg.kind, move: leg.move, provider: r.provider, label: r.label, seconds: r.seconds });
148
+ }
149
+ if (leg.kind === 'dive') await keepShown(ctx, leg);
150
+ }
151
+
152
+ const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');
153
+
154
+ // What makes a leg's clip: no key, the prompt and the seed as hashes.
155
+ function legPrint(ctx, leg, prompt, seed) {
156
+ const order = ctx.o.clipOptions?.order || parseProviderOrder(ctx.o.creds?.VIDEO_PROVIDER_ORDER);
157
+ return { prompt: sha(String(prompt)), seed: sha(seed), seconds: leg.seconds, size: ctx.o.size, move: leg.move || null, order };
158
+ }
159
+
160
+ // A raw clip is reused only when its record matches; a changed leg is made again, an unrecorded clip stops.
161
+ function reusable(ctx, leg, raw, fp) {
162
+ if (!fs.existsSync(raw)) return false;
163
+ const rec = `${raw.slice(0, -4)}.json`;
164
+ if (!fs.existsSync(rec)) {
165
+ throw Object.assign(new Error(`runDiveLegs: ${path.basename(raw)} has no record (${path.basename(rec)}) of what made it, so it is not known to fit this leg; delete it to make the leg again, or pass reuse: false`), { kind: 'reuse_unknown', leg: leg.name });
166
+ }
167
+ let was = null;
168
+ try { was = JSON.parse(fs.readFileSync(rec, 'utf8')); } catch { /* unreadable: made again */ }
169
+ const changed = Object.keys(fp).filter((k) => JSON.stringify(was?.[k]) !== JSON.stringify(fp[k]));
170
+ if (changed.length) ctx.emit({ type: 'stale', name: leg.name, changed, message: `${leg.name}: ${changed.join(', ')} changed since the clip was made; making it again` });
171
+ return !changed.length;
172
+ }
173
+
174
+ // Write to a fresh temporary name, then rename: a write cut off leaves no file under the final name.
175
+ function writeAtomic(file, data) {
176
+ const tmp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
177
+ try {
178
+ fs.writeFileSync(tmp, data, { flag: 'wx' });
179
+ fs.renameSync(tmp, file);
180
+ } catch (e) {
181
+ fs.rmSync(tmp, { force: true });
182
+ throw e;
183
+ }
184
+ }
185
+
186
+ // A dive or the finale starts from its still; an exit from its dive's last frame, taken from what
187
+ // shows (the zoomed dive when zoomBoost is on, else the dive as generated), before any landing.
188
+ async function seedFor(ctx, leg) {
189
+ if (leg.seed.from === 'still') return ctx.seeds[leg.scene];
190
+ const png = path.join(ctx.dir, `seed-${leg.name}.png`);
191
+ await extractLastFrame(ctx.shown[leg.seed.leg], png, { ffmpeg: ctx.bin });
192
+ return png;
193
+ }
194
+
195
+ async function keepShown(ctx, leg) {
196
+ const to = ctx.o.manifest.sections[leg.scene].zoom;
197
+ if (!to) { ctx.shown[leg.name] = ctx.raw[leg.name]; return; }
198
+ const out = path.join(ctx.dir, `${leg.name}.zoom.mp4`);
199
+ report(ctx, leg.name, await postClip(ctx.raw[leg.name], out, { zoom: { to } }, { ffmpeg: ctx.bin }));
200
+ ctx.shown[leg.name] = out;
201
+ }
202
+
203
+ function report(ctx, name, r) {
204
+ ctx.emit({ type: 'post', clip: name, fn: r.steps, encoded: r.encoded });
205
+ return r;
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // After every leg: finale, seams, connector posters
210
+ // ---------------------------------------------------------------------------
211
+
212
+ async function finish(ctx) {
213
+ const finale = path.join(ctx.dir, 'finale.mp4');
214
+ report(ctx, 'finale', await postClip(ctx.raw.finale, finale, { reverse: true, land: { png: ctx.seeds[ctx.n - 1], sec: ctx.o.finaleLandSec } }, { ffmpeg: ctx.bin }));
215
+ ctx.finale = finale;
216
+ for (let i = 0; i < ctx.n - 1; i++) await finishGap(ctx, i);
217
+ }
218
+
219
+ // The clip after gap i, as it plays: the next dive, or the finale.
220
+ const nextClip = (ctx, i) => (i + 1 < ctx.n - 1 ? ctx.shown[`dive-${i + 2}`] : ctx.finale);
221
+
222
+ async function finishGap(ctx, i) {
223
+ const land = await plateLanding(ctx, i, nextClip(ctx, i));
224
+ const dive = path.join(ctx.dir, `dive-${i + 1}.mp4`);
225
+ if (!ctx.o.exit) { await placeOrPost(ctx, `dive-${i + 1}`, ctx.shown[`dive-${i + 1}`], dive, land ? { land } : {}); return; }
226
+ fs.copyFileSync(ctx.shown[`dive-${i + 1}`], dive);
227
+ const exit = path.join(ctx.dir, `exit-${i + 1}.mp4`);
228
+ await placeOrPost(ctx, `exit-${i + 1}`, ctx.raw[`exit-${i + 1}`], exit, land ? { land } : {});
229
+ await extractFirstFrame(exit, path.join(ctx.dir, `exit-${i + 1}-first.png`), { ffmpeg: ctx.bin });
230
+ }
231
+
232
+ async function placeOrPost(ctx, name, src, out, steps) {
233
+ if (!Object.keys(steps).length) { fs.copyFileSync(src, out); return; }
234
+ report(ctx, name, await postClip(src, out, steps, { ffmpeg: ctx.bin }));
235
+ }
236
+
237
+ // The next clip's actual first frame decides the seam and gives the plate colour; null when it has no background border.
238
+ async function plateLanding(ctx, i, next) {
239
+ const first = await extractFirstFrame(next, path.join(ctx.dir, `next-${i + 1}-first.png`), { ffmpeg: ctx.bin });
240
+ return plateFrom(ctx, i, first, `the first frame of ${relOf(ctx, next)}`);
241
+ }
242
+
243
+ async function plateFrom(ctx, i, png, what) {
244
+ const j = await seamPlan(ctx, i, png, what);
245
+ if (!j.on) return null;
246
+ const plate = await solidPlate(j.rgb, ctx.o.size, path.join(ctx.dir, `plate-${i + 1}.png`), { ffmpeg: ctx.bin });
247
+ ctx.emit({ type: 'plate', gap: i + 1, rgb: j.rgb.map(Math.round) });
248
+ return { png: plate, sec: ctx.o.landSec };
249
+ }
250
+
251
+ function writeManifest(ctx) {
252
+ const m = ctx.o.manifest;
253
+ const last = ctx.n - 1;
254
+ const finaleFirst = path.join(ctx.dir, 'finale-first.png');
255
+ return extractFirstFrame(ctx.finale, finaleFirst, { ffmpeg: ctx.bin }).then(() => {
256
+ const sections = m.sections.map((s, i) => ({ ...s, clip: relOf(ctx, path.join(ctx.dir, i === last ? 'finale.mp4' : `dive-${i + 1}.mp4`)), ...(i === last ? { still: relOf(ctx, finaleFirst) } : {}) }));
257
+ const gaps = Array.from({ length: last }, (_, i) => i + 1);
258
+ const manifest = { ...m, sections, connectors: gaps.map((g) => (ctx.o.exit ? relOf(ctx, path.join(ctx.dir, `exit-${g}.mp4`)) : null)) };
259
+ if (ctx.o.exit) manifest.connectorStills = gaps.map((g) => relOf(ctx, path.join(ctx.dir, `exit-${g}-first.png`)));
260
+ const file = path.join(ctx.o.projectDir, ctx.o.manifestOut || 'intro.json');
261
+ fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
262
+ return { stage: 'done', manifest, file, seams: ctx.seams };
263
+ });
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // First pair gate: scene 1 without the exit and with it, side by side
268
+ // ---------------------------------------------------------------------------
269
+
270
+ async function firstPair(ctx) {
271
+ const gate = path.join(ctx.dir, 'gate');
272
+ fs.mkdirSync(gate, { recursive: true });
273
+ // the next clip does not exist yet: the next still decides and gives the colour (the next dive starts from that still)
274
+ const land = await plateFrom(ctx, 0, ctx.seeds[1], 'the next scene\'s still');
275
+ const noExit = path.join(gate, 'dive-1.noexit.mp4');
276
+ await placeOrPost(ctx, 'dive-1', ctx.shown['dive-1'], noExit, land ? { land } : {});
277
+ const motion = await cameraMotion(ctx.shown['dive-1'], { ffmpeg: ctx.bin });
278
+ const base = { stage: 'first-pair', seam: ctx.seams[0], withoutExit: [relOf(ctx, noExit)], motion: { rollDeg: motion.rollDeg, zoom: motion.zoom, method: motion.method } };
279
+ // choice b: no exit was made; what turning to c would add (exits between every pair of scenes), to approve first
280
+ if (!ctx.o.exit) return { ...base, withExit: null, switchToExitUsd: switchCost(ctx) };
281
+ const dive = path.join(gate, 'dive-1.mp4');
282
+ fs.copyFileSync(ctx.shown['dive-1'], dive);
283
+ const exit = path.join(gate, 'exit-1.mp4');
284
+ await placeOrPost(ctx, 'exit-1', ctx.raw['exit-1'], exit, land ? { land } : {});
285
+ return { ...base, exitEnd: await exitEnd(ctx), withExit: [relOf(ctx, dive), relOf(ctx, exit)], diveExitSsim: await seamSsim(dive, exit, { ffmpeg: ctx.bin }) };
286
+ }
287
+
288
+ // What exits between every pair of scenes add to the estimate (Sora rate, as Step 2 approved it).
289
+ function switchCost(ctx) {
290
+ const at = (exit) => estimateCost({ nScenes: ctx.n, mode: 'dive', diveSeconds: ctx.o.diveSeconds, exitSeconds: ctx.o.exitSeconds, exit }).videos;
291
+ return Math.round((at(true) - at(false)) * 100) / 100;
292
+ }
293
+
294
+ // Whether the exit as generated reached the background by its end (the same measure), for the gate to show.
295
+ async function exitEnd(ctx) {
296
+ const end = await extractLastFrame(ctx.raw['exit-1'], path.join(ctx.dir, 'gate', 'exit-1-end.png'), { ffmpeg: ctx.bin });
297
+ const j = await judgeBackground(end, { border: ctx.o.border, bgShare: ctx.o.bgShare, bgTolerance: ctx.o.bgTolerance, ffmpeg: ctx.bin });
298
+ return { reached: j.on, share: j.share, threshold: j.threshold };
299
+ }
@@ -50,6 +50,17 @@
50
50
  .sw-route__label { display: none; }
51
51
  }
52
52
 
53
+ /* Before a clip is ready the engine shows the scene's poster with a small zoom and shift of its own
54
+ (an inline transform on the img); the clip itself has none. When the clip appears the picture
55
+ jumps by that zoom (seen on a slow network at the first seams). The poster stays as still as the
56
+ clip's first frame: the inline style loses to !important, the engine file stays as shipped. */
57
+ .sw-scene__still { transform: none !important; }
58
+
59
+ /* The engine hides the poster the moment a clip has seeked, before the clip's frame is on screen: on
60
+ a slow network that left one or two frames with no picture at all. The poster stays under the clip
61
+ for 0.15 s more (the clip is on top from the start, so nothing fades; reduced motion loads no clip). */
62
+ .sw-scene.has-clip .sw-scene__still { transition: opacity 0s linear .15s; }
63
+
53
64
  /* The engine draws its primary buttons in white on the theme's ink colour, which a dark theme
54
65
  (light ink) turns into white on a light pill. The theme's background colour reads on the ink in
55
66
  any theme, as the body text reads on the background. */
@@ -28,12 +28,12 @@ Higgsfield·Monid 같은 외부 CLI 플래그는 쓰지 않는다 — 크기·
28
28
  - `PALETTE` — 4~6개 명명 hex. 예: `taro #9B7EBD, cream #F5EDE0, caramel #C88A5A, matcha #8FB98A, plum #3A2E48`. 하나를 씬 **배경색**(보통 가장 밝은 색), 하나를 기본 **accent** 로 고른다. 이 배경색을 페이지 테마(`--sw-bg`)에도 그대로 쓴다.
29
29
  - `TONE` — 한두 단어(cozy/premium, playful, industrial…).
30
30
  - `STYLE` — 아트디렉션(기본은 아래 clay diorama).
31
- - `CAMERA_FEEL` — fly-through(디오라마를 내려다보다 안으로 급강하) | walkthrough(한 번에 쭉 전진) | locked-iso(고정 아이소메트릭 활공). 느낌으로 제시하고 트레이드오프 한 줄씩 곁들인다.
31
+ - `CAMERA_FEEL` — 기본은 `CLIP_MODE` 를 따른다. dive 면 "장면마다 바깥에서 들어감"(장면별 MOVE: push-in, arc, crane. 8절), chain 이나 holdFlight 면 fly-through(디오라마를 내려다보다 안으로 급강하) | walkthrough(한 번에 쭉 전진) | locked-iso(고정 아이소메트릭 활공). 느낌으로 제시하고 트레이드오프 한 줄씩 곁들인다.
32
32
  - `MOBILE` — yes/no. yes 면 세로 9:16 렌더를 별도로 만들어 `clipMobile`/`stillMobile` 로 배선한다(대략 2배 비용, 승인 게이트에 반영).
33
33
  - `STILLS_SOURCE` — 기본 `gpt-image-2`(`generateImage`). 씬 간 코히전을 더 강하게 원하면 `FLUX.2-pro`(`generateImageFlux`) 옵트인.
34
- - `CLIP_MODE` — `chain`(기본, 검증된 경로) 또는 `holdFlight`(장면마다 정지 클립, 장면 사이 첫과 끝 프레임을 지정한 비행 클립. WAN 필요, 이 스킬에서 미검증).
34
+ - `CLIP_MODE` — 세 가지를 보이고 고르게 한다(SKILL Step 1): a `chain`(4절), b `dive` 나가기 없음(기본, 8절), c `dive` 나가기 있음. provider 와 관계없이 기본은 b 다(sora-2 비교 페이지에서 사용자가 고름). 영상이 WAN 으로 돌면 고를 때 "WAN 에서 dive 는 미측정" 을 알린다. `holdFlight`(장면마다 정지 클립, 장면 사이 첫과 끝 프레임을 지정한 비행 클립. WAN 필요, 이 스킬에서 미검증)는 원할 때만.
35
35
  - `SITE` — 단독 인트로 페이지 또는 기존 사이트 위에 붙임. 붙이면 그 사이트의 CSP, 본문 시작 선택자, 고정 헤더 선택자, 지원 언어를 받는다.
36
- - `STEP_NAV` — yes 면 휠 1칸, 키, 스와이프가 정확히 한 장면을 이동한다. 스크롤 동작을 가로채므로 사이트 성격을 보고 고른다.
36
+ - `STEP_NAV` — 기본 yes. 장면 시작점에 멈추는 장면 단위 이동이다(휠 1칸과 방향키 1번은 절반, 2번이면 한 장면, PageDown 과 Space 는 한 장면). 스크롤 동작을 가로채므로 원하지 않으면 no(매니페스트 `"stepNav": false`).
37
37
  - `PANEL` — `glass` 면 흐린 유리 문구 패널. 기본값은 어두운 장면용이다. 밝은 장면이 많으면 대비 기준(4.5)을 지키도록 패널을 덜 투명하게(`--intro-panel-alpha` .6 이상) 한다.
38
38
 
39
39
  ---
@@ -110,7 +110,7 @@ in, the roof and upper structure gently lift and open away to reveal the warm in
110
110
  여닫을 건물이 없는 씬(들판·광장·도로)이면 지붕 절을 "the camera flies low across [the scene] toward [focal point]." 로 바꾼다.
111
111
 
112
112
  **Leg i (i ≥ 1) — 체이닝된 프레임에서 다음 씬으로:**
113
- `firstFramePng = extractLastFrame(clip[i-1])` (직전 클립의 **raw** 마지막 프레임. 톤 정합을 걸면 연속성이 나빠진다: 실측 raw SSIM ~0.59, histeq 0.46).
113
+ `firstFramePng = extractLastFrame(clip[i-1])` (직전 클립의 **raw** 마지막 프레임. 시드에 톤 정합을 걸면 연속성이 나빠진다: 실측 raw SSIM ~0.59, histeq 0.46. 클립 쪽 톤 맞춤도 하지 않는다: sora-2 실측에서 새 클립 첫 프레임이 시드와 채널 평균 2.6/255 안이었다).
114
114
 
115
115
  ```
116
116
  Single continuous cinematic camera move, no cuts. Continue the same continuous camera flight
@@ -192,3 +192,50 @@ no captions.
192
192
  - 일이 일어나는 자리가 맞아야 한다. 예: 막는 장면은 이미 뚫린 안쪽이 아니라 바깥 경계에서 막는 모습으로.
193
193
  - 화면 속 글자는 깨지기 쉽다. 프롬프트 끝의 "No text, no captions." 를 지키고, 글자가 깨진 결과는 재생성한다.
194
194
  - 사물이 엉뚱한 위치에 뜨거나, 강조색이 유리나 다른 물체로 번지면 그 결과만 재생성한다.
195
+
196
+ ---
197
+
198
+ ## 8. dive 모드 프롬프트 (`CLIP_MODE = dive`, 기본 b 는 나가기 없음)
199
+
200
+ 장면마다 그 장면의 승인 스틸에서 시작한다(`firstFramePng = 장면 i 스틸`). 장면 사이 이음매는 Sora 가 배경까지 물러나는지에 기대지 않는다. 다음 장면 스틸의 테두리가 한 색이면 이음매 쪽 끝 0.6초를 그 색의 판으로 착지시킨다(`landOnStill`, 무료). 실행 순서와 첫 쌍 게이트는 SKILL Step 4C.
201
+ chain 프롬프트(4절)는 바꾸지 않았다. 아래는 dive 모드에만 쓴다.
202
+
203
+ **dive 모드의 스틸(선택, 미검증):** dive 모드에서만 스타일 서문 바로 뒤에 `Generous empty margin of plain background on every side; nothing touches the frame edges.` 를 붙일 수 있다. 가장자리에 닿는 지붕이나 연기를 줄여 배경 이음매가 켜질 몫을 늘리려는 문장이다. 효과는 재지 않았고, chain 과 holdFlight 의 스틸에는 붙이지 않는다(스타일 서문은 모든 장면에 글자 그대로 같아야 한다).
204
+
205
+ **dive (장면 i, 마지막 장면 제외, 8초):**
206
+
207
+ ```
208
+ Aerial wide shot, slight downward angle, 35 mm virtual lens, of the whole [SCENE i subject]
209
+ as a small island on a plain solid [BG_HEX] background. One continuous camera move, no cuts.
210
+ 0-1s: hold the aerial wide shot. 1-7s: [MOVE]. Final second: arrive at a close view of
211
+ [FOCAL POINT]. Only the camera moves. [STYLE tail + PALETTE]. No text, no captions.
212
+ ```
213
+
214
+ | MOVE | 문구 | 쓰는 곳 |
215
+ |---|---|---|
216
+ | `push-in` (기본) | "one steady push-in and descent toward [FOCAL POINT], [the roof and upper structure lift away to reveal the interior / flying low across the scene]" | 대부분의 장면 |
217
+ | `arc` (회전, 순응 낮음) | "a slow arc in around [FOCAL POINT], about a quarter turn, descending as it circles, keeping [FOCAL POINT] centered" | 제품, 랜드마크, 결론 앞 장면. 실측 3개 가운데 눈으로 돈 것은 1개라 첫 쌍 게이트에서 눈으로 확인 |
218
+ | `crane` | "a crane down from high above, descending straight toward [FOCAL POINT] as the scene grows to fill the frame" | 넓은 공간, 광장 |
219
+
220
+ **exit (선택 c 에서만, 장면 i 와 i+1 사이, 4초, dive 의 마지막 프레임에서):**
221
+
222
+ ```
223
+ One continuous camera move, no cuts. The camera pulls straight up and back away from
224
+ [FOCAL POINT], rising out of [SCENE i] until the whole island is small, then keeps receding
225
+ toward the plain solid [BG_HEX] background. Only the camera moves. No text, no captions.
226
+ ```
227
+
228
+ **finale (마지막 장면, 8초, 결론 스틸에서 멀어지게 만든 뒤 뒤집음):**
229
+
230
+ ```
231
+ One continuous camera move, no cuts. Starting from this view of [PRODUCT], the camera pulls
232
+ straight back and slightly up, [PRODUCT] shrinking toward the center of the plain solid
233
+ [BG_HEX] background until it is small. 0-0.5s: hold still. Static: figures hold still,
234
+ no smoke, no water flow, only the camera moves. No text, no captions.
235
+ ```
236
+
237
+ - "figures hold still" 은 finale 에만 둔다. 뒤집으면 사람과 연기의 움직임도 거꾸로 보이기 때문이다.
238
+ - finale 는 `reverseClip` 으로 뒤집고 끝 0.4초를 결론 스틸에 착지시킨다. 그래서 마지막 화면이 결론 스틸 그대로다.
239
+ - arc 의 회전량은 공식 어휘가 "slow arc in" 까지라 결과를 장담하지 못한다. sora-2 실측에서 roll 근사 누적은 3개 모두 10도 아래였고, 눈으로는 1개만 4분의 1 바퀴에 가깝게 돌았다. roll 근사는 궤도 회전을 재지 못하므로 눈 검토(`verify.md`)가 주 판정이다.
240
+ - 나가기는 선택 c 에서만 만든다(기본 b 는 없음). sora-2 실측에서 나가기 6개 가운데 4개가 끝에서 배경에 닿았고, 원본 이음매 SSIM 이 나가기 없음 0.43~0.60 에서 0.59~0.81 로 올랐다.
241
+ - WAN 으로 돌면(`VIDEO_PROVIDER_ORDER` 기본 `wan,sora`) 같은 프롬프트를 쓰되 그 경로는 미검증이다. WAN 은 exit 에 `lastFramePng = 배경 판` 을 줄 수 있으나 설계만 했고 유료로 확인하지 않았다.
@@ -97,6 +97,7 @@ function mountScrollWorld(container, config) {
97
97
  SEGMENTS.push({ kind: 'conn', si: i, clip: CONNECTORS[i], clipM: CONNECTORS_M[i],
98
98
  still: SECTIONS[i + 1].still, stillM: SECTIONS[i + 1].stillMobile,
99
99
  accent: SECTIONS[i + 1].accent, w: CONN_W });
100
+ if ((config.connectorStills || [])[i]) Object.assign(SEGMENTS[SEGMENTS.length - 1], { still: config.connectorStills[i], stillM: null }); // banker: opt-in connector poster, see NOTICE.md
100
101
  }
101
102
  });
102
103
  const NSEG = SEGMENTS.length;
@@ -57,3 +57,32 @@ html.isn-ready .isn-main { display: inline-flex; }
57
57
  /* The last scene keeps the main button alone; the body shows none of these. */
58
58
  html.isn-last .isn-hint, html.isn-last .sw-hint, html.isn-last .isn-skip,
59
59
  html.isn-body .isn-hint, html.isn-body .sw-hint, html.isn-body .isn-skip, html.isn-body .isn-main { display: none !important; }
60
+
61
+ /* At a stop or a half point step-nav.js marks the copy of the scene in view (isn-copy): it shows over the
62
+ engine's own fade, which leaves a middle scene's copy hidden at its start. When a move starts the mark
63
+ goes and the engine's fade takes over; both ways the opacity eases, at once under reduced motion. */
64
+ .isn-on .sw-copy { transition: opacity .35s ease; }
65
+ .isn-on .sw-copy.isn-copy { opacity: 1 !important; pointer-events: auto !important; }
66
+ @media (prefers-reduced-motion: reduce) {
67
+ .isn-on .sw-copy { transition: none; }
68
+ }
69
+ /* An instant move (Home, End, a route dot) swaps the copy at once. */
70
+ .isn-on.isn-instant .sw-copy { transition: none; }
71
+
72
+ /* The loading mark of a poster wait longer than loadingDelayMs (step-nav.js): small, mid-screen, above the
73
+ scenes, taking no input. A status region, so its text reaches a screen reader; it never takes the focus. */
74
+ .isn-loading {
75
+ position: fixed; left: 50%; top: 50%; z-index: 300; transform: translate(-50%, -50%);
76
+ display: none; align-items: center; gap: 10px; padding: 10px 16px; border-radius: 999px;
77
+ background: color-mix(in srgb, var(--sw-bg, #000) 78%, transparent); color: var(--sw-ink, #fff);
78
+ font-size: .82rem; letter-spacing: .04em; pointer-events: none;
79
+ }
80
+ .isn-loading.is-on { display: flex; }
81
+ .isn-loading__spin {
82
+ width: 16px; height: 16px; border-radius: 50%; border: 2px solid currentColor; border-right-color: transparent;
83
+ animation: isn-spin .8s linear infinite;
84
+ }
85
+ @keyframes isn-spin { to { transform: rotate(360deg); } }
86
+ @media (prefers-reduced-motion: reduce) {
87
+ .isn-loading__spin { animation: none; border-right-color: currentColor; opacity: .6; }
88
+ }