118 lines
5.5 KiB
JavaScript
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};
|
|
}
|