import fs from 'node:fs'; import path from 'node:path'; import assert from 'node:assert/strict'; import {fileURLToPath} from 'node:url'; import {PNG} from 'pngjs'; import {plans, labels} from './scene-plan.mjs'; export const rendererDir = path.dirname(fileURLToPath(import.meta.url)); export const projectDir = path.dirname(rendererDir); export const repoDir = path.resolve(projectDir, '../..'); export const fps = 50; export const read = (file) => JSON.parse(fs.readFileSync(file, 'utf8')); export const artifact = (stage) => read(path.join(projectDir, `stages/${stage}/r1/artifact.json`)); export const absolute = (relative) => path.join(repoDir, relative); export const relative = (file) => path.relative(repoDir, file).split(path.sep).join('/'); export const write = (file, value) => { fs.mkdirSync(path.dirname(file), {recursive: true}); fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n'); }; export function validateInputs(project, cuts, images, scenes) { for (const id of ['story', 'scenes']) { const review = project.reviews.find((r) => r.review_id === id); assert.equal(review?.decision, 'approved', `${id} approval required`); for (const ref of review.artifact_refs) assert.equal(ref.revision, 1); } for (const id of ['07-cuts', '08-scenes', '11-images']) { assert.equal(project.stages.find((s) => s.stage_id === id)?.status, 'complete', `${id} incomplete`); } assert.equal(cuts.length, 31); assert.equal(new Set(cuts.map((c) => c.id)).size, cuts.length); assert.equal(images.length, 62); assert.equal(new Set(images.map((i) => i.frame_id)).size, images.length); let end = 0; for (const cut of cuts) { assert.equal(cut.start_ms, end, `${cut.id} gap/overlap`); const duration = cut.end_ms - cut.start_ms; assert(duration > 0 && duration <= 5000); assert.equal(duration % 20, 0, '50fps requires exact frame times'); for (const position of ['start', 'end']) { const image = images.find((f) => f.frame_id === `${cut.id}-${position}`); assert(image?.media.verified, `${cut.id}-${position} unverified`); assert.equal(image.media.width * 16, image.media.height * 9); } end = cut.end_ms; } assert.equal(end, 110560); assert(end <= project.settings.max_duration_ms); for (const c of scenes.connections.filter((c) => c.mode === 'continuous')) { const get = (id) => images.find((i) => i.frame_id === id).media.path; assert.equal(get(`${c.from_cut_id}-end`), get(`${c.to_cut_id}-start`)); } } function bodyBox(png) { // Anti-aliased navy magnification guides can resemble cyan; use the largest // connected filled region rather than the union of every matching pixel. const width = 540, height = 410, mask = new Uint8Array(width * height); for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { const i = ((y + 620) * png.width + x + 220) * 4; const [r, g, b] = png.data.subarray(i, i + 3); mask[y * width + x] = r < 190 && g > 155 && b > 180 && g > r + 15 ? 1 : 0; } const queue = new Int32Array(mask.length); let largest = 0, bounds; for (let seed = 0; seed < mask.length; seed++) { if (!mask[seed]) continue; let head = 0, tail = 1, minX = width, minY = height, maxX = 0, maxY = 0; queue[0] = seed; mask[seed] = 0; while (head < tail) { const cell = queue[head++], x = cell % width, y = Math.floor(cell / width); minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); for (const next of [x ? cell - 1 : -1, x + 1 < width ? cell + 1 : -1, y ? cell - width : -1, y + 1 < height ? cell + width : -1]) { if (next >= 0 && mask[next]) {mask[next] = 0; queue[tail++] = next;} } } if (tail > largest) {largest = tail; bounds = [minX + 215, minY + 615, maxX - minX + 11, maxY - minY + 11];} } assert(largest > 10000, 'macro droplet body not found'); return bounds; } export function prepareData() { const project = read(path.join(projectDir, 'project.json')); const cutStage = artifact('07-cuts').payload; const imageStage = artifact('11-images').payload; const scenes = artifact('08-scenes').payload; const images = imageStage.result?.frames ?? []; validateInputs(project, cutStage.cuts, images, scenes); const publicDir = path.join(rendererDir, 'public'); fs.mkdirSync(publicDir, {recursive: true}); const copied = new Map(); function asset(media) { if (!copied.has(media.path)) { const name = `asset-${copied.size}.png`; const bytes = fs.readFileSync(absolute(media.path)); const png = PNG.sync.read(bytes, {checkCRC: true}); assert.equal(png.width, media.width); assert.equal(png.height, media.height); fs.writeFileSync(path.join(publicDir, name), bytes); copied.set(media.path, {name, png}); } return copied.get(media.path); } const clips = cutStage.cuts.map((c) => { const a = asset(images.find((f) => f.frame_id === `${c.id}-start`).media); const b = asset(images.find((f) => f.frame_id === `${c.id}-end`).media); const plan = plans[c.id]; const boxes = plan.kind === 'deform' ? [bodyBox(a.png), bodyBox(b.png)] : null; return {...c, frames: (c.end_ms - c.start_ms) / 20, a: a.name, b: b.name, plan, boxes, background: Array.from(a.png.data.subarray(0, 3))}; }); const titles = artifact('04-title').payload; const title = titles.candidates.find((t) => t.id === titles.selected_id).text; return {clips, captions: cutStage.captions, labels, title, fps, duration_ms: cutStage.narration_duration_ms ?? clips.at(-1).end_ms}; }