mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Remove media-30s.mp4 and media-30s.wav from gen-synthetic-content.mjs (these are committed real heroes, not synthetics to regenerate) - Add skip-if-exists guards to all generators to prevent manifest hash breakage from encoder-version differences - Add --force flag to gen-synthetic-content.mjs for deliberate overwrite - Fix generate-test-fixtures.mjs to skip encrypted.pdf if it exists (qpdf AES encryption uses random IVs, non-deterministic) - Fill provenance for 14 newly-scanned manifest entries after Phase 6b moves - Verify all three generators produce expected output against new layout
368 lines
16 KiB
JavaScript
368 lines
16 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 { existsSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { execSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createRequire } from "node:module";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const IMAGE_VALID = join(__dirname, "image/valid");
|
|
const DOC_VALID = join(__dirname, "document/valid");
|
|
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) {
|
|
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 = `BT /F1 24 Tf 72 700 Td (Page ${i}) Tj ET`;
|
|
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");
|
|
}
|
|
|
|
// ── 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");
|
|
|
|
// ── 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",
|
|
);
|
|
|
|
// ── 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);
|
|
});
|