feat: add deterministic cleared project media
This commit is contained in:
91
scripts/lib/asset-recipes.mjs
Normal file
91
scripts/lib/asset-recipes.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||
import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
const idPattern = /^[a-z][a-z0-9-]{0,63}$/u;
|
||||
const outputPattern = /^project-[a-z0-9][a-z0-9-]*\.(webp|png|webm)$/u;
|
||||
|
||||
const fail = (rule) => { throw new Error(rule); };
|
||||
|
||||
const exactKeys = (record, allowed, rule) => {
|
||||
if (Object.keys(record).some((key) => !allowed.includes(key))) fail(rule);
|
||||
};
|
||||
|
||||
export const readJson = async (path, rule = 'ASSET-JSON-001') => {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
} catch {
|
||||
fail(rule);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadRecipes = async (path) => {
|
||||
const input = await readJson(path, 'ASSET-RECIPE-001');
|
||||
if (!input || typeof input !== 'object' || input.schema !== 'portfolio-asset-recipes/v1' || !Array.isArray(input.assets)) {
|
||||
fail('ASSET-RECIPE-001');
|
||||
}
|
||||
exactKeys(input, ['schema', 'assets'], 'ASSET-RECIPE-001');
|
||||
const seenIds = new Set();
|
||||
const seenOutputs = new Set();
|
||||
const assets = input.assets.map((asset) => {
|
||||
if (!asset || typeof asset !== 'object') fail('ASSET-RECIPE-001');
|
||||
exactKeys(asset, [
|
||||
'id', 'kind', 'output', 'maxWidth', 'startSeconds', 'durationSeconds',
|
||||
'rightsHolder', 'license', 'attribution',
|
||||
], 'ASSET-RECIPE-001');
|
||||
if (!idPattern.test(asset.id) || !['image', 'video'].includes(asset.kind) || !outputPattern.test(asset.output)) {
|
||||
fail('ASSET-RECIPE-001');
|
||||
}
|
||||
if (asset.kind === 'image' && !/\.(webp|png)$/u.test(asset.output)) fail('ASSET-RECIPE-001');
|
||||
if (asset.kind === 'video' && !asset.output.endsWith('.webm')) fail('ASSET-RECIPE-001');
|
||||
if (!Number.isInteger(asset.maxWidth) || asset.maxWidth < 64 || asset.maxWidth > 4096) fail('ASSET-RECIPE-001');
|
||||
if (asset.kind === 'video' && (
|
||||
typeof asset.startSeconds !== 'number' || asset.startSeconds < 0 ||
|
||||
typeof asset.durationSeconds !== 'number' || asset.durationSeconds <= 0 || asset.durationSeconds > 30
|
||||
)) fail('ASSET-RECIPE-001');
|
||||
for (const field of ['rightsHolder', 'license', 'attribution']) {
|
||||
if (typeof asset[field] !== 'string' || asset[field].trim().length === 0 || /[\u0000-\u001f\u007f]/u.test(asset[field])) {
|
||||
fail('ASSET-RECIPE-001');
|
||||
}
|
||||
}
|
||||
if (seenIds.has(asset.id) || seenOutputs.has(asset.output.toLowerCase())) fail('ASSET-RECIPE-002');
|
||||
seenIds.add(asset.id);
|
||||
seenOutputs.add(asset.output.toLowerCase());
|
||||
return Object.freeze({ ...asset });
|
||||
});
|
||||
return Object.freeze(assets);
|
||||
};
|
||||
|
||||
export const loadPrivateMapping = async (path, recipes) => {
|
||||
const input = await readJson(path, 'ASSET-MAPPING-001');
|
||||
if (!input || typeof input !== 'object' || input.schema !== 'portfolio-private-source-map/v1' ||
|
||||
!input.sources || typeof input.sources !== 'object' || Array.isArray(input.sources)) fail('ASSET-MAPPING-001');
|
||||
exactKeys(input, ['schema', 'sources'], 'ASSET-MAPPING-001');
|
||||
const ids = recipes.map(({ id }) => id).sort();
|
||||
if (JSON.stringify(Object.keys(input.sources).sort()) !== JSON.stringify(ids)) fail('ASSET-MAPPING-002');
|
||||
for (const value of Object.values(input.sources)) {
|
||||
if (typeof value !== 'string' || value.length === 0 || isAbsolute(value) || value.includes('\\') ||
|
||||
value.split('/').some((part) => part === '' || part === '.' || part === '..')) fail('ASSET-MAPPING-001');
|
||||
}
|
||||
return input.sources;
|
||||
};
|
||||
|
||||
export const resolvePrivateSource = async (sourceRoot, mappedPath) => {
|
||||
const root = await realpath(sourceRoot);
|
||||
const candidate = resolve(root, mappedPath);
|
||||
const rel = relative(root, candidate);
|
||||
if (!rel || rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel)) fail('ASSET-SOURCE-001');
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await realpath(candidate);
|
||||
} catch {
|
||||
fail('ASSET-SOURCE-001');
|
||||
}
|
||||
const resolvedRel = relative(root, resolved);
|
||||
if (resolvedRel.startsWith(`..${sep}`) || resolvedRel === '..' || isAbsolute(resolvedRel)) fail('ASSET-SOURCE-001');
|
||||
const info = await stat(resolved);
|
||||
if (!info.isFile() || info.size <= 0 || info.size > 100 * 1024 * 1024) fail('ASSET-SOURCE-002');
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const publicFileNameFor = (recipe) => basename(recipe.output);
|
||||
export const publicExtensionFor = (recipe) => extname(recipe.output).slice(1);
|
||||
10
scripts/lib/hash-file.mjs
Normal file
10
scripts/lib/hash-file.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
|
||||
export const hashFile = async (path) => new Promise((resolve, reject) => {
|
||||
const digest = createHash('sha256');
|
||||
const stream = createReadStream(path);
|
||||
stream.on('error', reject);
|
||||
stream.on('data', (chunk) => digest.update(chunk));
|
||||
stream.on('end', () => resolve(digest.digest('hex')));
|
||||
});
|
||||
118
scripts/lib/privacy-scan.mjs
Normal file
118
scripts/lib/privacy-scan.mjs
Normal file
@@ -0,0 +1,118 @@
|
||||
import { access, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import { createWorker, OEM } from 'tesseract.js';
|
||||
import {
|
||||
BinaryBitmap,
|
||||
HybridBinarizer,
|
||||
MultiFormatReader,
|
||||
NotFoundException,
|
||||
RGBLuminanceSource,
|
||||
} from '@zxing/library';
|
||||
|
||||
const normalize = (value) => value.normalize('NFKC').toLocaleLowerCase('en-US');
|
||||
const fail = (rule, location = '') => { throw new Error(location ? `${rule}:${location}` : rule); };
|
||||
|
||||
export const loadDenylist = async (path) => {
|
||||
let text;
|
||||
try {
|
||||
text = await readFile(path, 'utf8');
|
||||
} catch {
|
||||
fail('PRIVACY-DENYLIST-001');
|
||||
}
|
||||
const terms = [...new Set(text.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map(normalize))];
|
||||
if (terms.length === 0 || terms.some((term) => term.length < 3 || /[\u0000-\u001f\u007f]/u.test(term))) {
|
||||
fail('PRIVACY-DENYLIST-001');
|
||||
}
|
||||
return terms;
|
||||
};
|
||||
|
||||
export const scanText = (bytesOrText, denylist, rule = 'PRIVACY-BYTES-001') => {
|
||||
const text = normalize(Buffer.isBuffer(bytesOrText) || bytesOrText instanceof Uint8Array
|
||||
? Buffer.from(bytesOrText).toString('utf8')
|
||||
: String(bytesOrText));
|
||||
if (denylist.some((term) => text.includes(normalize(term)))) fail(rule);
|
||||
};
|
||||
|
||||
const scanBarcode = async (path) => {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = await sharp(path).removeAlpha().greyscale().raw().toBuffer({ resolveWithObject: true });
|
||||
} catch {
|
||||
fail('ASSET-DECODE-001');
|
||||
}
|
||||
const { data, info } = decoded;
|
||||
const luminance = new RGBLuminanceSource(new Uint8ClampedArray(data), info.width, info.height);
|
||||
const bitmap = new BinaryBitmap(new HybridBinarizer(luminance));
|
||||
try {
|
||||
new MultiFormatReader().decode(bitmap);
|
||||
fail('PRIVACY-BARCODE-001');
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException || error?.name === 'NotFoundException') return;
|
||||
if (error instanceof Error && error.message === 'PRIVACY-BARCODE-001') throw error;
|
||||
fail('PRIVACY-BARCODE-002');
|
||||
}
|
||||
};
|
||||
|
||||
const scanOcr = async (path, denylist) => {
|
||||
const candidates = ['/usr/share/tessdata', '/usr/share/tesseract-ocr/5/tessdata'];
|
||||
let langPath;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await access(join(candidate, 'eng.traineddata'));
|
||||
langPath = candidate;
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
if (!langPath) fail('PRIVACY-OCR-002');
|
||||
let worker;
|
||||
try {
|
||||
worker = await createWorker('eng', OEM.LSTM_ONLY, { langPath, gzip: false, cacheMethod: 'none' });
|
||||
} catch {
|
||||
fail('PRIVACY-OCR-002');
|
||||
}
|
||||
let timer;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
worker.recognize(path),
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 25_000); }),
|
||||
]);
|
||||
scanText(result.data.text, denylist, 'PRIVACY-OCR-001');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'PRIVACY-OCR-001') throw error;
|
||||
fail('PRIVACY-OCR-002');
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
await worker.terminate().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
export const scanImage = async (path, denylist, options = {}) => {
|
||||
let type;
|
||||
try { type = await fileTypeFromFile(path); } catch { fail('PRIVACY-TYPE-001'); }
|
||||
if (!type || !['image/png', 'image/webp', 'image/jpeg'].includes(type.mime)) fail('PRIVACY-TYPE-001');
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await sharp(path, { failOn: 'error', limitInputPixels: 50_000_000 }).metadata();
|
||||
} catch {
|
||||
fail('ASSET-DECODE-001');
|
||||
}
|
||||
if (metadata.exif || metadata.icc || metadata.iptc || metadata.xmp || metadata.orientation !== undefined) {
|
||||
fail('PRIVACY-METADATA-001');
|
||||
}
|
||||
scanText(await readFile(path), denylist);
|
||||
await scanBarcode(path);
|
||||
if (options.runOcr !== false) await scanOcr(path, denylist);
|
||||
return { width: metadata.width, height: metadata.height, mediaType: type.mime };
|
||||
};
|
||||
|
||||
export const scanBinary = async (path, denylist) => {
|
||||
let bytes;
|
||||
try { bytes = await readFile(path); } catch { fail('PRIVACY-TYPE-001'); }
|
||||
scanText(bytes, denylist);
|
||||
let type;
|
||||
try { type = await fileTypeFromFile(path); } catch { fail('PRIVACY-TYPE-001'); }
|
||||
if (!type || !['video/webm', 'video/mp4'].includes(type.mime)) fail('PRIVACY-TYPE-001');
|
||||
return { mediaType: type.mime };
|
||||
};
|
||||
74
scripts/lib/video-frames.mjs
Normal file
74
scripts/lib/video-frames.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const fail = (rule) => { throw new Error(rule); };
|
||||
|
||||
export const runMediaCommand = async (command, args, rule, timeout = 60_000) => {
|
||||
try {
|
||||
return await execFileAsync(command, args, {
|
||||
encoding: 'utf8', timeout, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
fail(rule);
|
||||
}
|
||||
};
|
||||
|
||||
export const probeVideo = async (path, options = {}) => {
|
||||
const { stdout } = await runMediaCommand('ffprobe', [
|
||||
'-v', 'error', '-show_entries', 'format=duration:stream=index,codec_type,width,height,duration',
|
||||
'-of', 'json', path,
|
||||
], 'ASSET-VIDEO-PROBE-001', 20_000);
|
||||
let probe;
|
||||
try { probe = JSON.parse(stdout); } catch { fail('ASSET-VIDEO-PROBE-001'); }
|
||||
const videoStreams = probe.streams?.filter((stream) => stream.codec_type === 'video') ?? [];
|
||||
const audioStreams = probe.streams?.filter((stream) => stream.codec_type === 'audio') ?? [];
|
||||
const unexpected = probe.streams?.filter((stream) => !['video', 'audio'].includes(stream.codec_type)) ?? [];
|
||||
const duration = Number(probe.format?.duration ?? videoStreams[0]?.duration);
|
||||
if (videoStreams.length !== 1 || unexpected.length > 0 || (options.allowAudio === false && audioStreams.length > 0) ||
|
||||
!Number.isFinite(duration) || duration <= 0 || duration > 120) {
|
||||
fail('ASSET-VIDEO-PROBE-001');
|
||||
}
|
||||
return { duration, width: Number(videoStreams[0].width), height: Number(videoStreams[0].height) };
|
||||
};
|
||||
|
||||
export const encodeVideoDerivatives = async ({ source, webmPath, mp4Path, posterPath, startSeconds, durationSeconds, maxWidth }) => {
|
||||
const common = [
|
||||
'-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-threads', '1',
|
||||
'-ss', String(startSeconds), '-i', source, '-t', String(durationSeconds), '-map', '0:v:0',
|
||||
'-an', '-sn', '-dn', '-vf', `scale=w='min(${maxWidth},iw)':h=-2:flags=lanczos`,
|
||||
'-map_metadata', '-1', '-map_chapters', '-1', '-pix_fmt', 'yuv420p',
|
||||
];
|
||||
await runMediaCommand('ffmpeg', [
|
||||
...common, '-fflags', '+bitexact', '-flags:v', '+bitexact',
|
||||
'-c:v', 'libvpx-vp9', '-deadline', 'good', '-cpu-used', '4', '-row-mt', '0',
|
||||
'-b:v', '0', '-crf', '34', '-threads', '1', webmPath,
|
||||
], 'ASSET-VIDEO-ENCODE-001', 120_000);
|
||||
await runMediaCommand('ffmpeg', [
|
||||
...common, '-fflags', '+bitexact', '-flags:v', '+bitexact',
|
||||
'-c:v', 'libx264', '-preset', 'slow', '-crf', '25', '-threads', '1',
|
||||
'-movflags', '+faststart', mp4Path,
|
||||
], 'ASSET-VIDEO-ENCODE-001', 120_000);
|
||||
await runMediaCommand('ffmpeg', [
|
||||
'-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-threads', '1',
|
||||
'-ss', '0.05', '-i', webmPath, '-frames:v', '1', '-an', '-sn', '-dn', '-map_metadata', '-1', posterPath,
|
||||
], 'ASSET-VIDEO-POSTER-001', 30_000);
|
||||
};
|
||||
|
||||
export const extractVideoFrames = async ({ source, durationSeconds, outputDir, prefix = 'frame' }) => {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const points = [...new Set([0.05, durationSeconds / 2, Math.max(0.05, durationSeconds - 0.1)].map((value) => Math.min(durationSeconds, Math.max(0, value)).toFixed(3)))];
|
||||
const paths = [];
|
||||
for (const [index, point] of points.entries()) {
|
||||
const path = join(outputDir, `${prefix}-${String(index + 1).padStart(2, '0')}.png`);
|
||||
await runMediaCommand('ffmpeg', [
|
||||
'-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-threads', '1',
|
||||
'-ss', point, '-i', source, '-frames:v', '1', '-an', '-sn', '-dn', '-map_metadata', '-1', path,
|
||||
], 'ASSET-VIDEO-FRAME-001', 30_000);
|
||||
if ((await readFile(path)).length === 0) fail('ASSET-VIDEO-FRAME-001');
|
||||
paths.push(path);
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
Reference in New Issue
Block a user