mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
503 lines
17 KiB
JavaScript
503 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// gen-synthetic-content.mjs -- Regenerates Category A & B content fixtures as CC0.
|
|
//
|
|
// Category A: Deterministically regenerable (QR codes, barcodes, OCR text, PDFs,
|
|
// synthetic audio/video).
|
|
// Category B: Replaces copyrighted content (Simpsons GIF, ConvertICO SVG,
|
|
// Shutterstock stock photo) with project-owned CC0 alternatives.
|
|
//
|
|
// All output is provably CC0 (generated by project scripts, no third-party IP).
|
|
// Usage: node tests/fixtures/gen-synthetic-content.mjs
|
|
//
|
|
// Dependencies (resolved from apps/api/node_modules):
|
|
// sharp, qrcode
|
|
// System deps on PATH:
|
|
// ffmpeg
|
|
|
|
import { execSync } from "node:child_process";
|
|
import { existsSync, writeFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const IMAGE_VALID = join(__dirname, "image/valid");
|
|
const DOC_VALID = join(__dirname, "document/valid");
|
|
const DOC_EDGE = join(__dirname, "document/edge");
|
|
const require = createRequire(join(__dirname, "../../apps/api/package.json"));
|
|
const sharp = require("sharp");
|
|
const QRCode = require("qrcode");
|
|
|
|
const FORCE = process.argv.includes("--force");
|
|
|
|
// Write file only if it does not already exist (or --force is set).
|
|
// Committed synthetics must not be silently overwritten because
|
|
// encoder-version differences can change bytes and break manifest hashes.
|
|
function writeIfMissing(path, data, label) {
|
|
if (!FORCE && existsSync(path)) {
|
|
console.log(` SKIP (exists): ${label || path}`);
|
|
return false;
|
|
}
|
|
writeFileSync(path, data);
|
|
console.log(` ${label || path}`);
|
|
return true;
|
|
}
|
|
|
|
// ── Code 128B barcode encoder ──────────────────────────────
|
|
|
|
// Standard Code 128 bar patterns (11 modules each, stop = 13 modules).
|
|
// Each string is a binary pattern: 1 = dark bar, 0 = light space.
|
|
const CODE128 = [
|
|
"11011001100",
|
|
"11001101100",
|
|
"11001100110",
|
|
"10010011000",
|
|
"10010001100",
|
|
"10001001100",
|
|
"10011001000",
|
|
"10011000100",
|
|
"10001100100",
|
|
"11001001000",
|
|
"11001000100",
|
|
"11000100100",
|
|
"10110011100",
|
|
"10011011100",
|
|
"10011001110",
|
|
"10111001100",
|
|
"10011101100",
|
|
"10011100110",
|
|
"11001110010",
|
|
"11001011100",
|
|
"11001001110",
|
|
"11011100100",
|
|
"11001110100",
|
|
"11101101110",
|
|
"11101001100",
|
|
"11100101100",
|
|
"11100100110",
|
|
"11101100100",
|
|
"11100110100",
|
|
"11100110010",
|
|
"11011011000",
|
|
"11011000110",
|
|
"11000110110",
|
|
"10100011000",
|
|
"10001011000",
|
|
"10001000110",
|
|
"10110001000",
|
|
"10001101000",
|
|
"10001100010",
|
|
"11010001000",
|
|
"11000101000",
|
|
"11000100010",
|
|
"10110111000",
|
|
"10110001110",
|
|
"10001101110",
|
|
"10111011000",
|
|
"10111000110",
|
|
"10001110110",
|
|
"11101110110",
|
|
"11010001110",
|
|
"11000101110",
|
|
"11011101000",
|
|
"11011100010",
|
|
"11011101110",
|
|
"11101011000",
|
|
"11101000110",
|
|
"11100010110",
|
|
"11101101000",
|
|
"11101100010",
|
|
"11100011010",
|
|
"11101111010",
|
|
"11001000010",
|
|
"11110001010",
|
|
"10100110000",
|
|
"10100001100",
|
|
"10010110000",
|
|
"10010000110",
|
|
"10000101100",
|
|
"10000100110",
|
|
"10110010000",
|
|
"10110000100",
|
|
"10011010000",
|
|
"10011000010",
|
|
"10000110100",
|
|
"10000110010",
|
|
"11000010010",
|
|
"11001010000",
|
|
"11110111010",
|
|
"11000010100",
|
|
"10001111010",
|
|
"10100111100",
|
|
"10010111100",
|
|
"10010011110",
|
|
"10111100100",
|
|
"10011110100",
|
|
"10011110010",
|
|
"11110100100",
|
|
"11110010100",
|
|
"11110010010",
|
|
"11011011110",
|
|
"11011110110",
|
|
"11110110110",
|
|
"10101111000",
|
|
"10100011110",
|
|
"10001011110",
|
|
"10111101000",
|
|
"10111100010",
|
|
"11110101000",
|
|
"11110100010",
|
|
"10111011110",
|
|
"10111101110",
|
|
"11101011110",
|
|
"11110101110",
|
|
"11010000100", // 103 Start A
|
|
"11010010000", // 104 Start B
|
|
"11010011100", // 105 Start C
|
|
];
|
|
const CODE128_STOP = "1100011101011";
|
|
|
|
function encodeCode128B(text) {
|
|
const START_B = 104;
|
|
const values = [START_B];
|
|
let checksum = START_B;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const v = text.charCodeAt(i) - 32;
|
|
values.push(v);
|
|
checksum += v * (i + 1);
|
|
}
|
|
values.push(checksum % 103);
|
|
let bits = "";
|
|
for (const v of values) bits += CODE128[v];
|
|
bits += CODE128_STOP;
|
|
return bits;
|
|
}
|
|
|
|
function barcodeSvg(text, width, height) {
|
|
const bits = encodeCode128B(text);
|
|
const barH = height - 40;
|
|
const quietZone = 20;
|
|
const barAreaW = width - 2 * quietZone;
|
|
const unitW = barAreaW / bits.length;
|
|
const bars = [];
|
|
for (let i = 0; i < bits.length; i++) {
|
|
if (bits[i] === "1") {
|
|
bars.push(
|
|
`<rect x="${(quietZone + i * unitW).toFixed(2)}" y="10" width="${unitW.toFixed(2)}" height="${barH}" fill="black"/>`,
|
|
);
|
|
}
|
|
}
|
|
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="${width}" height="${height}" fill="white"/>
|
|
${bars.join("\n ")}
|
|
<text x="${width / 2}" y="${height - 8}" text-anchor="middle" font-family="monospace, Courier" font-size="16" fill="black">${text}</text>
|
|
</svg>`;
|
|
}
|
|
|
|
// ── Minimal PDF generator ──────────────────────────────────
|
|
|
|
function generatePdf(
|
|
pageCount,
|
|
contentForPage = (page) => `BT /F1 24 Tf 72 700 Td (Page ${page}) Tj ET`,
|
|
) {
|
|
const objects = [];
|
|
let nextObj = 1;
|
|
const addObj = (content) => {
|
|
const id = nextObj++;
|
|
objects.push({ id, content });
|
|
return id;
|
|
};
|
|
|
|
const catalogId = addObj(null);
|
|
const pagesId = addObj(null);
|
|
const fontId = addObj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
|
|
|
|
const pageIds = [];
|
|
for (let i = 1; i <= pageCount; i++) {
|
|
const stream = contentForPage(i);
|
|
const sId = addObj(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
|
|
const pId = addObj(
|
|
`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 612 792] ` +
|
|
`/Contents ${sId} 0 R /Resources << /Font << /F1 ${fontId} 0 R >> >> >>`,
|
|
);
|
|
pageIds.push(pId);
|
|
}
|
|
|
|
objects[0].content = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
|
|
objects[1].content = `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pageCount} >>`;
|
|
|
|
let pdf = "%PDF-1.7\n%\x81\x81\x81\x81\n\n";
|
|
const offsets = [];
|
|
for (const obj of objects) {
|
|
offsets.push(pdf.length);
|
|
pdf += `${obj.id} 0 obj\n${obj.content}\nendobj\n\n`;
|
|
}
|
|
|
|
const xrefOffset = pdf.length;
|
|
pdf += `xref\n0 ${objects.length + 1}\n`;
|
|
pdf += "0000000000 65535 f \n";
|
|
for (const off of offsets) {
|
|
pdf += `${String(off).padStart(10, "0")} 00000 n \n`;
|
|
}
|
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\n`;
|
|
pdf += `startxref\n${xrefOffset}\n%%EOF\n`;
|
|
return Buffer.from(pdf, "binary");
|
|
}
|
|
|
|
const COLORED_BLOCK_STREAM = [
|
|
"0.12 0.52 0.72 rg",
|
|
"72 520 240 65 re f",
|
|
"BT",
|
|
"/F1 14 Tf",
|
|
"1 1 1 rg",
|
|
"84 562 Td",
|
|
"(BLOCK LINE ONE) Tj",
|
|
"0 -24 Td",
|
|
"(BLOCK LINE TWO) Tj",
|
|
"ET",
|
|
].join("\n");
|
|
|
|
// ── SVG logo (project-owned, replaces ConvertICO brand) ───
|
|
|
|
function projectSvg() {
|
|
return `<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<title>SnapOtter Test Fixture</title>
|
|
<desc>CC0 geometric test SVG generated by gen-synthetic-content.mjs</desc>
|
|
<defs>
|
|
<linearGradient id="bg" x1="0" y1="0" x2="400" y2="400">
|
|
<stop stop-color="#E07832"/>
|
|
<stop offset="1" stop-color="#C06520"/>
|
|
</linearGradient>
|
|
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feGaussianBlur in="SourceGraphic" stdDeviation="4" result="blur"/>
|
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
</filter>
|
|
</defs>
|
|
<rect width="400" height="400" fill="#1a1210"/>
|
|
<circle cx="200" cy="180" r="100" fill="url(#bg)" filter="url(#glow)"/>
|
|
<ellipse cx="200" cy="180" rx="60" ry="40" fill="#1a1210" opacity="0.3"/>
|
|
<circle cx="170" cy="160" r="12" fill="white"/>
|
|
<circle cx="230" cy="160" r="12" fill="white"/>
|
|
<circle cx="170" cy="160" r="5" fill="#1a1210"/>
|
|
<circle cx="230" cy="160" r="5" fill="#1a1210"/>
|
|
<ellipse cx="200" cy="195" rx="15" ry="8" fill="#1a1210" opacity="0.4"/>
|
|
<text x="200" y="320" font-family="Helvetica, Arial, sans-serif" font-size="28" font-weight="bold" text-anchor="middle" fill="#F0EBE4">TEST FIXTURE</text>
|
|
<text x="200" y="350" font-family="Helvetica, Arial, sans-serif" font-size="14" text-anchor="middle" fill="#6B6560">CC0 - Generated Asset</text>
|
|
</svg>`;
|
|
}
|
|
|
|
// ── Multi-face placeholder (replaces Shutterstock photo) ──
|
|
|
|
function multiFaceSvg(w, h) {
|
|
const cols = 5;
|
|
const rows = 2;
|
|
const cw = w / cols;
|
|
const ch = h / rows;
|
|
const colors = [
|
|
"#D4956A",
|
|
"#C2885E",
|
|
"#E8B88A",
|
|
"#A67853",
|
|
"#BF9570",
|
|
"#D9A87C",
|
|
"#CB9068",
|
|
"#E5C49E",
|
|
"#B08060",
|
|
"#C89878",
|
|
];
|
|
const shapes = [];
|
|
for (let r = 0; r < rows; r++) {
|
|
for (let c = 0; c < cols; c++) {
|
|
const cx = cw * c + cw / 2;
|
|
const cy = ch * r + ch / 2;
|
|
const rx = cw * 0.3;
|
|
const ry = ch * 0.38;
|
|
const idx = r * cols + c;
|
|
shapes.push(
|
|
`<ellipse cx="${cx.toFixed(0)}" cy="${cy.toFixed(0)}" rx="${rx.toFixed(0)}" ry="${ry.toFixed(0)}" fill="${colors[idx]}" stroke="#888" stroke-width="0.5"/>`,
|
|
);
|
|
shapes.push(
|
|
`<circle cx="${(cx - rx * 0.3).toFixed(0)}" cy="${(cy - ry * 0.15).toFixed(0)}" r="3" fill="#333"/>`,
|
|
);
|
|
shapes.push(
|
|
`<circle cx="${(cx + rx * 0.3).toFixed(0)}" cy="${(cy - ry * 0.15).toFixed(0)}" r="3" fill="#333"/>`,
|
|
);
|
|
}
|
|
}
|
|
return `<svg width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="${w}" height="${h}" fill="#d0d0d0"/>
|
|
${shapes.join("\n ")}
|
|
</svg>`;
|
|
}
|
|
|
|
// ── ffmpeg helper (all args are hardcoded constants) ───────
|
|
|
|
function ff(args, label) {
|
|
try {
|
|
execSync(`ffmpeg ${args}`, { stdio: "pipe" });
|
|
console.log(` ${label}`);
|
|
} catch (e) {
|
|
console.error(` FAILED: ${label}`, e.stderr?.toString().slice(-300));
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// Like ff(), but skip if the output file already exists (unless --force).
|
|
function ffIfMissing(outPath, args, label) {
|
|
if (!FORCE && existsSync(outPath)) {
|
|
console.log(` SKIP (exists): ${label}`);
|
|
return;
|
|
}
|
|
ff(args, label);
|
|
}
|
|
|
|
// ── Main ───────────────────────────────────────────────────
|
|
//
|
|
// Scoped outputs:
|
|
// - image/valid/ : QR codes, barcodes, OCR text, SVG logo, multi-face, animated GIF
|
|
// - document/valid/: alt-2page.pdf, multipage-6.pdf
|
|
//
|
|
// Deliberately EXCLUDED (committed real heroes, not synthetics):
|
|
// - video/valid/media-30s.mp4 (Big Buck Bunny CC-BY clip)
|
|
// - audio/valid/media-30s.wav (committed TTS/synthetic, hash-locked)
|
|
// - video/valid/speech-10s.mp4 (macOS TTS, committed)
|
|
// - audio/valid/speech-*.{wav,flac,ogg,m4a,aac,opus} (macOS TTS, committed)
|
|
// - video/valid/hero.{mov,webm,mkv,avi} (Big Buck Bunny CC-BY, committed)
|
|
|
|
async function main() {
|
|
console.log("Regenerating Category A & B content fixtures...");
|
|
console.log(
|
|
`Mode: ${FORCE ? "FORCE (overwrite all)" : "skip existing files (pass --force to overwrite)"}\n`,
|
|
);
|
|
|
|
// ── QR codes (A) ──
|
|
console.log("QR codes:");
|
|
const qrData = "https://snapotter.com";
|
|
const qrPng = await QRCode.toBuffer(qrData, {
|
|
width: 400,
|
|
margin: 2,
|
|
color: { dark: "#000000", light: "#ffffff" },
|
|
});
|
|
writeIfMissing(join(IMAGE_VALID, "qr-code.png"), qrPng, "qr-code.png");
|
|
|
|
const qrSvg = await QRCode.toString(qrData, { type: "svg", width: 296, margin: 2 });
|
|
writeIfMissing(join(IMAGE_VALID, "qr-code.svg"), qrSvg, "qr-code.svg");
|
|
|
|
const qrAvif = await sharp(qrPng).resize(400, 400).avif({ quality: 80 }).toBuffer();
|
|
writeIfMissing(join(IMAGE_VALID, "qr-code.avif"), qrAvif, "qr-code.avif");
|
|
|
|
// ── Barcodes (A) ──
|
|
console.log("Barcodes:");
|
|
const bcText = "SNAPOTTER-TEST-123";
|
|
const bcSvgStr = barcodeSvg(bcText, 699, 152);
|
|
const bcPng = await sharp(Buffer.from(bcSvgStr)).png().toBuffer();
|
|
writeIfMissing(join(IMAGE_VALID, "barcode.png"), bcPng, "barcode.png");
|
|
|
|
const bcAvif = await sharp(Buffer.from(bcSvgStr)).avif({ quality: 80 }).toBuffer();
|
|
writeIfMissing(join(IMAGE_VALID, "barcode.avif"), bcAvif, "barcode.avif");
|
|
|
|
// ── OCR text images (A) ──
|
|
console.log("OCR text images:");
|
|
const ocrEngSvg = `<svg width="800" height="200" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="800" height="200" fill="white"/>
|
|
<text x="400" y="115" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="42" font-weight="bold" fill="black">The quick brown fox 12345</text>
|
|
</svg>`;
|
|
const ocrEngPng = await sharp(Buffer.from(ocrEngSvg)).png().toBuffer();
|
|
writeIfMissing(join(IMAGE_VALID, "ocr-clean.png"), ocrEngPng, "ocr-clean.png");
|
|
|
|
const jpText =
|
|
"日本語の表記においては,漢字や仮名だけで" +
|
|
"なく,ローマ字やアラビア数字,さらに句読" +
|
|
"点や括弧類などの記述記号を用いる。これら" +
|
|
"を組み合わせて表す日本語の文書では,表記" +
|
|
"上における種々の問題がある。";
|
|
const chars = [...jpText];
|
|
const lineLen = Math.ceil(chars.length / 6);
|
|
const lines = [];
|
|
for (let i = 0; i < chars.length; i += lineLen) {
|
|
lines.push(chars.slice(i, i + lineLen).join(""));
|
|
}
|
|
const jpSvg = `<svg width="480" height="172" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="480" height="172" fill="white"/>
|
|
${lines.map((line, i) => `<text x="16" y="${30 + i * 28}" font-family="Hiragino Sans, Hiragino Kaku Gothic Pro, sans-serif" font-size="20" fill="black">${line}</text>`).join("\n ")}
|
|
</svg>`;
|
|
const jpPng = await sharp(Buffer.from(jpSvg)).png().toBuffer();
|
|
writeIfMissing(join(IMAGE_VALID, "ocr-japanese.png"), jpPng, "ocr-japanese.png");
|
|
|
|
// ── PDFs (A: deterministic generator, safe to overwrite) ──
|
|
console.log("PDFs:");
|
|
writeIfMissing(join(DOC_VALID, "alt-2page.pdf"), generatePdf(2), "alt-2page.pdf");
|
|
writeIfMissing(join(DOC_VALID, "multipage-6.pdf"), generatePdf(6), "multipage-6.pdf");
|
|
writeIfMissing(
|
|
join(DOC_EDGE, "colored-block.pdf"),
|
|
generatePdf(1, () => COLORED_BLOCK_STREAM),
|
|
"colored-block.pdf",
|
|
);
|
|
|
|
// ── SVG logo (B: replaces ConvertICO brand) ──
|
|
console.log("SVG logo (replacing ConvertICO brand):");
|
|
writeIfMissing(join(IMAGE_VALID, "svg-logo.svg"), projectSvg(), "svg-logo.svg");
|
|
|
|
// ── Multi-face placeholder (B: replaces Shutterstock photo) ──
|
|
console.log("Multi-face placeholder (replacing Shutterstock stock photo):");
|
|
const mfSvg = multiFaceSvg(433, 280);
|
|
const mfWebp = await sharp(Buffer.from(mfSvg)).webp({ quality: 80 }).toBuffer();
|
|
writeIfMissing(join(IMAGE_VALID, "multi-face.webp"), mfWebp, "multi-face.webp");
|
|
|
|
// ── Animated GIF (B: replaces copyrighted Simpsons clip) ──
|
|
console.log("Animated GIF (replacing copyrighted Simpsons clip):");
|
|
const gifOut = join(IMAGE_VALID, "animated-simpsons.gif");
|
|
ffIfMissing(
|
|
gifOut,
|
|
`-f lavfi -i "testsrc=duration=3:size=320x320:rate=10" -pix_fmt rgb8 -loop 0 -y "${gifOut}"`,
|
|
"animated-simpsons.gif",
|
|
);
|
|
|
|
// ── Animated APNG (multi-frame, full alpha) for remove-gif-background ──
|
|
// The committed file is a 4-frame RGBA APNG. This regenerates a small animated
|
|
// APNG only if the fixture is missing (bytes will differ from the committed one).
|
|
console.log("Animated APNG (for remove-gif-background):");
|
|
const apngOut = join(IMAGE_VALID, "animated.apng");
|
|
ffIfMissing(
|
|
apngOut,
|
|
`-f lavfi -i "testsrc=duration=1:size=48x48:rate=4" -pix_fmt rgba -plays 0 -f apng -y "${apngOut}"`,
|
|
"animated.apng",
|
|
);
|
|
|
|
// ── Synthetic audio/video (A) ──
|
|
// NOTE: media-30s.mp4 and media-30s.wav are no longer generated here.
|
|
// media-30s.mp4 is now a real Big Buck Bunny CC-BY hero clip (committed).
|
|
// media-30s.wav is a committed synthetic whose hash is locked in manifest.json.
|
|
// Regenerating either would clobber the committed content with different bytes.
|
|
console.log("Synthetic audio/video (metadata-tagged synthetics only):");
|
|
|
|
const audioTagsOut = join(__dirname, "audio/valid/audio-with-tags.mp3");
|
|
ffIfMissing(
|
|
audioTagsOut,
|
|
`-f lavfi -i "sine=frequency=440:duration=1.2:sample_rate=8000" ` +
|
|
`-c:a libmp3lame -b:a 64k -ar 8000 -ac 1 ` +
|
|
`-metadata title="Test Song" -metadata artist="Test Artist" ` +
|
|
`-metadata album="Test Album" -metadata date="2026" ` +
|
|
`-metadata genre="Electronic" -metadata track="1/10" ` +
|
|
`-y "${audioTagsOut}"`,
|
|
"audio-with-tags.mp3",
|
|
);
|
|
|
|
const videoMetaOut = join(__dirname, "video/valid/video-with-meta.mp4");
|
|
ffIfMissing(
|
|
videoMetaOut,
|
|
`-f lavfi -i "color=c=0xE07832:s=64x64:d=1:rate=16" ` +
|
|
`-c:v libx264 -pix_fmt yuv420p -movflags +faststart ` +
|
|
`-y "${videoMetaOut}"`,
|
|
"video-with-meta.mp4",
|
|
);
|
|
|
|
console.log("\nDone. Run gen-manifest.mjs to re-stamp hashes if any files were regenerated.");
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("FATAL:", e);
|
|
process.exit(1);
|
|
});
|