@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +68 -0
- package/PRIVACY.md +1 -0
- package/README.md +39 -13
- package/bin/banker.js +118 -22
- package/bin/lib/codex-hooks.mjs +116 -18
- package/bin/lib/codex-names.mjs +4 -0
- package/bin/lib/codex-routes.mjs +93 -1
- package/bin/lib/command-skill.cjs +37 -6
- package/codex/command-skills/front-qa/SKILL.md +1 -1
- package/codex/command-skills/setup/SKILL.md +4 -4
- package/codex/hooks.json +5 -5
- package/codex/manifest.json +18 -15
- package/codex/skills/progress/scripts/progress.mjs +44 -14
- package/codex/skills/teamwork/SKILL.md +143 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +1007 -0
- package/codex/transform-matrix.md +5 -3
- package/hooks/obsidize-hook.mjs +75 -32
- package/hooks/telemetry-count-codex.mjs +238 -57
- package/hooks/update-fetch.mjs +2 -1
- package/hooks/update-notify.mjs +82 -20
- package/package.json +3 -1
- package/skills/3d-intro-build/SKILL.md +101 -20
- package/skills/3d-intro-build/references/NOTICE.md +9 -2
- package/skills/3d-intro-build/references/assemble.mjs +113 -22
- package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-build/references/clip-post.mjs +443 -0
- package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
- package/skills/3d-intro-build/references/intro-fixes.css +11 -0
- package/skills/3d-intro-build/references/prompts.md +51 -4
- package/skills/3d-intro-build/references/scrub-engine.js +1 -0
- package/skills/3d-intro-build/references/step-nav.css +29 -0
- package/skills/3d-intro-build/references/step-nav.js +284 -106
- package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
- package/skills/3d-intro-build/references/verify.md +6 -0
- package/skills/3d-intro-build/references/video-pool.mjs +142 -12
- package/skills/3d-intro-setup/SKILL.md +20 -7
- package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
- package/skills/all-in-one/SKILL.md +125 -86
- package/skills/omc-patch/SKILL.md +1 -1
- package/skills/payload-mon/SKILL.md +1 -1
- package/skills/play-qa/SKILL.md +6 -4
- package/skills/progress-mon/README.md +164 -0
- package/skills/progress-mon/SKILL.md +165 -0
- package/skills/progress-mon/assets/fixture/README.md +1 -0
- package/skills/progress-mon/assets/fixture/arch.json +33 -0
- package/skills/progress-mon/assets/fixture/commits.json +7 -0
- package/skills/progress-mon/assets/fixture/config.json +30 -0
- package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
- package/skills/progress-mon/assets/fixture/flow.json +44 -0
- package/skills/progress-mon/assets/fixture/items.json +14 -0
- package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
- package/skills/progress-mon/assets/fixture/reqs.json +11 -0
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
- package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
- package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
- package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
- package/skills/progress-mon/references/arch.md +280 -0
- package/skills/progress-mon/references/authoring.md +199 -0
- package/skills/progress-mon/references/config.md +115 -0
- package/skills/progress-mon/references/flow.md +75 -0
- package/skills/progress-mon/references/inventory.md +58 -0
- package/skills/progress-mon/references/items.md +74 -0
- package/skills/progress-mon/references/req-spec.md +74 -0
- package/skills/progress-mon/references/reqs.md +30 -0
- package/skills/progress-mon/scripts/index.js +31 -0
- package/skills/progress-mon/scripts/pm-check.mjs +549 -0
- package/skills/progress-mon/scripts/pm-config.mjs +343 -0
- package/skills/progress-mon/scripts/pm-data.mjs +394 -0
- package/skills/progress-mon/scripts/pm-html.mjs +77 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +333 -0
- package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
- package/skills/progress-mon/scripts/pm-server.mjs +781 -0
- package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
- package/skills/ralph-qa/SKILL.md +107 -17
- package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
- package/skills/ralph-qa/references/payload-scan.mjs +19 -2
- package/skills/ralph-qa/references/run-folder.mjs +299 -0
- package/skills/ralph-qa/references/seat-payload.mjs +60 -13
- package/skills/ralph-qa/references/seat-run.mjs +200 -159
- package/skills/remains/SKILL.md +2 -0
- package/skills/remains/scripts/boxes.mjs +6 -2
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
- package/skills/setup-mcp/SKILL.md +1 -1
- package/skills/setup-omc-hud/SKILL.md +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
- package/skills/ultra-chain/SKILL.md +32 -14
- package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
- package/skills/ultra-init/SKILL.md +30 -22
- package/skills/ultraqa/NOTICE.md +34 -0
- package/skills/ultraqa/SKILL.md +197 -0
- package/skills/ultraqa/references/gate-evidence.mjs +457 -0
- package/skills/ultraqa/references/run-folder.mjs +299 -0
- package/skills/ultraqa/references/tmux-drive.md +52 -0
- package/skills/ultraqa/references/uqa-run.mjs +339 -0
- package/skills/update-banker/SKILL.md +1 -1
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Clip post-processing for 3d-intro-build (this skill only; no mirror). FREE: ffmpeg on this machine.
|
|
3
|
+
*
|
|
4
|
+
* ENCODE the one set of encoder arguments every output uses: H.264 yuv420p, a keyframe every
|
|
5
|
+
* 8 frames, faststart, no audio (what the scrub engine seeks well). Each output also
|
|
6
|
+
* carries its source clip's colour tags (colourParams; bt709 when the source has none)
|
|
7
|
+
* postClip one clip's steps (reverse, zoom, landing) as ONE filter graph: one re-encode
|
|
8
|
+
* reverseClip, zoomBoost, landOnStill postClip with one step
|
|
9
|
+
* probeClip, frameStats, solidPlate, resizePng, ssimFrames, seamSsim, cameraMotion, seamReport measures and plates
|
|
10
|
+
*
|
|
11
|
+
* There is no tone step: on the sora-2 clips measured, a new clip's first frame was within 2.6/255 of
|
|
12
|
+
* its seed in every channel mean, and a correction only added a re-encode.
|
|
13
|
+
* cameraMotion is a roll approximation: for each pair of frames `step` s apart it finds the rotation
|
|
14
|
+
* about the centre and the zoom that best map one frame onto the next. Pan is not measured.
|
|
15
|
+
*
|
|
16
|
+
* ffmpeg always gets an argument array; file paths go only to -i and the output, never into a
|
|
17
|
+
* filter graph. Frames for measures are read small, once per clip, and searched in Node.
|
|
18
|
+
* Runtime: Node >=18 builtins; ffmpeg from resolveFfmpeg (azure-adapter.mjs) or opts.ffmpeg.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import cp from 'node:child_process';
|
|
24
|
+
import { probeDims, resolveFfmpeg } from './azure-adapter.mjs';
|
|
25
|
+
|
|
26
|
+
export const ENCODE = ['-c:v', 'libx264', '-crf', '20', '-g', '8', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an'];
|
|
27
|
+
/** reverseClip holds every frame in memory, so it takes at most this long a clip and 1280x720 pixels. */
|
|
28
|
+
export const REVERSE_MAX_SEC = 12;
|
|
29
|
+
const REVERSE_MAX_PIXELS = 1280 * 720;
|
|
30
|
+
/** cameraMotion's search: degrees and zoom per frame pair, then a finer pass around the best. */
|
|
31
|
+
export const MOTION_SEARCH = { degrees: [-8, 8, 1], zooms: [0.9, 1.25, 0.01], fineDegrees: 0.25, fineZoom: 0.0025, shortSide: 128 };
|
|
32
|
+
|
|
33
|
+
const binOf = async (opts) => opts?.ffmpeg || resolveFfmpeg();
|
|
34
|
+
const tail = (s, n = 400) => { const str = String(s || ''); return str.length > n ? str.slice(-n) : str; };
|
|
35
|
+
const n4 = (x) => String(Math.round(x * 10_000) / 10_000);
|
|
36
|
+
|
|
37
|
+
// Run ffmpeg with an argument array. stdout is a Buffer when `raw`.
|
|
38
|
+
function ff(bin, args, raw = false) {
|
|
39
|
+
const r = cp.spawnSync(bin, ['-hide_banner', '-nostdin', ...args], { encoding: raw ? 'buffer' : 'utf8', maxBuffer: 1 << 29, windowsHide: true });
|
|
40
|
+
return { code: r.status, stdout: r.stdout, stderr: String(r.stderr || '') };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function must(r, what) {
|
|
44
|
+
if (r.code !== 0) throw new Error(`${what} failed: ${tail(r.stderr)}`);
|
|
45
|
+
return r;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tmpDir() {
|
|
49
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'clip-post-'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Probes and frames
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A clip's size, frame rate and length from ffmpeg's banner (no ffprobe). The length is the video
|
|
58
|
+
* stream's (its frames counted by decoding; lengthFrom 'video', or 'container' when they could not be counted): the banner's Duration is the container's, which a
|
|
59
|
+
* longer audio track stretches (sora-2 clips: 4.1 s for 4.0 s of video), and a landing timed on it misses.
|
|
60
|
+
*/
|
|
61
|
+
export async function probeClip(mp4, opts = {}) {
|
|
62
|
+
const bin = await binOf(opts);
|
|
63
|
+
const info = parseBanner(ff(bin, ['-i', mp4]).stderr, path.basename(String(mp4))); // no output named: exits 1 after the banner
|
|
64
|
+
// decoded, not stream-copied: ffmpeg 7 prints no frame count for a stream copy
|
|
65
|
+
const frames = [...ff(bin, ['-i', mp4, '-map', '0:v:0', '-f', 'null', '-']).stderr.matchAll(/frame=\s*(\d+)/g)].at(-1);
|
|
66
|
+
if (!frames || !(+frames[1] > 0)) return { ...info, container: info.duration, lengthFrom: 'container' }; // video length unread: said so
|
|
67
|
+
return { ...info, duration: Math.round((+frames[1] / info.fps) * 10_000) / 10_000, container: info.duration, lengthFrom: 'video' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The banner fields probeClip reads, the colour tags included. Throws when the text names no video stream. */
|
|
71
|
+
export function parseBanner(text, name = 'clip') {
|
|
72
|
+
const d = text.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
|
73
|
+
const v = text.match(/Stream #[^\n]*Video:[^\n]*/);
|
|
74
|
+
const size = v && v[0].match(/,\s*(\d{2,5})x(\d{2,5})[\s,[]/);
|
|
75
|
+
const fps = v && (v[0].match(/([\d.]+) fps/) || v[0].match(/([\d.]+) tbr/));
|
|
76
|
+
if (!d || !size || !fps) throw new Error(`probeClip: ${name} is not a video ffmpeg can read`);
|
|
77
|
+
return { width: +size[1], height: +size[2], fps: +fps[1], duration: +d[1] * 3600 + +d[2] * 60 + +d[3], color: colourOf(v[0]) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What an untagged source is taken to be: HD video, limited range, bt709. */
|
|
81
|
+
export const COLOUR_DEFAULT = { range: 'tv', space: 'bt709', primaries: 'bt709', trc: 'bt709' };
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The colour tags of a banner's video line: `yuv420p(tv, bt709, progressive)` names all three at once,
|
|
85
|
+
* `yuv420p(tv, bt470bg/bt709/iec61966-2-1)` space/primaries/transfer. A tag left out or `unknown` is COLOUR_DEFAULT's.
|
|
86
|
+
*/
|
|
87
|
+
export function colourOf(videoLine) {
|
|
88
|
+
const m = /,\s*[a-z0-9]+\(([^)]*)\)/.exec(String(videoLine || ''));
|
|
89
|
+
const c = { ...COLOUR_DEFAULT };
|
|
90
|
+
for (const part of m ? m[1].split(/,\s*/) : []) {
|
|
91
|
+
if (part === 'tv' || part === 'pc') c.range = part;
|
|
92
|
+
else if (/\d/.test(part) && /^[\w.-]+(\/[\w.-]+){0,2}$/.test(part)) Object.assign(c, colourNames(part));
|
|
93
|
+
}
|
|
94
|
+
return c;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// `bt709` (all three) or `space/primaries/transfer`; an `unknown` name is left out.
|
|
98
|
+
function colourNames(part) {
|
|
99
|
+
const [space, primaries = space, trc = space] = part.split('/');
|
|
100
|
+
return Object.fromEntries(Object.entries({ space, primaries, trc }).filter(([, v]) => v !== 'unknown'));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The filter that tags the output frames with the source's colours (the encoder takes the tags from the
|
|
105
|
+
* frames; the -colorspace style output options alone leave primaries and transfer unknown in ffmpeg 8).
|
|
106
|
+
*/
|
|
107
|
+
export const colourParams = (c = COLOUR_DEFAULT) => `setparams=range=${c.range}:color_primaries=${c.primaries}:color_trc=${c.trc}:colorspace=${c.space}`;
|
|
108
|
+
|
|
109
|
+
// Frames as raw bytes (rgb24 or gray), read once.
|
|
110
|
+
function rawFrames(bin, inputArgs, vf, pixFmt) {
|
|
111
|
+
const r = ff(bin, [...inputArgs, '-vf', vf, '-f', 'rawvideo', '-pix_fmt', pixFmt, '-'], true);
|
|
112
|
+
if (r.code !== 0 || !r.stdout?.length) throw new Error(`reading frames failed: ${tail(r.stderr)}`);
|
|
113
|
+
return r.stdout;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A PNG's per-channel mean and standard deviation (0-255), over the whole picture or, with `border`
|
|
118
|
+
* (a fraction such as 0.04), only over the band that wide along each edge.
|
|
119
|
+
*/
|
|
120
|
+
export async function frameStats(png, { border = 0, ffmpeg } = {}) {
|
|
121
|
+
const { width, height } = probeDims(png);
|
|
122
|
+
const buf = rawFrames(await binOf({ ffmpeg }), ['-i', png], 'format=rgb24', 'rgb24');
|
|
123
|
+
return statsOf(buf, width, height, border);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Channel statistics of rgb24 bytes. */
|
|
127
|
+
export function statsOf(buf, width, height, border = 0) {
|
|
128
|
+
const bx = border > 0 ? Math.max(1, Math.round(width * border)) : 0;
|
|
129
|
+
const by = border > 0 ? Math.max(1, Math.round(height * border)) : 0;
|
|
130
|
+
const acc = { sum: [0, 0, 0], sq: [0, 0, 0], n: 0 };
|
|
131
|
+
for (let y = 0; y < height; y++) {
|
|
132
|
+
const fullRow = !bx || y < by || y >= height - by;
|
|
133
|
+
if (fullRow) addSpan(acc, buf, (y * width) * 3, width);
|
|
134
|
+
else { addSpan(acc, buf, (y * width) * 3, bx); addSpan(acc, buf, (y * width + width - bx) * 3, bx); }
|
|
135
|
+
}
|
|
136
|
+
const mean = acc.sum.map((s) => s / acc.n);
|
|
137
|
+
const std = acc.sq.map((s, c) => Math.sqrt(Math.max(0, s / acc.n - mean[c] ** 2)));
|
|
138
|
+
return { mean, std, pixels: acc.n, width, height };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Add `count` rgb24 pixels from byte `start` to the sums.
|
|
142
|
+
function addSpan(acc, buf, start, count) {
|
|
143
|
+
for (let i = start, end = start + count * 3; i < end; i += 3) {
|
|
144
|
+
for (let c = 0; c < 3; c++) { acc.sum[c] += buf[i + c]; acc.sq[c] += buf[i + c] ** 2; }
|
|
145
|
+
}
|
|
146
|
+
acc.n += count;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The most common colour of a picture's border band (`border` of each side) and how much of the band is
|
|
151
|
+
* near it: rgb24 pixels are grouped into colour bins `bin` wide, the fullest bin's mean is the reference,
|
|
152
|
+
* and `share` is the part of the band within `tolerance` (every channel) of it. `rgb` is the mean of those
|
|
153
|
+
* near pixels: the background colour, small objects on the edge left out.
|
|
154
|
+
*/
|
|
155
|
+
export function borderMode(buf, width, height, { border = 0.04, tolerance = 12, bin = 8 } = {}) {
|
|
156
|
+
const px = bandPixels(buf, width, height, border);
|
|
157
|
+
const counts = new Map();
|
|
158
|
+
for (const i of px) { const k = binKey(buf, i, bin); counts.set(k, (counts.get(k) || 0) + 1); }
|
|
159
|
+
const top = [...counts.entries()].reduce((a, b) => (b[1] > a[1] ? b : a))[0];
|
|
160
|
+
const ref = meanOf(buf, px.filter((i) => binKey(buf, i, bin) === top));
|
|
161
|
+
const near = px.filter((i) => [0, 1, 2].every((c) => Math.abs(buf[i + c] - ref[c]) <= tolerance));
|
|
162
|
+
return { share: near.length / px.length, rgb: meanOf(buf, near), pixels: px.length };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Byte offsets of the rgb24 pixels in the border band.
|
|
166
|
+
function bandPixels(buf, width, height, border) {
|
|
167
|
+
const bx = Math.max(1, Math.round(width * border));
|
|
168
|
+
const by = Math.max(1, Math.round(height * border));
|
|
169
|
+
const out = [];
|
|
170
|
+
for (let y = 0; y < height; y++) {
|
|
171
|
+
const full = y < by || y >= height - by;
|
|
172
|
+
for (let x = 0; x < width; x++) if (full || x < bx || x >= width - bx) out.push((y * width + x) * 3);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const binKey = (buf, i, bin) => ((buf[i] / bin) | 0) * 65536 + ((buf[i + 1] / bin) | 0) * 256 + ((buf[i + 2] / bin) | 0);
|
|
178
|
+
const meanOf = (buf, px) => [0, 1, 2].map((c) => px.reduce((s, i) => s + buf[i + c], 0) / Math.max(1, px.length));
|
|
179
|
+
|
|
180
|
+
/** borderMode of a PNG. */
|
|
181
|
+
export async function borderColour(png, { ffmpeg, ...o } = {}) {
|
|
182
|
+
const { width, height } = probeDims(png);
|
|
183
|
+
return borderMode(rawFrames(await binOf({ ffmpeg }), ['-i', png], 'format=rgb24', 'rgb24'), width, height, o);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const sizeOf = (size) => (typeof size === 'string' ? size.split('x').map(Number) : [size.width, size.height]);
|
|
187
|
+
|
|
188
|
+
/** A one-colour PNG of `size` ('720x1280' or {width, height}) from an [r, g, b] colour. Returns out. */
|
|
189
|
+
export async function solidPlate(rgb, size, out, opts = {}) {
|
|
190
|
+
const [w, h] = sizeOf(size);
|
|
191
|
+
const hex = rgb.map((c) => Math.min(255, Math.max(0, Math.round(c))).toString(16).padStart(2, '0')).join('');
|
|
192
|
+
fs.rmSync(out, { force: true });
|
|
193
|
+
must(ff(await binOf(opts), ['-y', '-f', 'lavfi', '-i', `color=c=0x${hex}:s=${w}x${h}`, '-frames:v', '1', out]), 'solidPlate');
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A PNG scaled to `size` ('720x1280' or {width, height}), e.g. a classic-path still before it seeds a clip. Returns out. */
|
|
198
|
+
export async function resizePng(png, size, out, opts = {}) {
|
|
199
|
+
const [w, h] = sizeOf(size);
|
|
200
|
+
fs.rmSync(out, { force: true });
|
|
201
|
+
must(ff(await binOf(opts), ['-y', '-i', png, '-vf', `scale=${w}:${h}:flags=lanczos,setsar=1,format=rgb24`, '-frames:v', '1', out]), 'resizePng');
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A clip frame as RGB the way a browser shows it: the clip's own tags, bt709 when it has none
|
|
206
|
+
// (Chromium reads an untagged clip as bt709; ffmpeg alone would take bt601).
|
|
207
|
+
const asShown = (bin, mp4) => ['-vf', colourParams(colourOf(ff(bin, ['-i', mp4]).stderr.match(/Stream #[^\n]*Video:[^\n]*/)?.[0]))];
|
|
208
|
+
|
|
209
|
+
function firstFramePng(bin, mp4, out) {
|
|
210
|
+
must(ff(bin, ['-y', '-i', mp4, ...asShown(bin, mp4), '-frames:v', '1', out]), 'first frame');
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The last frame: seek near the end and keep overwriting one picture.
|
|
215
|
+
function lastFramePng(bin, mp4, out) {
|
|
216
|
+
must(ff(bin, ['-y', '-sseof', '-1', '-i', mp4, ...asShown(bin, mp4), '-update', '1', out]), 'last frame');
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function ssimPng(bin, aPng, bPng) {
|
|
221
|
+
const { width, height } = probeDims(aPng);
|
|
222
|
+
const graph = `[1:v]scale=${width}:${height},setsar=1,format=yuv420p[b];[0:v]setsar=1,format=yuv420p[a];[a][b]ssim`;
|
|
223
|
+
const r = must(ff(bin, ['-i', aPng, '-i', bPng, '-filter_complex', graph, '-f', 'null', '-']), 'ssim');
|
|
224
|
+
const m = r.stderr.match(/All:\s*([\d.]+)/);
|
|
225
|
+
if (!m) throw new Error(`ssim: no score in ffmpeg's output: ${tail(r.stderr)}`);
|
|
226
|
+
return Number(m[1]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** SSIM (ffmpeg `ssim`, All) of two PNGs; b is scaled to a's size. */
|
|
230
|
+
export async function ssimFrames(aPng, bPng, opts = {}) {
|
|
231
|
+
return ssimPng(await binOf(opts), aPng, bPng);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** SSIM of a seam: clip a's last frame against clip b's first frame. */
|
|
235
|
+
export async function seamSsim(aMp4, bMp4, opts = {}) {
|
|
236
|
+
const bin = await binOf(opts);
|
|
237
|
+
const dir = tmpDir();
|
|
238
|
+
try {
|
|
239
|
+
return ssimPng(bin, lastFramePng(bin, aMp4, path.join(dir, 'a.png')), firstFramePng(bin, bMp4, path.join(dir, 'b.png')));
|
|
240
|
+
} finally {
|
|
241
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// One filter graph per clip
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Encode `clip` to `out` with the steps given, in this order, in one filter graph:
|
|
251
|
+
* reverse: true play backwards (at most REVERSE_MAX_SEC and 1280x720)
|
|
252
|
+
* zoom: { to = 1.15 } digital push-in from 1 to `to` over the clip, about the centre, at the clip's size
|
|
253
|
+
* land: { png, sec = 0.6 } the last `sec` fade onto the PNG (a plate or a still), the length kept;
|
|
254
|
+
* the last frames are the PNG itself
|
|
255
|
+
* The output carries the clip's colour tags. Returns { out, encoded, steps }; with no step, out is a
|
|
256
|
+
* copy of the clip and `encoded` is false.
|
|
257
|
+
*/
|
|
258
|
+
export async function postClip(clip, out, given = {}, opts = {}) {
|
|
259
|
+
const steps = Object.fromEntries(Object.entries(given).filter(([, v]) => v)); // { reverse: false } is no step
|
|
260
|
+
const bin = await binOf(opts);
|
|
261
|
+
const info = await probeClip(clip, { ffmpeg: bin });
|
|
262
|
+
if (steps.reverse) refuseLongReverse(info);
|
|
263
|
+
if (!Object.keys(steps).length) {
|
|
264
|
+
fs.copyFileSync(clip, out); // nothing to do: the clip as it was, not one more encode
|
|
265
|
+
return { out, encoded: false, steps: [] };
|
|
266
|
+
}
|
|
267
|
+
encode(bin, clip, out, info, steps);
|
|
268
|
+
return { out, encoded: true, steps: Object.keys(steps) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function refuseLongReverse(info) {
|
|
272
|
+
if (info.duration > REVERSE_MAX_SEC + 0.05 || info.width * info.height > REVERSE_MAX_PIXELS) {
|
|
273
|
+
throw new Error(`reverseClip: ${info.duration} s at ${info.width}x${info.height} is past the limit (${REVERSE_MAX_SEC} s, 1280x720); reverse holds every frame in memory`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function encode(bin, clip, out, info, steps) {
|
|
278
|
+
const inputs = ['-i', clip];
|
|
279
|
+
if (steps.land) inputs.push('-loop', '1', '-t', n4(landSec(steps.land)), '-framerate', String(info.fps), '-i', steps.land.png);
|
|
280
|
+
fs.rmSync(out, { force: true });
|
|
281
|
+
must(ff(bin, ['-y', ...inputs, '-filter_complex', filterGraph(info, steps), '-map', '[out]', ...ENCODE, out]), 'postClip');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const landSec = (land) => Number(land.sec ?? 0.6);
|
|
285
|
+
|
|
286
|
+
/** The filter graph postClip runs (exported for tests). */
|
|
287
|
+
export function filterGraph(info, steps) {
|
|
288
|
+
const segs = [`[0:v]setsar=1,fps=${info.fps}${steps.reverse ? ',reverse' : ''}[s0]`];
|
|
289
|
+
let at = 's0';
|
|
290
|
+
if (steps.zoom) { segs.push(`[${at}]${zoomFilter(info, Number(steps.zoom.to ?? 1.15))}[sz]`); at = 'sz'; }
|
|
291
|
+
if (steps.land) { segs.push(...landSegs(at, info, landSec(steps.land))); at = 'sl'; }
|
|
292
|
+
segs.push(`[${at}]format=yuv420p,${colourParams(info.color)}[out]`);
|
|
293
|
+
return segs.join(';');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// A centred push-in at the clip's own size: each frame's centre 1/z of the picture is drawn to the
|
|
297
|
+
// corners (perspective, evaluated per frame). scale+crop would change the frame size mid-stream, and
|
|
298
|
+
// crop keeps its first offsets then, which pushes in towards the top-left corner instead.
|
|
299
|
+
function zoomFilter(info, to) {
|
|
300
|
+
const k = n4((to - 1) / Math.max(0.04, info.duration));
|
|
301
|
+
const m = `(1-1/(1+${k}*in/${info.fps}))/2`;
|
|
302
|
+
const lo = (side) => `'${side}*${m}'`;
|
|
303
|
+
const hi = (side) => `'${side}*(1-${m})'`;
|
|
304
|
+
return `perspective=x0=${lo('W')}:y0=${lo('H')}:x1=${hi('W')}:y1=${lo('H')}:x2=${lo('W')}:y2=${hi('H')}:x3=${hi('W')}:y3=${hi('H')}:eval=frame`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The PNG as a short clip at the clip's size and rate, faded in so that its last two frames are the PNG alone.
|
|
308
|
+
function landSegs(at, info, sec) {
|
|
309
|
+
const fade = Math.max(1 / info.fps, sec - 2 / info.fps);
|
|
310
|
+
const offset = Math.max(0, info.duration - sec);
|
|
311
|
+
return [
|
|
312
|
+
`[1:v]scale=${info.width}:${info.height},setsar=1,fps=${info.fps},format=yuv420p,settb=AVTB[pl]`,
|
|
313
|
+
`[${at}]format=yuv420p,settb=AVTB[pv]`,
|
|
314
|
+
`[pv][pl]xfade=transition=fade:duration=${n4(fade)}:offset=${n4(offset)}[sl]`,
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Play a clip backwards (at most REVERSE_MAX_SEC, 1280x720). */
|
|
319
|
+
export const reverseClip = (clip, out, opts = {}) => postClip(clip, out, { reverse: true }, opts);
|
|
320
|
+
/** Digital push-in from 1 to `to` (off unless asked for). */
|
|
321
|
+
export const zoomBoost = (clip, out, { to = 1.15, ...opts } = {}) => postClip(clip, out, { zoom: { to } }, opts);
|
|
322
|
+
/** Land the clip's last `sec` on a PNG (a background plate, or the closing still), the length kept. */
|
|
323
|
+
export const landOnStill = (clip, png, out, { sec = 0.6, ...opts } = {}) => postClip(clip, out, { land: { png, sec } }, opts);
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Camera motion (roll approximation and zoom)
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
const rangeOf = (lo, hi, stepBy) => Array.from({ length: Math.round((hi - lo) / stepBy) + 1 }, (_, i) => lo + i * stepBy);
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The camera's roll (degrees, clockwise positive) and zoom (>1 is moving in) over a clip, summed
|
|
333
|
+
* and multiplied over frame pairs `step` seconds apart. Frames are read once, grey, MOTION_SEARCH.shortSide
|
|
334
|
+
* pixels on the short side. Pan is not measured; a pan reads as a lower pair SSIM.
|
|
335
|
+
*/
|
|
336
|
+
export async function cameraMotion(mp4, { step = 1, ffmpeg } = {}) {
|
|
337
|
+
const bin = await binOf({ ffmpeg });
|
|
338
|
+
const info = await probeClip(mp4, { ffmpeg: bin });
|
|
339
|
+
const k = MOTION_SEARCH.shortSide / Math.min(info.width, info.height);
|
|
340
|
+
const w = 2 * Math.round((info.width * k) / 2);
|
|
341
|
+
const h = 2 * Math.round((info.height * k) / 2);
|
|
342
|
+
const buf = rawFrames(bin, ['-i', mp4], `fps=1/${step},scale=${w}:${h}:flags=area,format=gray`, 'gray');
|
|
343
|
+
const frames = Array.from({ length: Math.floor(buf.length / (w * h)) }, (_, i) => buf.subarray(i * w * h, (i + 1) * w * h));
|
|
344
|
+
const pairs = frames.slice(1).map((b, i) => ({ t: i * step, ...bestFit(frames[i], b, w, h) }));
|
|
345
|
+
return {
|
|
346
|
+
method: 'roll approximation: per frame pair, the rotation about the centre and the zoom that best map one frame onto the next; pan is not measured',
|
|
347
|
+
rollDeg: pairs.reduce((s, p) => s + p.deg, 0), zoom: pairs.reduce((s, p) => s * p.zoom, 1),
|
|
348
|
+
pairs, size: `${w}x${h}`, step,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function bestFit(a, b, w, h) {
|
|
353
|
+
const S = MOTION_SEARCH;
|
|
354
|
+
const coarse = search(a, b, w, h, rangeOf(...S.degrees), rangeOf(...S.zooms));
|
|
355
|
+
const degs = rangeOf(coarse.deg - S.degrees[2], coarse.deg + S.degrees[2], S.fineDegrees);
|
|
356
|
+
const zooms = rangeOf(coarse.zoom - S.zooms[2], coarse.zoom + S.zooms[2], S.fineZoom);
|
|
357
|
+
const fine = search(a, b, w, h, degs, zooms);
|
|
358
|
+
return { deg: Math.round(fine.deg * 100) / 100, zoom: Math.round(fine.zoom * 10_000) / 10_000, ssim: Math.round(fine.ssim * 10_000) / 10_000 };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function search(a, b, w, h, degs, zooms) {
|
|
362
|
+
let best = { deg: 0, zoom: 1, ssim: -Infinity };
|
|
363
|
+
for (const deg of degs) {
|
|
364
|
+
for (const zoom of zooms) {
|
|
365
|
+
const s = fitSsim(a, b, w, h, deg, zoom);
|
|
366
|
+
if (s > best.ssim) best = { deg, zoom, ssim: s };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return best;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// SSIM (one window) of frame b's centre against frame a rotated by `deg` and zoomed by `zoom`.
|
|
373
|
+
function fitSsim(a, b, w, h, deg, zoom) {
|
|
374
|
+
const half = Math.floor(0.3 * Math.min(w, h));
|
|
375
|
+
const cx = (w - 1) / 2;
|
|
376
|
+
const cy = (h - 1) / 2;
|
|
377
|
+
const t = (-deg * Math.PI) / 180;
|
|
378
|
+
const cos = Math.cos(t) / zoom;
|
|
379
|
+
const sin = Math.sin(t) / zoom;
|
|
380
|
+
const acc = { sa: 0, sb: 0, saa: 0, sbb: 0, sab: 0, n: 0 };
|
|
381
|
+
for (let dy = -half; dy <= half; dy++) {
|
|
382
|
+
for (let dx = -half; dx <= half; dx++) {
|
|
383
|
+
const va = bilinear(a, w, h, cx + dx * cos - dy * sin, cy + dx * sin + dy * cos);
|
|
384
|
+
const vb = b[Math.round(cy + dy) * w + Math.round(cx + dx)];
|
|
385
|
+
acc.sa += va; acc.sb += vb; acc.saa += va * va; acc.sbb += vb * vb; acc.sab += va * vb; acc.n++;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return ssimOf(acc);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function bilinear(img, w, h, x, y) {
|
|
392
|
+
const x0 = Math.min(w - 2, Math.max(0, Math.floor(x)));
|
|
393
|
+
const y0 = Math.min(h - 2, Math.max(0, Math.floor(y)));
|
|
394
|
+
const fx = Math.min(1, Math.max(0, x - x0));
|
|
395
|
+
const fy = Math.min(1, Math.max(0, y - y0));
|
|
396
|
+
const i = y0 * w + x0;
|
|
397
|
+
const top = img[i] * (1 - fx) + img[i + 1] * fx;
|
|
398
|
+
const bottom = img[i + w] * (1 - fx) + img[i + w + 1] * fx;
|
|
399
|
+
return top * (1 - fy) + bottom * fy;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function ssimOf({ sa, sb, saa, sbb, sab, n }) {
|
|
403
|
+
const C1 = (0.01 * 255) ** 2;
|
|
404
|
+
const C2 = (0.03 * 255) ** 2;
|
|
405
|
+
const ma = sa / n;
|
|
406
|
+
const mb = sb / n;
|
|
407
|
+
const va = saa / n - ma * ma;
|
|
408
|
+
const vb = sbb / n - mb * mb;
|
|
409
|
+
const cov = sab / n - ma * mb;
|
|
410
|
+
return ((2 * ma * mb + C1) * (2 * cov + C2)) / ((ma * ma + mb * mb + C1) * (va + vb + C2));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Seam report
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
// The clips of intro.json in playing order: section 1, connector 1 (when set), section 2, ...
|
|
418
|
+
function clipOrder(manifest) {
|
|
419
|
+
const order = [];
|
|
420
|
+
(manifest.sections || []).forEach((s, i) => {
|
|
421
|
+
if (i > 0 && manifest.connectors?.[i - 1]) order.push({ role: 'connector', index: i - 1, file: manifest.connectors[i - 1] });
|
|
422
|
+
order.push({ role: 'section', index: i, file: s.clip });
|
|
423
|
+
});
|
|
424
|
+
return order;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Seam SSIM between each clip and the next in intro.json's playing order, and each clip's
|
|
429
|
+
* cameraMotion (rollDeg, zoom). Paths in the report are the manifest's (relative to projectDir).
|
|
430
|
+
*/
|
|
431
|
+
export async function seamReport(projectDir, opts = {}) {
|
|
432
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(projectDir, opts.manifest || 'intro.json'), 'utf8'));
|
|
433
|
+
const order = clipOrder(manifest);
|
|
434
|
+
const at = (f) => path.join(projectDir, ...String(f).split(/[\\/]/));
|
|
435
|
+
const clips = [];
|
|
436
|
+
for (const c of order) {
|
|
437
|
+
const m = await cameraMotion(at(c.file), opts);
|
|
438
|
+
clips.push({ ...c, rollDeg: m.rollDeg, zoom: m.zoom });
|
|
439
|
+
}
|
|
440
|
+
const seams = [];
|
|
441
|
+
for (let i = 1; i < order.length; i++) seams.push({ from: order[i - 1].file, to: order[i].file, ssim: await seamSsim(at(order[i - 1].file), at(order[i].file), opts) });
|
|
442
|
+
return { seams, clips };
|
|
443
|
+
}
|