Files
ShortVideoGenerator/projects/water-hydrogen-bond-surface-tension/renderer/data.mjs
T

118 lines
5.5 KiB
JavaScript

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};
}