0xray 4.0.12 → 4.0.14
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/.grok-plugin/plugin.json +1 -1
- package/.xray/features.json +1 -1
- package/.xray/features.schema.json +1 -1
- package/AGENTS-consumer.md +4 -2
- package/AGENTS.md +8 -1
- package/README.md +1 -1
- package/SKILLS.md +1 -1
- package/dist/AGENTS.md +8 -1
- package/dist/CHANGELOG.md +25 -1
- package/dist/README.md +1 -1
- package/dist/SKILLS.md +1 -1
- package/dist/integrations/cursor/README.md +61 -0
- package/dist/integrations/cursor/hooks/after-file-edit.js +38 -0
- package/dist/integrations/cursor/hooks/cursor-hook-utils.js +170 -0
- package/dist/integrations/cursor/hooks/cursor-usage-receipt.js +298 -0
- package/dist/integrations/cursor/hooks/hooks.json +20 -0
- package/dist/integrations/cursor/hooks/pre-compact.js +88 -0
- package/dist/integrations/cursor/hooks/pre-tool-use.js +105 -0
- package/dist/integrations/hooks/station-hook-runtime.cjs +89 -1
- package/dist/integrations/hooks/station-hook-runtime.mjs +2 -0
- package/dist/integrations/openclaw/plugin/xray-pre-tool/package.json +1 -1
- package/dist/nucleus/suit-temperament.d.ts +2 -2
- package/dist/nucleus/suit-temperament.js +3 -1
- package/llms.txt +6 -4
- package/package.json +4 -2
- package/scripts/foundry/CHANGELOG.md +37 -1
- package/scripts/foundry/README.md +13 -1
- package/scripts/foundry/assert-packed-dist-cli.mjs +97 -0
- package/scripts/foundry/blip-render.cjs +1237 -0
- package/scripts/foundry/blip-rippel.cjs +1963 -0
- package/scripts/foundry/blip.mjs +109 -0
- package/scripts/foundry/cli.mjs +8 -4
- package/scripts/foundry/inspect.mjs +117 -7
- package/scripts/foundry/mint-suit.cjs +215 -14
- package/scripts/foundry/package.json +7 -1
- package/scripts/foundry/plant/agents/blip-inspect.yml +5 -0
- package/scripts/foundry/plant/agents/blip.yml +5 -0
- package/scripts/foundry/plant/agents/inspect.yml +1 -1
- package/scripts/foundry/plant/agents/mill.yml +1 -1
- package/scripts/foundry/plant/agents/sound-inspect.yml +5 -0
- package/scripts/foundry/plant/agents/sound.yml +5 -0
- package/scripts/foundry/plant/motions/MOTION-REGISTRY.md +35 -0
- package/scripts/foundry/plant/motions/registry.json +81 -0
- package/scripts/foundry/plant/skills/blip/SKILL.md +39 -0
- package/scripts/foundry/plant/skills/blip-inspect/SKILL.md +32 -0
- package/scripts/foundry/plant/skills/inspect/SKILL.md +2 -2
- package/scripts/foundry/plant/skills/sound/SKILL.md +29 -0
- package/scripts/foundry/plant/skills/sound-inspect/SKILL.md +28 -0
- package/scripts/foundry/release-gate.mjs +18 -10
- package/scripts/foundry/release.mjs +3 -0
- package/scripts/foundry/sound-bed.cjs +454 -0
- package/scripts/foundry/sound-rippel.cjs +1434 -0
- package/scripts/foundry/sound.mjs +93 -0
- package/scripts/node/assert-packed-dist-cli.mjs +4 -0
- package/scripts/node/consumer-install-smoke.mjs +20 -1
- package/src/integrations/openclaw/plugin/xray-pre-tool/package.json +1 -1
- package/xray/features.json +1 -1
- package/xray/features.schema.json +1 -1
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory-blip plant spine — sibling to mill + sound, not a mill bolt-on.
|
|
3
|
+
* Brief → checksum seed → registry picture mode → 4.44s mp4 + audio bed → inspect gate.
|
|
4
|
+
*
|
|
5
|
+
* Rippel v2 (TICKET-BLIP-RENDERER-UPGRADE): VisualConfig.circles at ≥720p.
|
|
6
|
+
* Look variants: focus (solid disc + satellites) | cage (Wu hairline + field).
|
|
7
|
+
* Sharp focus + tempo/frequency animation on all five viz.
|
|
8
|
+
* Audio syncopates to the motion grid — same seed, tempo, and phase0=0.
|
|
9
|
+
* Power Plant (`still` id) is a living ident — plate dissolves, not a frozen poster.
|
|
10
|
+
* Seed mesh (family + gait + shells + faces) is the NFT fingerprint on every mint.
|
|
11
|
+
* ffmpeg wireframe is --engine wireframe only.
|
|
12
|
+
* HARD: every Blip muxes a 4.44s stereo AAC bed — missing stream or inaudible = inspect FAIL.
|
|
13
|
+
*
|
|
14
|
+
* Motions live in plant/motions/registry.json (dynamic). v0 = still + Rippel five.
|
|
15
|
+
* Kapow is a growth stub (renderer null → FAIL). Unknown id FAIL.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const crypto = require("crypto");
|
|
19
|
+
const { spawnSync } = require("child_process");
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const os = require("os");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const rippel = require("./blip-rippel.cjs");
|
|
24
|
+
const sound = require("./sound-bed.cjs");
|
|
25
|
+
|
|
26
|
+
const SPINE = 1;
|
|
27
|
+
const DURATION_SEC = 4.44;
|
|
28
|
+
const DURATION_TOL_SEC = 0.12;
|
|
29
|
+
/** RIPPEL-ANIM-TYPES.md Codex line (~30fps). */
|
|
30
|
+
const FPS = 30;
|
|
31
|
+
/** Logical Power Plant plate. Output ident is ≥720p (same as the Rippel five). */
|
|
32
|
+
const PLATE_WIDTH = 320;
|
|
33
|
+
const PLATE_HEIGHT = 180;
|
|
34
|
+
const STILL_WIDTH = PLATE_WIDTH;
|
|
35
|
+
const STILL_HEIGHT = PLATE_HEIGHT;
|
|
36
|
+
const WIDTH = PLATE_WIDTH;
|
|
37
|
+
const HEIGHT = PLATE_HEIGHT;
|
|
38
|
+
const POWER_PLANT_ENGINE = "power-plant-headless";
|
|
39
|
+
const POWER_PLANT_LOOK = "power-plant-blip";
|
|
40
|
+
const MOTION_WIDTH = rippel.MOTION_WIDTH;
|
|
41
|
+
const MOTION_HEIGHT = rippel.MOTION_HEIGHT;
|
|
42
|
+
const RECEIPT_REL = path.join(".xray", "blip", "receipt.json");
|
|
43
|
+
const MP4_REL = path.join(".xray", "blip", "blip.mp4");
|
|
44
|
+
const REGISTRY_REL = path.join("plant", "motions", "registry.json");
|
|
45
|
+
/** Digital silence / near-silence. Real beds peak near 0 dBFS. */
|
|
46
|
+
const AUDIBLE_MAX_DB_MIN = -40;
|
|
47
|
+
|
|
48
|
+
const RIPPEL_IMPORTS = ["orb", "swirl", "snap", "waves", "spark"];
|
|
49
|
+
const V0_IDS = ["still", ...RIPPEL_IMPORTS];
|
|
50
|
+
const STILL_PLATES = ["titlecard", "corridor", "rain", "endcard"];
|
|
51
|
+
|
|
52
|
+
/** Power Plant intro plate — HARD design SSOT. Dissolves between plates, flat vector. */
|
|
53
|
+
const PALETTE = {
|
|
54
|
+
void: "#08090B",
|
|
55
|
+
ink: "#F5F7FA",
|
|
56
|
+
cyan: "#3DE0E8",
|
|
57
|
+
gold: "#F5C518",
|
|
58
|
+
blue: "#4A7FD4",
|
|
59
|
+
};
|
|
60
|
+
const PLATE = "power-plant-intro";
|
|
61
|
+
|
|
62
|
+
function hexRgb(hex) {
|
|
63
|
+
const h = String(hex || "").replace(/^#/, "");
|
|
64
|
+
return [
|
|
65
|
+
Number.parseInt(h.slice(0, 2), 16),
|
|
66
|
+
Number.parseInt(h.slice(2, 4), 16),
|
|
67
|
+
Number.parseInt(h.slice(4, 6), 16),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const RGB = {
|
|
72
|
+
void: hexRgb(PALETTE.void),
|
|
73
|
+
ink: hexRgb(PALETTE.ink),
|
|
74
|
+
cyan: hexRgb(PALETTE.cyan),
|
|
75
|
+
gold: hexRgb(PALETTE.gold),
|
|
76
|
+
blue: hexRgb(PALETTE.blue),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const SSOT = rippel.SSOT;
|
|
80
|
+
const ANIMATION_TO_VISUALIZATION = rippel.ANIMATION_TO_VISUALIZATION;
|
|
81
|
+
|
|
82
|
+
function defaultRegistryPath() {
|
|
83
|
+
return path.join(__dirname, REGISTRY_REL);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function loadRegistry(registryPath) {
|
|
87
|
+
const file = registryPath || defaultRegistryPath();
|
|
88
|
+
if (!fs.existsSync(file)) {
|
|
89
|
+
const err = new Error(`motion registry missing: ${file}`);
|
|
90
|
+
err.code = "BLIP_REGISTRY_MISSING";
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
94
|
+
const motions = Array.isArray(parsed?.motions) ? parsed.motions : [];
|
|
95
|
+
const byId = new Map();
|
|
96
|
+
for (const entry of motions) {
|
|
97
|
+
if (entry && typeof entry.id === "string" && entry.id.trim()) {
|
|
98
|
+
byId.set(entry.id.trim(), entry);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { file, raw: parsed, motions, byId };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function listMotionIds(registryPath) {
|
|
105
|
+
return loadRegistry(registryPath).motions.map((entry) => entry.id).filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function listV0Ids(registryPath) {
|
|
109
|
+
const loaded = loadRegistry(registryPath);
|
|
110
|
+
const v0 = Array.isArray(loaded.raw?.v0) ? loaded.raw.v0 : V0_IDS;
|
|
111
|
+
return v0.filter((id) => typeof id === "string");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function canonicalJson(value) {
|
|
115
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
116
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
117
|
+
const keys = Object.keys(value).sort();
|
|
118
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parsePictureMode(raw) {
|
|
122
|
+
const text = String(raw || "still")
|
|
123
|
+
.trim()
|
|
124
|
+
.toLowerCase();
|
|
125
|
+
if (!text) return { pictureMode: "still", motionId: "still" };
|
|
126
|
+
if (text === "still" || text === "motion:still") {
|
|
127
|
+
return { pictureMode: "still", motionId: "still" };
|
|
128
|
+
}
|
|
129
|
+
if (text.startsWith("motion:")) {
|
|
130
|
+
const motionId = text.slice("motion:".length).trim();
|
|
131
|
+
return { pictureMode: motionId === "still" ? "still" : `motion:${motionId}`, motionId };
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
pictureMode: `motion:${text}`,
|
|
135
|
+
motionId: text,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveMode(name, registryPath) {
|
|
140
|
+
const parsed = parsePictureMode(name);
|
|
141
|
+
const motionId = parsed.motionId;
|
|
142
|
+
if (!motionId) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
id: "",
|
|
146
|
+
pictureMode: parsed.pictureMode,
|
|
147
|
+
motionId: "",
|
|
148
|
+
reason: "unknown motion id",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
let loaded;
|
|
152
|
+
try {
|
|
153
|
+
loaded = loadRegistry(registryPath);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
id: motionId,
|
|
158
|
+
pictureMode: parsed.pictureMode,
|
|
159
|
+
motionId,
|
|
160
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const entry = loaded.byId.get(motionId);
|
|
164
|
+
if (!entry) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
id: motionId,
|
|
168
|
+
pictureMode: parsed.pictureMode,
|
|
169
|
+
motionId,
|
|
170
|
+
reason: `unknown motion id "${motionId}"`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (!entry.renderer) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
id: motionId,
|
|
177
|
+
pictureMode: parsed.pictureMode,
|
|
178
|
+
motionId,
|
|
179
|
+
entry,
|
|
180
|
+
visualization: entry.visualization || null,
|
|
181
|
+
reason: `motion "${motionId}" is registered but has no renderer (growth/stub)`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
id: motionId,
|
|
187
|
+
pictureMode: parsed.pictureMode,
|
|
188
|
+
motionId,
|
|
189
|
+
renderer: entry.renderer,
|
|
190
|
+
visualization: entry.visualization || null,
|
|
191
|
+
source: entry.source || null,
|
|
192
|
+
entry,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function seedFromBrief(brief, mode) {
|
|
197
|
+
const parsed = parsePictureMode(mode);
|
|
198
|
+
const payload = canonicalJson({
|
|
199
|
+
brief: String(brief || "").trim(),
|
|
200
|
+
motionId: parsed.motionId,
|
|
201
|
+
durationSec: DURATION_SEC,
|
|
202
|
+
spine: SPINE,
|
|
203
|
+
});
|
|
204
|
+
return `0x${crypto.createHash("sha256").update(payload, "utf8").digest("hex")}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function seedRgb(seedHex) {
|
|
208
|
+
const hex = String(seedHex || "").replace(/^0x/, "");
|
|
209
|
+
const r = Number.parseInt(hex.slice(0, 2), 16);
|
|
210
|
+
const g = Number.parseInt(hex.slice(2, 4), 16);
|
|
211
|
+
const b = Number.parseInt(hex.slice(4, 6), 16);
|
|
212
|
+
return {
|
|
213
|
+
r: Number.isFinite(r) ? r : 0,
|
|
214
|
+
g: Number.isFinite(g) ? g : 0,
|
|
215
|
+
b: Number.isFinite(b) ? b : 0,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function seedU32(seedHex, offset) {
|
|
220
|
+
const hex = String(seedHex || "").replace(/^0x/, "");
|
|
221
|
+
const n = Number.parseInt(hex.slice(offset, offset + 8), 16);
|
|
222
|
+
return Number.isFinite(n) ? n >>> 0 : 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function clampByte(n) {
|
|
226
|
+
return Math.max(0, Math.min(255, Math.round(n)));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function writePpm(file, width, height, paint) {
|
|
230
|
+
const header = Buffer.from(`P6\n${width} ${height}\n255\n`);
|
|
231
|
+
const pixels = Buffer.alloc(width * height * 3);
|
|
232
|
+
for (let y = 0; y < height; y++) {
|
|
233
|
+
for (let x = 0; x < width; x++) {
|
|
234
|
+
const rgb = paint(x, y);
|
|
235
|
+
const i = (y * width + x) * 3;
|
|
236
|
+
pixels[i] = clampByte(rgb[0]);
|
|
237
|
+
pixels[i + 1] = clampByte(rgb[1]);
|
|
238
|
+
pixels[i + 2] = clampByte(rgb[2]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
242
|
+
fs.writeFileSync(file, Buffer.concat([header, pixels]));
|
|
243
|
+
return file;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function phase(t) {
|
|
247
|
+
return DURATION_SEC > 0 ? t / DURATION_SEC : 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function inBox(x, y, x0, y0, x1, y1) {
|
|
251
|
+
return x >= x0 && x < x1 && y >= y0 && y < y1;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function onEdge(x, y, x0, y0, x1, y1) {
|
|
255
|
+
return inBox(x, y, x0, y0, x1, y1) && (x === x0 || x === x1 - 1 || y === y0 || y === y1 - 1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function stillPlate(seedHex, t) {
|
|
259
|
+
return plateClock(seedHex, t).plate;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function plateClock(seedHex, t) {
|
|
263
|
+
const start = seedU32(seedHex, 0) % STILL_PLATES.length;
|
|
264
|
+
const grid = sound.motionGrid(seedHex, "ambient");
|
|
265
|
+
const beats = Math.max(STILL_PLATES.length, Math.floor(DURATION_SEC / grid.beatSec + 1e-9));
|
|
266
|
+
const every = Math.max(1, Math.floor(beats / STILL_PLATES.length));
|
|
267
|
+
const n = t == null || t <= 0 ? 0 : t >= DURATION_SEC ? DURATION_SEC - 1e-6 : t;
|
|
268
|
+
const beatIndex = Math.floor(n / grid.beatSec);
|
|
269
|
+
const step = Math.min(STILL_PLATES.length - 1, Math.floor(beatIndex / every));
|
|
270
|
+
const span = every * grid.beatSec;
|
|
271
|
+
const cutAt = step * span;
|
|
272
|
+
const into = n - cutAt;
|
|
273
|
+
const xfade = Math.min(0.32, Math.max(0.22, span * 0.28));
|
|
274
|
+
let prevMix = 0;
|
|
275
|
+
let prev = null;
|
|
276
|
+
if (step > 0 && into < xfade) {
|
|
277
|
+
prev = STILL_PLATES[(start + step - 1 + STILL_PLATES.length) % STILL_PLATES.length];
|
|
278
|
+
const u = into / xfade;
|
|
279
|
+
prevMix = 1 - u * u * (3 - 2 * u);
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
plate: STILL_PLATES[(start + step) % STILL_PLATES.length],
|
|
283
|
+
prev,
|
|
284
|
+
prevMix,
|
|
285
|
+
u: span > 0 ? (n - cutAt) / span : 0,
|
|
286
|
+
start,
|
|
287
|
+
step,
|
|
288
|
+
xfade,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Shared Power Plant chrome on every plate — gold/cyan ticks, ink label, blue node. */
|
|
293
|
+
function paintChrome(x, y) {
|
|
294
|
+
if (y >= 6 && y < 8 && x >= 136 && x < 168) return RGB.gold;
|
|
295
|
+
if (y >= 6 && y < 8 && x >= 168 && x < 184) return RGB.cyan;
|
|
296
|
+
if (inBox(x, y, 24, 164, 120, 172)) return RGB.ink;
|
|
297
|
+
if (inBox(x, y, 292, 164, 308, 176)) return RGB.blue;
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Power Plant ident — titlecard / corridor / rain / endcard over 4.44s.
|
|
303
|
+
* Seed picks the opening plate, then the other three dissolve in (~140ms).
|
|
304
|
+
* Flat vector, five hexes. u=0 of the opening plate matches the old lockup.
|
|
305
|
+
*/
|
|
306
|
+
function paintPlate(plate, u) {
|
|
307
|
+
const slide = Math.floor(u * 28);
|
|
308
|
+
return function paint(x, y) {
|
|
309
|
+
if (plate === "titlecard") {
|
|
310
|
+
const cards = [
|
|
311
|
+
[12 - slide, 36, 84 - slide, 132],
|
|
312
|
+
[88 - slide, 36, 160 - slide, 132],
|
|
313
|
+
[164 - slide, 36, 236 - slide, 132],
|
|
314
|
+
[240 - slide, 36, 312 - slide, 132],
|
|
315
|
+
];
|
|
316
|
+
for (let i = 0; i < cards.length; i++) {
|
|
317
|
+
const [x0, y0, x1, y1] = cards[i];
|
|
318
|
+
if (onEdge(x, y, x0, y0, x1, y1)) return i % 2 === 0 ? RGB.cyan : RGB.gold;
|
|
319
|
+
if (inBox(x, y, x0 + 1, y0 + 1, x1 - 1, y1 - 1)) {
|
|
320
|
+
if (i === 1 && inBox(x, y, 118 - slide, 64, 130 - slide, 120)) return RGB.blue;
|
|
321
|
+
if (i === 2 && (x + y) % 8 === 0) return RGB.cyan;
|
|
322
|
+
return RGB.void;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return RGB.void;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (plate === "corridor") {
|
|
329
|
+
const slit = 156 + slide;
|
|
330
|
+
if (x >= slit && x < slit + 8) return RGB.cyan;
|
|
331
|
+
if (inBox(x, y, 148, 72 - Math.floor(u * 16), 172, 148 - Math.floor(u * 16))) return RGB.blue;
|
|
332
|
+
return RGB.void;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (plate === "rain") {
|
|
336
|
+
const fall = Math.floor(u * 36);
|
|
337
|
+
if (x > 188 && y > 28 && y < 150 && (x + 3 * (y + fall)) % 9 === 0) return RGB.cyan;
|
|
338
|
+
if (inBox(x, y, 208 + Math.floor(u * 12), 52, 268 + Math.floor(u * 12), 124)) return RGB.blue;
|
|
339
|
+
return RGB.void;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (inBox(x, y, 208 - slide, 64, 300 - slide, 96)) return RGB.ink;
|
|
343
|
+
if (inBox(x, y, 208 - slide, 104, 248 - slide, 116)) return RGB.gold;
|
|
344
|
+
return RGB.void;
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function mixPlateRgb(a, b, amount) {
|
|
349
|
+
const w = Math.max(0, Math.min(1, amount));
|
|
350
|
+
return [
|
|
351
|
+
(a[0] + (b[0] - a[0]) * w + 0.5) | 0,
|
|
352
|
+
(a[1] + (b[1] - a[1]) * w + 0.5) | 0,
|
|
353
|
+
(a[2] + (b[2] - a[2]) * w + 0.5) | 0,
|
|
354
|
+
];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function paintStill(seedHex, t) {
|
|
358
|
+
const clock = plateClock(seedHex, t || 0);
|
|
359
|
+
const current = paintPlate(clock.plate, clock.u);
|
|
360
|
+
const incoming = clock.prevMix > 0.01 && clock.prev ? paintPlate(clock.prev, 1) : null;
|
|
361
|
+
return function paint(x, y) {
|
|
362
|
+
const chrome = paintChrome(x, y);
|
|
363
|
+
if (chrome) return chrome;
|
|
364
|
+
const now = current(x, y);
|
|
365
|
+
if (!incoming) return now;
|
|
366
|
+
return mixPlateRgb(now, incoming(x, y), clock.prevMix);
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function paintStillFrame(buf, width, height, seedHex, t, brief) {
|
|
371
|
+
const paint = paintStill(seedHex, t);
|
|
372
|
+
for (let y = 0; y < height; y++) {
|
|
373
|
+
const ly = Math.min(PLATE_HEIGHT - 1, ((y * PLATE_HEIGHT) / height) | 0);
|
|
374
|
+
for (let x = 0; x < width; x++) {
|
|
375
|
+
const lx = Math.min(PLATE_WIDTH - 1, ((x * PLATE_WIDTH) / width) | 0);
|
|
376
|
+
const rgb = paint(lx, ly);
|
|
377
|
+
const i = (y * width + x) * 3;
|
|
378
|
+
buf[i] = clampByte(rgb[0]);
|
|
379
|
+
buf[i + 1] = clampByte(rgb[1]);
|
|
380
|
+
buf[i + 2] = clampByte(rgb[2]);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (width >= 1280) {
|
|
384
|
+
rippel.paintMeshOverlay(buf, width, height, { seedHex, brief, t });
|
|
385
|
+
}
|
|
386
|
+
return buf;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function sampleStillFrames(seedHex) {
|
|
390
|
+
const a = Buffer.alloc(PLATE_WIDTH * PLATE_HEIGHT * 3);
|
|
391
|
+
const b = Buffer.alloc(PLATE_WIDTH * PLATE_HEIGHT * 3);
|
|
392
|
+
paintStillFrame(a, PLATE_WIDTH, PLATE_HEIGHT, seedHex, 0);
|
|
393
|
+
paintStillFrame(b, PLATE_WIDTH, PLATE_HEIGHT, seedHex, DURATION_SEC * 0.5);
|
|
394
|
+
return { differ: rippel.framesDiffer(a, b), width: PLATE_WIDTH, height: PLATE_HEIGHT };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Wireframe fallback — old ffmpeg geometry. Kept if Rippel headless throws. */
|
|
398
|
+
function paintOrb(t, width, height) {
|
|
399
|
+
const w = width || WIDTH;
|
|
400
|
+
const h = height || HEIGHT;
|
|
401
|
+
const cx = (w - 1) / 2;
|
|
402
|
+
const cy = (h - 1) / 2;
|
|
403
|
+
const pulse = Math.sin(2 * Math.PI * phase(t)) > 0 ? 4 : 0;
|
|
404
|
+
const scale = Math.min(w, h) / HEIGHT;
|
|
405
|
+
return function paint(x, y) {
|
|
406
|
+
const d = Math.hypot(x - cx, y - cy);
|
|
407
|
+
if (d < 18 * scale) return RGB.gold;
|
|
408
|
+
if (d < (36 + pulse) * scale) return RGB.cyan;
|
|
409
|
+
if (d < (40 + pulse) * scale) return RGB.ink;
|
|
410
|
+
return RGB.void;
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function paintSwirl(t, width, height) {
|
|
415
|
+
const w = width || WIDTH;
|
|
416
|
+
const h = height || HEIGHT;
|
|
417
|
+
const cx = (w - 1) / 2;
|
|
418
|
+
const cy = (h - 1) / 2;
|
|
419
|
+
const spin = 2 * Math.PI * phase(t);
|
|
420
|
+
return function paint(x, y) {
|
|
421
|
+
const dx = x - cx;
|
|
422
|
+
const dy = y - cy;
|
|
423
|
+
const d = Math.hypot(dx, dy);
|
|
424
|
+
if (d > Math.min(w, h) / 2) return RGB.void;
|
|
425
|
+
const ang = Math.atan2(dy, dx) + spin + d / 18;
|
|
426
|
+
const arm = Math.abs(Math.sin(ang * 3));
|
|
427
|
+
if (arm < 0.55) return RGB.void;
|
|
428
|
+
const turned = ((ang % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
|
429
|
+
const sector = Math.floor(turned / ((Math.PI * 2) / 3)) % 3;
|
|
430
|
+
if (sector === 0) return RGB.cyan;
|
|
431
|
+
if (sector === 1) return RGB.gold;
|
|
432
|
+
return RGB.blue;
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function paintSnap(t, width, height) {
|
|
437
|
+
const w = width || WIDTH;
|
|
438
|
+
const h = height || HEIGHT;
|
|
439
|
+
const on = Math.sin(2 * Math.PI * phase(t)) > 0;
|
|
440
|
+
const gx = Math.max(8, Math.round(40 * (w / WIDTH)));
|
|
441
|
+
const gy = Math.max(8, Math.round(30 * (h / HEIGHT)));
|
|
442
|
+
return function paint(x, y) {
|
|
443
|
+
const nearX = x % gx < 2 || x % gx > gx - 2;
|
|
444
|
+
const nearY = y % gy < 2 || y % gy > gy - 2;
|
|
445
|
+
const node = x % gx < 4 && y % gy < 4;
|
|
446
|
+
if (node) return on ? RGB.gold : RGB.cyan;
|
|
447
|
+
if (nearX || nearY) return RGB.blue;
|
|
448
|
+
return RGB.void;
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function paintWaves(t, width, height) {
|
|
453
|
+
const w = width || WIDTH;
|
|
454
|
+
const h = height || HEIGHT;
|
|
455
|
+
const mid = (h - 1) / 2;
|
|
456
|
+
const shift = phase(t) * w;
|
|
457
|
+
return function paint(x, y) {
|
|
458
|
+
const wave = mid + Math.sin((x + shift) / 12) * (h / 5);
|
|
459
|
+
const d = Math.abs(y - wave);
|
|
460
|
+
if (d < 2) return RGB.gold;
|
|
461
|
+
if (d < 5) return RGB.cyan;
|
|
462
|
+
if (y > wave) return RGB.blue;
|
|
463
|
+
return RGB.void;
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function paintSpark(t, seedHex, width, height) {
|
|
468
|
+
const w = width || WIDTH;
|
|
469
|
+
const h = height || HEIGHT;
|
|
470
|
+
const dots = [];
|
|
471
|
+
let s = seedU32(seedHex, 8);
|
|
472
|
+
const colors = [RGB.cyan, RGB.gold, RGB.ink];
|
|
473
|
+
for (let i = 0; i < 28; i++) {
|
|
474
|
+
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
475
|
+
dots.push({
|
|
476
|
+
x: s % w,
|
|
477
|
+
y: (s >>> 9) % h,
|
|
478
|
+
c: colors[i % 3],
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
const drift = phase(t) * 40;
|
|
482
|
+
return function paint(x, y) {
|
|
483
|
+
for (const dot of dots) {
|
|
484
|
+
const dx = x - ((dot.x + drift) % w);
|
|
485
|
+
const dy = y - dot.y;
|
|
486
|
+
if (dx * dx + dy * dy < 9) return dot.c;
|
|
487
|
+
}
|
|
488
|
+
return RGB.void;
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function painterFor(renderer, t, seedHex, width, height) {
|
|
493
|
+
if (renderer === "still") return paintStill(seedHex, t);
|
|
494
|
+
if (renderer === "orb") return paintOrb(t, width, height);
|
|
495
|
+
if (renderer === "swirl") return paintSwirl(t, width, height);
|
|
496
|
+
if (renderer === "snap") return paintSnap(t, width, height);
|
|
497
|
+
if (renderer === "waves") return paintWaves(t, width, height);
|
|
498
|
+
if (renderer === "spark") return paintSpark(t, seedHex, width, height);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function paintWireframeFrame(buf, width, height, renderer, t, seedHex) {
|
|
503
|
+
const paint = painterFor(renderer, t, seedHex, width, height);
|
|
504
|
+
if (!paint) {
|
|
505
|
+
const err = new Error(`no factory renderer for "${renderer}"`);
|
|
506
|
+
err.code = "BLIP_WIREFRAME_MISS";
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
509
|
+
for (let y = 0; y < height; y++) {
|
|
510
|
+
for (let x = 0; x < width; x++) {
|
|
511
|
+
const rgb = paint(x, y);
|
|
512
|
+
const i = (y * width + x) * 3;
|
|
513
|
+
buf[i] = clampByte(rgb[0]);
|
|
514
|
+
buf[i + 1] = clampByte(rgb[1]);
|
|
515
|
+
buf[i + 2] = clampByte(rgb[2]);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return buf;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function hasFfmpeg() {
|
|
522
|
+
const result = spawnSync("ffmpeg", ["-version"], { encoding: "utf8" });
|
|
523
|
+
return !result.error && result.status === 0;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function runTool(bin, args, label) {
|
|
527
|
+
const result = spawnSync(bin, args, { encoding: "utf8" });
|
|
528
|
+
if (result.error) {
|
|
529
|
+
const err = new Error(`${label} missing (${bin})`);
|
|
530
|
+
err.code = "BLIP_FFMPEG_MISSING";
|
|
531
|
+
throw err;
|
|
532
|
+
}
|
|
533
|
+
if (result.status !== 0) {
|
|
534
|
+
const err = new Error(`${label} failed: ${(result.stderr || result.stdout || "").trim() || "exit"}`);
|
|
535
|
+
err.code = "BLIP_FFMPEG_FAIL";
|
|
536
|
+
throw err;
|
|
537
|
+
}
|
|
538
|
+
return result;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function encodeStill(ppm, mp4) {
|
|
542
|
+
runTool(
|
|
543
|
+
"ffmpeg",
|
|
544
|
+
[
|
|
545
|
+
"-y",
|
|
546
|
+
"-loop",
|
|
547
|
+
"1",
|
|
548
|
+
"-i",
|
|
549
|
+
ppm,
|
|
550
|
+
"-t",
|
|
551
|
+
String(DURATION_SEC),
|
|
552
|
+
"-r",
|
|
553
|
+
String(FPS),
|
|
554
|
+
"-pix_fmt",
|
|
555
|
+
"yuv420p",
|
|
556
|
+
"-c:v",
|
|
557
|
+
"libx264",
|
|
558
|
+
"-preset",
|
|
559
|
+
"ultrafast",
|
|
560
|
+
"-crf",
|
|
561
|
+
"28",
|
|
562
|
+
"-an",
|
|
563
|
+
"-movflags",
|
|
564
|
+
"+faststart",
|
|
565
|
+
mp4,
|
|
566
|
+
],
|
|
567
|
+
"ffmpeg still",
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function encodeFrames(framesDir, frameCount, mp4) {
|
|
572
|
+
runTool(
|
|
573
|
+
"ffmpeg",
|
|
574
|
+
[
|
|
575
|
+
"-y",
|
|
576
|
+
"-framerate",
|
|
577
|
+
String(FPS),
|
|
578
|
+
"-i",
|
|
579
|
+
path.join(framesDir, "frame_%04d.ppm"),
|
|
580
|
+
"-frames:v",
|
|
581
|
+
String(frameCount),
|
|
582
|
+
"-t",
|
|
583
|
+
String(DURATION_SEC),
|
|
584
|
+
"-pix_fmt",
|
|
585
|
+
"yuv420p",
|
|
586
|
+
"-c:v",
|
|
587
|
+
"libx264",
|
|
588
|
+
"-preset",
|
|
589
|
+
"ultrafast",
|
|
590
|
+
"-crf",
|
|
591
|
+
"28",
|
|
592
|
+
"-an",
|
|
593
|
+
"-movflags",
|
|
594
|
+
"+faststart",
|
|
595
|
+
mp4,
|
|
596
|
+
],
|
|
597
|
+
"ffmpeg motion",
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function encodeRaw(rawFile, width, height, frameCount, mp4) {
|
|
602
|
+
runTool(
|
|
603
|
+
"ffmpeg",
|
|
604
|
+
[
|
|
605
|
+
"-y",
|
|
606
|
+
"-f",
|
|
607
|
+
"rawvideo",
|
|
608
|
+
"-pix_fmt",
|
|
609
|
+
"rgb24",
|
|
610
|
+
"-s",
|
|
611
|
+
`${width}x${height}`,
|
|
612
|
+
"-r",
|
|
613
|
+
String(FPS),
|
|
614
|
+
"-i",
|
|
615
|
+
rawFile,
|
|
616
|
+
"-frames:v",
|
|
617
|
+
String(frameCount),
|
|
618
|
+
"-t",
|
|
619
|
+
String(DURATION_SEC),
|
|
620
|
+
"-pix_fmt",
|
|
621
|
+
"yuv420p",
|
|
622
|
+
"-c:v",
|
|
623
|
+
"libx264",
|
|
624
|
+
"-preset",
|
|
625
|
+
"ultrafast",
|
|
626
|
+
"-crf",
|
|
627
|
+
"23",
|
|
628
|
+
"-an",
|
|
629
|
+
"-movflags",
|
|
630
|
+
"+faststart",
|
|
631
|
+
mp4,
|
|
632
|
+
],
|
|
633
|
+
"ffmpeg motion",
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function writeRawMotion(rawFile, width, height, frameCount, paintInto) {
|
|
638
|
+
const frameSize = width * height * 3;
|
|
639
|
+
const buf = Buffer.alloc(frameSize);
|
|
640
|
+
const fd = fs.openSync(rawFile, "w");
|
|
641
|
+
try {
|
|
642
|
+
for (let i = 0; i < frameCount; i++) {
|
|
643
|
+
paintInto(buf, i / FPS);
|
|
644
|
+
fs.writeSync(fd, buf);
|
|
645
|
+
}
|
|
646
|
+
} finally {
|
|
647
|
+
fs.closeSync(fd);
|
|
648
|
+
}
|
|
649
|
+
return rawFile;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function muxBed(video, bed, mp4) {
|
|
653
|
+
runTool(
|
|
654
|
+
"ffmpeg",
|
|
655
|
+
[
|
|
656
|
+
"-y",
|
|
657
|
+
"-i",
|
|
658
|
+
video,
|
|
659
|
+
"-i",
|
|
660
|
+
bed,
|
|
661
|
+
"-c:v",
|
|
662
|
+
"copy",
|
|
663
|
+
"-af",
|
|
664
|
+
"apad,aformat=channel_layouts=stereo",
|
|
665
|
+
"-ar",
|
|
666
|
+
"44100",
|
|
667
|
+
"-c:a",
|
|
668
|
+
"aac",
|
|
669
|
+
"-b:a",
|
|
670
|
+
"192k",
|
|
671
|
+
"-t",
|
|
672
|
+
String(DURATION_SEC),
|
|
673
|
+
"-movflags",
|
|
674
|
+
"+faststart",
|
|
675
|
+
mp4,
|
|
676
|
+
],
|
|
677
|
+
"ffmpeg mux",
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function probeAudio(file) {
|
|
682
|
+
const chRun = spawnSync(
|
|
683
|
+
"ffprobe",
|
|
684
|
+
[
|
|
685
|
+
"-v",
|
|
686
|
+
"error",
|
|
687
|
+
"-select_streams",
|
|
688
|
+
"a:0",
|
|
689
|
+
"-show_entries",
|
|
690
|
+
"stream=channels",
|
|
691
|
+
"-of",
|
|
692
|
+
"default=nw=1:nk=1",
|
|
693
|
+
file,
|
|
694
|
+
],
|
|
695
|
+
{ encoding: "utf8" },
|
|
696
|
+
);
|
|
697
|
+
const channels = Number.parseInt(String(chRun.stdout || "").trim(), 10);
|
|
698
|
+
const levelRun = spawnSync(
|
|
699
|
+
"ffmpeg",
|
|
700
|
+
["-hide_banner", "-nostats", "-i", file, "-af", "volumedetect", "-f", "null", "-"],
|
|
701
|
+
{ encoding: "utf8" },
|
|
702
|
+
);
|
|
703
|
+
const text = `${levelRun.stdout || ""}\n${levelRun.stderr || ""}`;
|
|
704
|
+
function db(name) {
|
|
705
|
+
const matches = [...text.matchAll(new RegExp(`${name}:\\s*([-infINF+\\d.]+)`, "gi"))];
|
|
706
|
+
const last = matches[matches.length - 1];
|
|
707
|
+
if (!last) return null;
|
|
708
|
+
const value = Number(last[1]);
|
|
709
|
+
return Number.isFinite(value) || value === Number.NEGATIVE_INFINITY ? value : null;
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
channels: Number.isFinite(channels) ? channels : null,
|
|
713
|
+
meanVolumeDb: db("mean_volume"),
|
|
714
|
+
maxVolumeDb: db("max_volume"),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function probeMedia(file) {
|
|
719
|
+
if (!file || !fs.existsSync(file)) {
|
|
720
|
+
return {
|
|
721
|
+
ok: false,
|
|
722
|
+
reason: "mp4 missing",
|
|
723
|
+
hasVideo: false,
|
|
724
|
+
hasAudio: false,
|
|
725
|
+
durationSec: null,
|
|
726
|
+
width: null,
|
|
727
|
+
height: null,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
const durationRun = spawnSync(
|
|
731
|
+
"ffprobe",
|
|
732
|
+
["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", file],
|
|
733
|
+
{ encoding: "utf8" },
|
|
734
|
+
);
|
|
735
|
+
if (durationRun.error) {
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
reason: "ffprobe missing",
|
|
739
|
+
hasVideo: false,
|
|
740
|
+
hasAudio: false,
|
|
741
|
+
durationSec: null,
|
|
742
|
+
width: null,
|
|
743
|
+
height: null,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
if (durationRun.status !== 0) {
|
|
747
|
+
return {
|
|
748
|
+
ok: false,
|
|
749
|
+
reason: "mp4 unreadable",
|
|
750
|
+
hasVideo: false,
|
|
751
|
+
hasAudio: false,
|
|
752
|
+
durationSec: null,
|
|
753
|
+
width: null,
|
|
754
|
+
height: null,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
const durationSec = Number.parseFloat(String(durationRun.stdout || "").trim());
|
|
758
|
+
const streamsRun = spawnSync(
|
|
759
|
+
"ffprobe",
|
|
760
|
+
["-v", "error", "-show_entries", "stream=codec_type,width,height", "-of", "csv=p=0", file],
|
|
761
|
+
{ encoding: "utf8" },
|
|
762
|
+
);
|
|
763
|
+
const lines = String(streamsRun.stdout || "")
|
|
764
|
+
.split(/\n/)
|
|
765
|
+
.map((line) => line.trim())
|
|
766
|
+
.filter(Boolean);
|
|
767
|
+
let hasVideo = false;
|
|
768
|
+
let hasAudio = false;
|
|
769
|
+
let width = null;
|
|
770
|
+
let height = null;
|
|
771
|
+
for (const line of lines) {
|
|
772
|
+
const parts = line.split(",").map((part) => part.trim());
|
|
773
|
+
if (parts[0] === "video") {
|
|
774
|
+
hasVideo = true;
|
|
775
|
+
const w = Number.parseInt(parts[1], 10);
|
|
776
|
+
const h = Number.parseInt(parts[2], 10);
|
|
777
|
+
if (Number.isFinite(w)) width = w;
|
|
778
|
+
if (Number.isFinite(h)) height = h;
|
|
779
|
+
} else if (parts[0] === "audio") {
|
|
780
|
+
hasAudio = true;
|
|
781
|
+
} else if (parts.includes("audio")) {
|
|
782
|
+
hasAudio = true;
|
|
783
|
+
} else if (parts.includes("video")) {
|
|
784
|
+
hasVideo = true;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
ok: Number.isFinite(durationSec) && hasVideo,
|
|
789
|
+
durationSec: Number.isFinite(durationSec) ? durationSec : null,
|
|
790
|
+
hasVideo,
|
|
791
|
+
hasAudio,
|
|
792
|
+
width,
|
|
793
|
+
height,
|
|
794
|
+
reason: !hasVideo ? "video stream missing" : null,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function evaluateProbe(probe, opts = {}) {
|
|
799
|
+
const wantAudio = opts.wantAudio !== false;
|
|
800
|
+
const motion = Boolean(opts.motion);
|
|
801
|
+
const durationSec = probe.durationSec;
|
|
802
|
+
const durationOk =
|
|
803
|
+
Number.isFinite(durationSec) && Math.abs(durationSec - DURATION_SEC) <= DURATION_TOL_SEC;
|
|
804
|
+
const fileOk = Boolean(probe.ok && probe.hasVideo);
|
|
805
|
+
const audio = opts.audio || {};
|
|
806
|
+
const maxVolumeDb = audio.maxVolumeDb;
|
|
807
|
+
const channels = audio.channels;
|
|
808
|
+
const audibleOk = Number.isFinite(maxVolumeDb) && maxVolumeDb > AUDIBLE_MAX_DB_MIN;
|
|
809
|
+
const stereoOk = channels === 2;
|
|
810
|
+
const audioOk = !wantAudio || (Boolean(probe.hasAudio) && audibleOk && stereoOk);
|
|
811
|
+
const width = probe.width;
|
|
812
|
+
const height = probe.height;
|
|
813
|
+
const resolutionOk =
|
|
814
|
+
!motion || (Number.isFinite(width) && Number.isFinite(height) && width >= 1280 && height >= 720);
|
|
815
|
+
const gates = {
|
|
816
|
+
file: fileOk,
|
|
817
|
+
duration: durationOk,
|
|
818
|
+
video: Boolean(probe.hasVideo),
|
|
819
|
+
audio: audioOk,
|
|
820
|
+
mode: Boolean(opts.mode),
|
|
821
|
+
resolution: resolutionOk,
|
|
822
|
+
};
|
|
823
|
+
let reason = null;
|
|
824
|
+
if (!fileOk) reason = probe.reason || "mp4 missing";
|
|
825
|
+
else if (!gates.mode) reason = "mode missing";
|
|
826
|
+
else if (!durationOk) reason = `duration ${durationSec}s not ${DURATION_SEC}s±${DURATION_TOL_SEC}`;
|
|
827
|
+
else if (wantAudio && !probe.hasAudio) reason = "audio stream missing";
|
|
828
|
+
else if (wantAudio && !audibleOk) reason = "inaudible bed";
|
|
829
|
+
else if (wantAudio && !stereoOk) reason = "audio not stereo";
|
|
830
|
+
else if (!resolutionOk) reason = `motion ${width}×${height} below 720p`;
|
|
831
|
+
const status = Object.values(gates).every(Boolean) ? "PASS" : "FAIL";
|
|
832
|
+
return {
|
|
833
|
+
status,
|
|
834
|
+
failClosed: true,
|
|
835
|
+
gates,
|
|
836
|
+
durationSec,
|
|
837
|
+
hasVideo: Boolean(probe.hasVideo),
|
|
838
|
+
hasAudio: Boolean(probe.hasAudio),
|
|
839
|
+
audioChannels: channels ?? null,
|
|
840
|
+
meanVolumeDb: audio.meanVolumeDb ?? null,
|
|
841
|
+
maxVolumeDb: maxVolumeDb ?? null,
|
|
842
|
+
width: width ?? null,
|
|
843
|
+
height: height ?? null,
|
|
844
|
+
reason,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function evaluateMp4File(file, opts = {}) {
|
|
849
|
+
const probe = probeMedia(file);
|
|
850
|
+
const audio = probe.hasAudio
|
|
851
|
+
? probeAudio(file)
|
|
852
|
+
: { channels: null, meanVolumeDb: null, maxVolumeDb: null };
|
|
853
|
+
return evaluateProbe(probe, { ...opts, audio });
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function receiptPath(root) {
|
|
857
|
+
return path.join(root, RECEIPT_REL);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function defaultMp4Path(root) {
|
|
861
|
+
return path.join(root, MP4_REL);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function writeReceipt(root, receipt) {
|
|
865
|
+
const file = receiptPath(root);
|
|
866
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
867
|
+
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
868
|
+
return file;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function readReceipt(root) {
|
|
872
|
+
const file = receiptPath(root);
|
|
873
|
+
if (!fs.existsSync(file)) return null;
|
|
874
|
+
try {
|
|
875
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
876
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
877
|
+
} catch {
|
|
878
|
+
return { status: "FAIL", failClosed: true, reason: "receipt-unreadable" };
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function buildReceipt(input, evaled) {
|
|
883
|
+
const status = evaled.status === "PASS" ? "PASS" : "FAIL";
|
|
884
|
+
return {
|
|
885
|
+
kind: "blip",
|
|
886
|
+
plant: "blip",
|
|
887
|
+
status,
|
|
888
|
+
failClosed: true,
|
|
889
|
+
brief: input.brief,
|
|
890
|
+
seed: input.seed,
|
|
891
|
+
pictureMode: input.pictureMode,
|
|
892
|
+
motionId: input.motionId,
|
|
893
|
+
mode: input.motionId,
|
|
894
|
+
visualization: input.visualization || null,
|
|
895
|
+
palette: PALETTE,
|
|
896
|
+
plate: PLATE,
|
|
897
|
+
stillPlate: input.stillPlate || null,
|
|
898
|
+
durationSec: DURATION_SEC,
|
|
899
|
+
measuredDurationSec: evaled.durationSec ?? null,
|
|
900
|
+
width: evaled.width ?? input.width ?? null,
|
|
901
|
+
height: evaled.height ?? input.height ?? null,
|
|
902
|
+
engine: input.engine || rippel.ENGINE,
|
|
903
|
+
look: input.look || (input.engine === rippel.ENGINE ? rippel.LOOK : null),
|
|
904
|
+
lookKind: input.lookKind || (input.visualConfig && input.visualConfig.lookKind) || null,
|
|
905
|
+
fallback: input.fallback || false,
|
|
906
|
+
bedSource: input.bedSource || null,
|
|
907
|
+
visualConfig: input.visualConfig || null,
|
|
908
|
+
grid: input.grid || null,
|
|
909
|
+
ssot: SSOT,
|
|
910
|
+
registry: input.registryIds || listMotionIds(),
|
|
911
|
+
mp4: input.mp4Rel || input.mp4,
|
|
912
|
+
bed: input.bedRel || input.bed || null,
|
|
913
|
+
hasVideo: Boolean(evaled.hasVideo),
|
|
914
|
+
hasAudio: Boolean(evaled.hasAudio),
|
|
915
|
+
audioChannels: evaled.audioChannels ?? null,
|
|
916
|
+
meanVolumeDb: evaled.meanVolumeDb ?? null,
|
|
917
|
+
maxVolumeDb: evaled.maxVolumeDb ?? null,
|
|
918
|
+
gates: evaled.gates || {
|
|
919
|
+
file: false,
|
|
920
|
+
duration: false,
|
|
921
|
+
video: false,
|
|
922
|
+
audio: false,
|
|
923
|
+
mode: false,
|
|
924
|
+
resolution: false,
|
|
925
|
+
},
|
|
926
|
+
reason: evaled.reason || null,
|
|
927
|
+
spine: SPINE,
|
|
928
|
+
writtenAt: new Date().toISOString(),
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function evaluateReceipt(root, receipt) {
|
|
933
|
+
if (!receipt || typeof receipt !== "object") {
|
|
934
|
+
return { status: "FAIL", failClosed: true, reason: "receipt-unreadable" };
|
|
935
|
+
}
|
|
936
|
+
if (receipt.reason === "receipt-unreadable") {
|
|
937
|
+
return { status: "FAIL", failClosed: true, reason: "receipt-unreadable" };
|
|
938
|
+
}
|
|
939
|
+
const modeRaw = receipt.pictureMode || receipt.mode;
|
|
940
|
+
const mode = resolveMode(modeRaw);
|
|
941
|
+
if (receipt.status !== "PASS") {
|
|
942
|
+
return {
|
|
943
|
+
status: "FAIL",
|
|
944
|
+
failClosed: true,
|
|
945
|
+
mode: receipt.motionId || receipt.mode || null,
|
|
946
|
+
pictureMode: receipt.pictureMode || null,
|
|
947
|
+
durationSec: receipt.durationSec ?? DURATION_SEC,
|
|
948
|
+
mp4: receipt.mp4 || null,
|
|
949
|
+
hasVideo: receipt.hasVideo ?? null,
|
|
950
|
+
hasAudio: receipt.hasAudio ?? null,
|
|
951
|
+
reason: receipt.reason || "blip receipt FAIL",
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
if (!mode.ok) {
|
|
955
|
+
return {
|
|
956
|
+
status: "FAIL",
|
|
957
|
+
failClosed: true,
|
|
958
|
+
mode: mode.motionId || null,
|
|
959
|
+
pictureMode: mode.pictureMode || null,
|
|
960
|
+
reason: mode.reason || "unknown motion id",
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
const mp4Rel = typeof receipt.mp4 === "string" ? receipt.mp4 : MP4_REL;
|
|
964
|
+
const mp4 = path.isAbsolute(mp4Rel) ? mp4Rel : path.join(root, mp4Rel);
|
|
965
|
+
const evaled = evaluateMp4File(mp4, {
|
|
966
|
+
mode: mode.motionId,
|
|
967
|
+
wantAudio: true,
|
|
968
|
+
motion: true,
|
|
969
|
+
});
|
|
970
|
+
return {
|
|
971
|
+
...evaled,
|
|
972
|
+
mode: mode.motionId,
|
|
973
|
+
pictureMode: mode.pictureMode,
|
|
974
|
+
mp4: mp4Rel,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function failReceipt(root, input, reason, extra = {}) {
|
|
979
|
+
const receipt = buildReceipt(input, {
|
|
980
|
+
status: "FAIL",
|
|
981
|
+
failClosed: true,
|
|
982
|
+
durationSec: extra.durationSec ?? null,
|
|
983
|
+
hasVideo: extra.hasVideo ?? false,
|
|
984
|
+
hasAudio: extra.hasAudio ?? false,
|
|
985
|
+
gates: extra.gates || {
|
|
986
|
+
file: false,
|
|
987
|
+
duration: false,
|
|
988
|
+
video: false,
|
|
989
|
+
audio: false,
|
|
990
|
+
mode: Boolean(input.motionId),
|
|
991
|
+
resolution: false,
|
|
992
|
+
},
|
|
993
|
+
reason,
|
|
994
|
+
});
|
|
995
|
+
writeReceipt(root, receipt);
|
|
996
|
+
return { receipt, mp4: input.mp4, seed: input.seed };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function resolveBed(opts, root, brief, work, seed) {
|
|
1000
|
+
if (opts.bed) {
|
|
1001
|
+
const bed = path.resolve(root, opts.bed);
|
|
1002
|
+
if (!fs.existsSync(bed)) {
|
|
1003
|
+
return { ok: false, reason: `bed missing: ${opts.bed}` };
|
|
1004
|
+
}
|
|
1005
|
+
return { ok: true, bed, source: "flag", grid: null };
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
const out = opts.autoBedOut
|
|
1009
|
+
? path.resolve(root, opts.autoBedOut)
|
|
1010
|
+
: path.join(root, ".xray", "blip", "bed.wav");
|
|
1011
|
+
const rendered = sound.renderSamples({
|
|
1012
|
+
brief,
|
|
1013
|
+
genre: opts.genre || "ambient",
|
|
1014
|
+
seconds: DURATION_SEC,
|
|
1015
|
+
seed,
|
|
1016
|
+
syncopate: true,
|
|
1017
|
+
});
|
|
1018
|
+
sound.writeWav16Mono(out, rendered.samples, rendered.sampleRate);
|
|
1019
|
+
if (!fs.existsSync(out)) {
|
|
1020
|
+
return { ok: false, reason: "auto bed missing" };
|
|
1021
|
+
}
|
|
1022
|
+
return { ok: true, bed: out, source: "auto-sound", grid: rendered.grid || null };
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
return {
|
|
1025
|
+
ok: false,
|
|
1026
|
+
reason: `auto bed failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function renderMotionPicture(work, modeInfo, seed, brief, opts) {
|
|
1032
|
+
const frames = Math.max(1, Math.round(DURATION_SEC * FPS));
|
|
1033
|
+
const picture = path.join(work, "picture.mp4");
|
|
1034
|
+
const forceFallback = Boolean(opts.forceFallback) || opts.engine === "wireframe";
|
|
1035
|
+
const width = MOTION_WIDTH;
|
|
1036
|
+
const height = MOTION_HEIGHT;
|
|
1037
|
+
const raw = path.join(work, "motion.rgb");
|
|
1038
|
+
|
|
1039
|
+
if (forceFallback) {
|
|
1040
|
+
if (!painterFor(modeInfo.renderer, 0, seed, width, height)) {
|
|
1041
|
+
const err = new Error(`no factory renderer for "${modeInfo.renderer}"`);
|
|
1042
|
+
err.code = "BLIP_WIREFRAME_MISS";
|
|
1043
|
+
throw err;
|
|
1044
|
+
}
|
|
1045
|
+
writeRawMotion(raw, width, height, frames, (buf, t) => {
|
|
1046
|
+
paintWireframeFrame(buf, width, height, modeInfo.renderer, t, seed);
|
|
1047
|
+
});
|
|
1048
|
+
encodeRaw(raw, width, height, frames, picture);
|
|
1049
|
+
return {
|
|
1050
|
+
picture,
|
|
1051
|
+
width,
|
|
1052
|
+
height,
|
|
1053
|
+
engine: "ffmpeg-wireframe-fallback",
|
|
1054
|
+
fallback: true,
|
|
1055
|
+
visualConfig: null,
|
|
1056
|
+
look: null,
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
let visualConfig = null;
|
|
1061
|
+
let look = rippel.LOOK;
|
|
1062
|
+
let lookKind = null;
|
|
1063
|
+
writeRawMotion(raw, width, height, frames, (buf, t) => {
|
|
1064
|
+
const painted = rippel.paintRippelFrame({
|
|
1065
|
+
renderer: modeInfo.renderer,
|
|
1066
|
+
t,
|
|
1067
|
+
seedHex: seed,
|
|
1068
|
+
brief,
|
|
1069
|
+
durationSec: DURATION_SEC,
|
|
1070
|
+
width,
|
|
1071
|
+
height,
|
|
1072
|
+
buffer: buf,
|
|
1073
|
+
lookKind: opts.lookKind,
|
|
1074
|
+
});
|
|
1075
|
+
visualConfig = painted.visualConfig;
|
|
1076
|
+
look = painted.look;
|
|
1077
|
+
lookKind = painted.lookKind;
|
|
1078
|
+
});
|
|
1079
|
+
encodeRaw(raw, width, height, frames, picture);
|
|
1080
|
+
return { picture, width, height, engine: rippel.ENGINE, look, lookKind, fallback: false, visualConfig };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function renderBlip(opts = {}) {
|
|
1084
|
+
const root = opts.root || process.cwd();
|
|
1085
|
+
const brief = String(opts.brief || "factory-blip");
|
|
1086
|
+
const modeInfo = resolveMode(opts.pictureMode || opts.mode, opts.registryPath);
|
|
1087
|
+
const seed = opts.seed || seedFromBrief(brief, modeInfo.motionId || modeInfo.id);
|
|
1088
|
+
const mp4 = opts.out ? path.resolve(root, opts.out) : defaultMp4Path(root);
|
|
1089
|
+
const input = {
|
|
1090
|
+
brief,
|
|
1091
|
+
seed,
|
|
1092
|
+
pictureMode: modeInfo.pictureMode,
|
|
1093
|
+
motionId: modeInfo.motionId || modeInfo.id,
|
|
1094
|
+
visualization: modeInfo.visualization || rippel.visualizationFor(modeInfo.renderer) || null,
|
|
1095
|
+
stillPlate: modeInfo.renderer === "still" ? stillPlate(seed, 0) : null,
|
|
1096
|
+
registryIds: listMotionIds(opts.registryPath),
|
|
1097
|
+
mp4,
|
|
1098
|
+
mp4Rel: path.relative(root, mp4) || mp4,
|
|
1099
|
+
bed: null,
|
|
1100
|
+
bedRel: null,
|
|
1101
|
+
bedSource: null,
|
|
1102
|
+
engine: modeInfo.renderer === "still" ? POWER_PLANT_ENGINE : rippel.ENGINE,
|
|
1103
|
+
look: modeInfo.renderer === "still" ? POWER_PLANT_LOOK : rippel.LOOK,
|
|
1104
|
+
fallback: false,
|
|
1105
|
+
visualConfig: null,
|
|
1106
|
+
lookKind: opts.lookKind || null,
|
|
1107
|
+
width: MOTION_WIDTH,
|
|
1108
|
+
height: MOTION_HEIGHT,
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
if (!modeInfo.ok) {
|
|
1112
|
+
return failReceipt(root, input, modeInfo.reason);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
fs.mkdirSync(path.dirname(mp4), { recursive: true });
|
|
1116
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "xray-foundry-blip-"));
|
|
1117
|
+
try {
|
|
1118
|
+
const bedInfo = resolveBed(opts, root, brief, work, seed);
|
|
1119
|
+
if (!bedInfo.ok) {
|
|
1120
|
+
return failReceipt(root, input, bedInfo.reason);
|
|
1121
|
+
}
|
|
1122
|
+
input.bed = bedInfo.bed;
|
|
1123
|
+
input.bedRel = path.relative(root, bedInfo.bed) || bedInfo.bed;
|
|
1124
|
+
input.bedSource = bedInfo.source;
|
|
1125
|
+
input.grid = bedInfo.grid || null;
|
|
1126
|
+
|
|
1127
|
+
const picture = path.join(work, "picture.mp4");
|
|
1128
|
+
if (modeInfo.renderer === "still") {
|
|
1129
|
+
const frames = Math.max(1, Math.round(DURATION_SEC * FPS));
|
|
1130
|
+
const raw = path.join(work, "power-plant.rgb");
|
|
1131
|
+
writeRawMotion(raw, MOTION_WIDTH, MOTION_HEIGHT, frames, (buf, t) => {
|
|
1132
|
+
paintStillFrame(buf, MOTION_WIDTH, MOTION_HEIGHT, seed, t, brief);
|
|
1133
|
+
});
|
|
1134
|
+
encodeRaw(raw, MOTION_WIDTH, MOTION_HEIGHT, frames, picture);
|
|
1135
|
+
input.engine = POWER_PLANT_ENGINE;
|
|
1136
|
+
input.look = POWER_PLANT_LOOK;
|
|
1137
|
+
input.width = MOTION_WIDTH;
|
|
1138
|
+
input.height = MOTION_HEIGHT;
|
|
1139
|
+
input.visualConfig = rippel.summarizeVisual(
|
|
1140
|
+
rippel.cachedChecksum({
|
|
1141
|
+
brief,
|
|
1142
|
+
seedHex: seed,
|
|
1143
|
+
width: MOTION_WIDTH,
|
|
1144
|
+
height: MOTION_HEIGHT,
|
|
1145
|
+
lookKind: opts.lookKind,
|
|
1146
|
+
}),
|
|
1147
|
+
);
|
|
1148
|
+
input.lookKind = input.visualConfig && input.visualConfig.lookKind;
|
|
1149
|
+
} else {
|
|
1150
|
+
const motion = renderMotionPicture(work, modeInfo, seed, brief, opts);
|
|
1151
|
+
if (motion.picture !== picture) fs.copyFileSync(motion.picture, picture);
|
|
1152
|
+
input.engine = motion.engine;
|
|
1153
|
+
input.look = motion.look || (motion.fallback ? null : rippel.LOOK);
|
|
1154
|
+
input.fallback = motion.fallback;
|
|
1155
|
+
input.width = motion.width;
|
|
1156
|
+
input.height = motion.height;
|
|
1157
|
+
input.visualConfig = rippel.summarizeVisual(
|
|
1158
|
+
rippel.cachedChecksum({
|
|
1159
|
+
brief,
|
|
1160
|
+
seedHex: seed,
|
|
1161
|
+
width: motion.width,
|
|
1162
|
+
height: motion.height,
|
|
1163
|
+
lookKind: opts.lookKind,
|
|
1164
|
+
}),
|
|
1165
|
+
);
|
|
1166
|
+
input.lookKind = motion.lookKind || (input.visualConfig && input.visualConfig.lookKind);
|
|
1167
|
+
}
|
|
1168
|
+
muxBed(picture, bedInfo.bed, mp4);
|
|
1169
|
+
const evaled = evaluateMp4File(mp4, {
|
|
1170
|
+
mode: modeInfo.motionId,
|
|
1171
|
+
wantAudio: true,
|
|
1172
|
+
motion: true,
|
|
1173
|
+
});
|
|
1174
|
+
const receipt = buildReceipt(input, evaled);
|
|
1175
|
+
writeReceipt(root, receipt);
|
|
1176
|
+
return { receipt, mp4, seed };
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1179
|
+
return failReceipt(root, input, reason);
|
|
1180
|
+
} finally {
|
|
1181
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
module.exports = {
|
|
1186
|
+
SPINE,
|
|
1187
|
+
DURATION_SEC,
|
|
1188
|
+
DURATION_TOL_SEC,
|
|
1189
|
+
FPS,
|
|
1190
|
+
WIDTH,
|
|
1191
|
+
HEIGHT,
|
|
1192
|
+
STILL_WIDTH,
|
|
1193
|
+
STILL_HEIGHT,
|
|
1194
|
+
MOTION_WIDTH,
|
|
1195
|
+
MOTION_HEIGHT,
|
|
1196
|
+
RECEIPT_REL,
|
|
1197
|
+
MP4_REL,
|
|
1198
|
+
V0_IDS,
|
|
1199
|
+
RIPPEL_IMPORTS,
|
|
1200
|
+
STILL_PLATES,
|
|
1201
|
+
PALETTE,
|
|
1202
|
+
PLATE,
|
|
1203
|
+
RGB,
|
|
1204
|
+
SSOT,
|
|
1205
|
+
ANIMATION_TO_VISUALIZATION,
|
|
1206
|
+
stillPlate,
|
|
1207
|
+
plateClock,
|
|
1208
|
+
paintStill,
|
|
1209
|
+
paintStillFrame,
|
|
1210
|
+
sampleStillFrames,
|
|
1211
|
+
POWER_PLANT_ENGINE,
|
|
1212
|
+
POWER_PLANT_LOOK,
|
|
1213
|
+
defaultRegistryPath,
|
|
1214
|
+
loadRegistry,
|
|
1215
|
+
listMotionIds,
|
|
1216
|
+
listV0Ids,
|
|
1217
|
+
parsePictureMode,
|
|
1218
|
+
seedFromBrief,
|
|
1219
|
+
resolveMode,
|
|
1220
|
+
seedRgb,
|
|
1221
|
+
writePpm,
|
|
1222
|
+
hasFfmpeg,
|
|
1223
|
+
renderBlip,
|
|
1224
|
+
probeMedia,
|
|
1225
|
+
probeAudio,
|
|
1226
|
+
AUDIBLE_MAX_DB_MIN,
|
|
1227
|
+
evaluateMp4File,
|
|
1228
|
+
evaluateReceipt,
|
|
1229
|
+
receiptPath,
|
|
1230
|
+
defaultMp4Path,
|
|
1231
|
+
writeReceipt,
|
|
1232
|
+
readReceipt,
|
|
1233
|
+
buildReceipt,
|
|
1234
|
+
resolveBed,
|
|
1235
|
+
paintWireframeFrame,
|
|
1236
|
+
encodeRaw,
|
|
1237
|
+
};
|