feat: add deterministic cleared project media
This commit is contained in:
16
content/public/asset-recipes.json
Normal file
16
content/public/asset-recipes.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"schema": "portfolio-asset-recipes/v1",
|
||||||
|
"assets": [
|
||||||
|
{ "id": "medication-mobile-logs", "kind": "image", "output": "project-medication-logs.webp", "maxWidth": 720, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||||
|
{ "id": "medication-mobile-data-settings", "kind": "image", "output": "project-medication-data-settings.webp", "maxWidth": 720, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||||
|
{ "id": "medication-mobile-notification-settings", "kind": "image", "output": "project-medication-notification-settings.webp", "maxWidth": 720, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||||
|
{ "id": "footy-detection-frame", "kind": "image", "output": "project-footy-detection.webp", "maxWidth": 1600, "rightsHolder": "Third-party footage and Luca Pîslaru", "license": "Publication requires confirmation", "attribution": "Detection overlay by Luca Pîslaru" },
|
||||||
|
{ "id": "opinion-polarization-chart", "kind": "image", "output": "project-opinion-polarization.webp", "maxWidth": 1000, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||||
|
{ "id": "opinion-cascade-thresholds-chart", "kind": "image", "output": "project-opinion-cascade-thresholds.webp", "maxWidth": 1000, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||||
|
{ "id": "opinion-average-degree-chart", "kind": "image", "output": "project-opinion-average-degree.webp", "maxWidth": 1000, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||||
|
{ "id": "opinion-revolt-frequency-chart", "kind": "image", "output": "project-opinion-revolt-frequency.webp", "maxWidth": 1000, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||||
|
{ "id": "opinion-final-distribution-chart", "kind": "image", "output": "project-opinion-final-distribution.webp", "maxWidth": 1000, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||||
|
{ "id": "private-game-loop", "kind": "video", "output": "project-private-game-loop.webm", "maxWidth": 1280, "startSeconds": 3, "durationSeconds": 5, "rightsHolder": "Rights holder confirmation pending", "license": "Publication requires confirmation", "attribution": "Prototype contribution by Luca Pîslaru" },
|
||||||
|
{ "id": "currency-converter-interface", "kind": "image", "output": "project-currency-converter.webp", "maxWidth": 1440, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" }
|
||||||
|
]
|
||||||
|
}
|
||||||
35
docs/asset-clearance-template.md
Normal file
35
docs/asset-clearance-template.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Private asset clearance register template
|
||||||
|
|
||||||
|
This template is for a private, untracked approval record. Do not place completed approvals, private source paths, original filenames, private product names, account details, or health data in the repository.
|
||||||
|
|
||||||
|
## Required preparation evidence
|
||||||
|
|
||||||
|
- Preparation date and reviewer.
|
||||||
|
- Generic asset ID.
|
||||||
|
- Candidate derivative filename and SHA-256 hash.
|
||||||
|
- Rights holder and display permission confirmed.
|
||||||
|
- Final derivative reviewed at readable size.
|
||||||
|
- OCR, barcode/QR, byte, container, and metadata checks passed.
|
||||||
|
- No browser chrome, account data, real health data, private branding, or identifying title frames.
|
||||||
|
- Video gameplay range and every materially changing frame reviewed.
|
||||||
|
- Health-related UI contains only fictional/demo information.
|
||||||
|
|
||||||
|
## Approval file shape
|
||||||
|
|
||||||
|
The private approval JSON must match `portfolio-asset-approvals/v1`. It is copied from the generated private `candidate-approval.json` only after visual review. Any later byte change invalidates approval because the SHA-256 hash changes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": "portfolio-asset-approvals/v1",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"id": "generic-asset-id",
|
||||||
|
"files": [
|
||||||
|
{ "fileName": "project-generic-output.webp", "sha256": "64-lowercase-hex-characters" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The preparation command stages candidates and creates a private contact sheet. It publishes nothing until this exact hash-bound register is supplied. The public manifest and prepared asset directory must remain empty while approval is pending.
|
||||||
2153
package-lock.json
generated
2153
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@
|
|||||||
"build:ssr": "vite build --ssr src/entry-server.ts --outDir dist-ssr",
|
"build:ssr": "vite build --ssr src/entry-server.ts --outDir dist-ssr",
|
||||||
"prerender": "node scripts/prerender.mjs",
|
"prerender": "node scripts/prerender.mjs",
|
||||||
"verify:artifacts": "node scripts/verify-artifacts.mjs",
|
"verify:artifacts": "node scripts/verify-artifacts.mjs",
|
||||||
|
"prepare:assets": "node scripts/prepare-assets.mjs",
|
||||||
|
"verify:assets": "node scripts/prepare-assets.mjs --verify",
|
||||||
"prepare:preview-assets": "node scripts/materialize-empty-asset-set.mjs",
|
"prepare:preview-assets": "node scripts/materialize-empty-asset-set.mjs",
|
||||||
"build:preview": "npm run prepare:preview-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json"
|
"build:preview": "npm run prepare:preview-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json"
|
||||||
},
|
},
|
||||||
@@ -32,9 +34,14 @@
|
|||||||
"@vitejs/plugin-vue": "^6.0.8",
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"@vue/server-renderer": "^3.5.42",
|
"@vue/server-renderer": "^3.5.42",
|
||||||
"@vue/test-utils": "^2.5.0",
|
"@vue/test-utils": "^2.5.0",
|
||||||
|
"@zxing/library": "^0.21.3",
|
||||||
"eslint": "^10.9.1",
|
"eslint": "^10.9.1",
|
||||||
|
"file-type": "^22.0.2",
|
||||||
"happy-dom": "^20.12.0",
|
"happy-dom": "^20.12.0",
|
||||||
|
"lighthouse": "^13.4.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.2.2",
|
"vite": "^8.2.2",
|
||||||
"vitest": "^4.1.11",
|
"vitest": "^4.1.11",
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
265
scripts/prepare-assets.mjs
Normal file
265
scripts/prepare-assets.mjs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, dirname, extname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { fileTypeFromFile } from 'file-type';
|
||||||
|
|
||||||
|
import { hashFile } from './lib/hash-file.mjs';
|
||||||
|
import { loadPrivateMapping, loadRecipes, readJson, resolvePrivateSource } from './lib/asset-recipes.mjs';
|
||||||
|
import { loadDenylist, scanBinary, scanImage } from './lib/privacy-scan.mjs';
|
||||||
|
import { encodeVideoDerivatives, extractVideoFrames, probeVideo } from './lib/video-frames.mjs';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const fail = (rule) => { throw new Error(rule); };
|
||||||
|
|
||||||
|
const assertSafeOutputRoot = (path, rule) => {
|
||||||
|
const resolved = resolve(path);
|
||||||
|
const permittedRepositoryOutput = join(repositoryRoot, 'inputs', 'prepared-assets');
|
||||||
|
if (resolved !== permittedRepositoryOutput && !resolved.startsWith('/tmp/')) fail(rule);
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDirectory = async (path, rule) => {
|
||||||
|
const safe = assertSafeOutputRoot(path, rule);
|
||||||
|
await rm(safe, { recursive: true, force: true });
|
||||||
|
await mkdir(safe, { recursive: true });
|
||||||
|
return safe;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = (items) => [...items].sort((left, right) => left.fileName.localeCompare(right.fileName, 'en-US'));
|
||||||
|
|
||||||
|
const candidate = async ({ id, fileName, path, mediaType, width, height, durationMs, recipe }) => ({
|
||||||
|
id,
|
||||||
|
fileName,
|
||||||
|
sha256: await hashFile(path),
|
||||||
|
mediaType,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
durationMs,
|
||||||
|
rightsHolder: recipe.rightsHolder,
|
||||||
|
license: recipe.license,
|
||||||
|
attribution: recipe.attribution,
|
||||||
|
});
|
||||||
|
|
||||||
|
const encodeImage = async (source, output, recipe) => {
|
||||||
|
let detected;
|
||||||
|
try { detected = await fileTypeFromFile(source); } catch { fail('ASSET-TYPE-001'); }
|
||||||
|
if (!detected || !['image/jpeg', 'image/png', 'image/webp'].includes(detected.mime)) fail('ASSET-TYPE-001');
|
||||||
|
try {
|
||||||
|
let pipeline = sharp(source, { failOn: 'error', limitInputPixels: 50_000_000, sequentialRead: true })
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: recipe.maxWidth, withoutEnlargement: true, fit: 'inside', kernel: sharp.kernel.lanczos3 });
|
||||||
|
pipeline = recipe.output.endsWith('.png')
|
||||||
|
? pipeline.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
||||||
|
: pipeline.webp({ lossless: true, effort: 6, smartSubsample: false });
|
||||||
|
await pipeline.toFile(output);
|
||||||
|
} catch {
|
||||||
|
fail('ASSET-DECODE-001');
|
||||||
|
}
|
||||||
|
const details = await scanImage(output, [], { runOcr: false });
|
||||||
|
return details;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareImage = async ({ source, recipe, stagingRoot, denylist }) => {
|
||||||
|
const path = join(stagingRoot, recipe.output);
|
||||||
|
const details = await encodeImage(source, path, recipe);
|
||||||
|
await scanImage(path, denylist, { runOcr: true });
|
||||||
|
return [await candidate({
|
||||||
|
id: recipe.id, fileName: recipe.output, path, mediaType: details.mediaType,
|
||||||
|
width: details.width, height: details.height, durationMs: null, recipe,
|
||||||
|
})];
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareVideo = async ({ source, recipe, stagingRoot, reportDir, denylist }) => {
|
||||||
|
const sourceProbe = await probeVideo(source);
|
||||||
|
if (recipe.startSeconds + recipe.durationSeconds > sourceProbe.duration + 0.05) fail('ASSET-VIDEO-RANGE-001');
|
||||||
|
const base = recipe.output.slice(0, -'.webm'.length);
|
||||||
|
const webmPath = join(stagingRoot, `${base}.webm`);
|
||||||
|
const mp4Path = join(stagingRoot, `${base}.mp4`);
|
||||||
|
const posterPath = join(stagingRoot, `${base}-poster.webp`);
|
||||||
|
await encodeVideoDerivatives({
|
||||||
|
source, webmPath, mp4Path, posterPath,
|
||||||
|
startSeconds: recipe.startSeconds, durationSeconds: recipe.durationSeconds, maxWidth: recipe.maxWidth,
|
||||||
|
});
|
||||||
|
const webmProbe = await probeVideo(webmPath, { allowAudio: false });
|
||||||
|
const mp4Probe = await probeVideo(mp4Path, { allowAudio: false });
|
||||||
|
const frames = await extractVideoFrames({
|
||||||
|
source: webmPath,
|
||||||
|
durationSeconds: Math.min(webmProbe.duration, recipe.durationSeconds),
|
||||||
|
outputDir: join(reportDir, 'video-frames'),
|
||||||
|
prefix: recipe.id,
|
||||||
|
});
|
||||||
|
await scanBinary(webmPath, denylist);
|
||||||
|
await scanBinary(mp4Path, denylist);
|
||||||
|
await scanImage(posterPath, denylist, { runOcr: true });
|
||||||
|
for (const frame of frames) await scanImage(frame, denylist, { runOcr: true });
|
||||||
|
const poster = await sharp(posterPath).metadata();
|
||||||
|
return [
|
||||||
|
await candidate({
|
||||||
|
id: recipe.id, fileName: `${base}.webm`, path: webmPath, mediaType: 'video/webm',
|
||||||
|
width: webmProbe.width, height: webmProbe.height, durationMs: Math.round(webmProbe.duration * 1000), recipe,
|
||||||
|
}),
|
||||||
|
await candidate({
|
||||||
|
id: `${recipe.id}-mp4`, fileName: `${base}.mp4`, path: mp4Path, mediaType: 'video/mp4',
|
||||||
|
width: mp4Probe.width, height: mp4Probe.height, durationMs: Math.round(mp4Probe.duration * 1000), recipe,
|
||||||
|
}),
|
||||||
|
await candidate({
|
||||||
|
id: `${recipe.id}-poster`, fileName: `${base}-poster.webp`, path: posterPath, mediaType: 'image/webp',
|
||||||
|
width: poster.width, height: poster.height, durationMs: null, recipe,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const createContactSheet = async (candidates, stagingRoot, reportDir) => {
|
||||||
|
const images = candidates.filter(({ mediaType }) => mediaType.startsWith('image/')).map((image) => ({
|
||||||
|
id: image.id,
|
||||||
|
path: join(stagingRoot, image.fileName),
|
||||||
|
}));
|
||||||
|
let frameNames = [];
|
||||||
|
try { frameNames = (await readdir(join(reportDir, 'video-frames'))).filter((name) => name.endsWith('.png')).sort(); } catch {}
|
||||||
|
images.push(...frameNames.map((name, index) => ({
|
||||||
|
id: `private-game-loop-frame-${String(index + 1).padStart(2, '0')}`,
|
||||||
|
path: join(reportDir, 'video-frames', name),
|
||||||
|
})));
|
||||||
|
if (images.length === 0) return;
|
||||||
|
const imageWidth = 1100;
|
||||||
|
const width = 1200;
|
||||||
|
const composites = [];
|
||||||
|
let y = 30;
|
||||||
|
for (const image of images) {
|
||||||
|
const thumb = await sharp(image.path)
|
||||||
|
.resize({ width: imageWidth, height: 1400, fit: 'inside', withoutEnlargement: false })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const metadata = await sharp(thumb).metadata();
|
||||||
|
const left = Math.round((width - (metadata.width ?? imageWidth)) / 2);
|
||||||
|
composites.push({ input: thumb, left, top: y });
|
||||||
|
const safeLabel = image.id.replace(/[^a-z0-9-]/gu, '');
|
||||||
|
const label = Buffer.from(`<svg width="1100" height="54" xmlns="http://www.w3.org/2000/svg"><rect width="1100" height="54" fill="#11131a"/><text x="16" y="36" font-size="24" font-family="sans-serif" fill="#f4f2f7">${safeLabel}</text></svg>`);
|
||||||
|
y += metadata.height ?? 0;
|
||||||
|
composites.push({ input: label, left: 50, top: y + 8 });
|
||||||
|
y += 86;
|
||||||
|
}
|
||||||
|
await sharp({ create: { width, height: y + 20, channels: 3, background: '#070910' } })
|
||||||
|
.composite(composites)
|
||||||
|
.webp({ lossless: true, effort: 6 })
|
||||||
|
.toFile(join(reportDir, 'contact-sheet.webp'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvalProjection = (recipes, candidates) => ({
|
||||||
|
schema: 'portfolio-asset-approvals/v1',
|
||||||
|
assets: recipes.map(({ id }) => ({
|
||||||
|
id,
|
||||||
|
files: sorted(candidates.filter((item) => item.id === id || item.id.startsWith(`${id}-`)))
|
||||||
|
.map(({ fileName, sha256 }) => ({ fileName, sha256 })),
|
||||||
|
})).sort((left, right) => left.id.localeCompare(right.id, 'en-US')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const approvalsMatch = async (path, expected) => {
|
||||||
|
const actual = await readJson(path, 'ASSET-APPROVAL-001');
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) fail('ASSET-APPROVAL-002');
|
||||||
|
};
|
||||||
|
|
||||||
|
const publish = async ({ candidates, stagingRoot, publishRoot, manifestPath }) => {
|
||||||
|
if (candidates.some((item) => /pending|requires confirmation/iu.test(`${item.rightsHolder} ${item.license}`))) {
|
||||||
|
fail('ASSET-RIGHTS-001');
|
||||||
|
}
|
||||||
|
const safePublishRoot = await resetDirectory(publishRoot, 'ASSET-PUBLISH-001');
|
||||||
|
for (const item of candidates) await copyFile(join(stagingRoot, item.fileName), join(safePublishRoot, item.fileName));
|
||||||
|
await mkdir(dirname(manifestPath), { recursive: true });
|
||||||
|
await writeFile(manifestPath, `${JSON.stringify({ assets: candidates }, null, 2)}\n`, 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prepareAssets = async (options) => {
|
||||||
|
const required = ['sourceRoot', 'mappingPath', 'denylistPath', 'recipesPath', 'stagingRoot', 'reportDir'];
|
||||||
|
if (!options || required.some((key) => typeof options[key] !== 'string')) fail('ASSET-ARGUMENT-001');
|
||||||
|
const recipes = await loadRecipes(options.recipesPath);
|
||||||
|
const mapping = await loadPrivateMapping(options.mappingPath, recipes);
|
||||||
|
const denylist = await loadDenylist(options.denylistPath);
|
||||||
|
const stagingRoot = await resetDirectory(options.stagingRoot, 'ASSET-STAGING-001');
|
||||||
|
const reportDir = await resetDirectory(options.reportDir, 'ASSET-REPORT-001');
|
||||||
|
const candidates = [];
|
||||||
|
for (const recipe of recipes) {
|
||||||
|
const source = await resolvePrivateSource(options.sourceRoot, mapping[recipe.id]);
|
||||||
|
const prepared = recipe.kind === 'image'
|
||||||
|
? await prepareImage({ source, recipe, stagingRoot, denylist })
|
||||||
|
: await prepareVideo({ source, recipe, stagingRoot, reportDir, denylist });
|
||||||
|
candidates.push(...prepared);
|
||||||
|
}
|
||||||
|
const finalCandidates = sorted(candidates);
|
||||||
|
const approval = approvalProjection(recipes, finalCandidates);
|
||||||
|
await writeFile(join(reportDir, 'candidate-approval.json'), `${JSON.stringify(approval, null, 2)}\n`, 'utf8');
|
||||||
|
await writeFile(join(reportDir, 'scan-report.json'), `${JSON.stringify({
|
||||||
|
schema: 'portfolio-private-scan-report/v1', automatedChecks: 'passed', publication: 'pending-manual-approval',
|
||||||
|
assets: finalCandidates.map(({ id, fileName, sha256 }) => ({ id, fileName, sha256 })),
|
||||||
|
}, null, 2)}\n`, 'utf8');
|
||||||
|
await createContactSheet(finalCandidates, stagingRoot, reportDir);
|
||||||
|
let approvalStatus = 'pending';
|
||||||
|
if (options.approvalRegisterPath !== undefined) {
|
||||||
|
if (typeof options.publishRoot !== 'string' || typeof options.manifestPath !== 'string') fail('ASSET-ARGUMENT-001');
|
||||||
|
await approvalsMatch(options.approvalRegisterPath, approval);
|
||||||
|
await publish({ candidates: finalCandidates, stagingRoot, publishRoot: options.publishRoot, manifestPath: options.manifestPath });
|
||||||
|
approvalStatus = 'approved';
|
||||||
|
}
|
||||||
|
return { candidates: finalCandidates, approvalStatus, reportDir };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyPreparedAssets = async ({ publishRoot, manifestPath, denylistPath }) => {
|
||||||
|
if (![publishRoot, manifestPath, denylistPath].every((value) => typeof value === 'string')) fail('ASSET-ARGUMENT-001');
|
||||||
|
const manifest = await readJson(manifestPath, 'ASSET-MANIFEST-001');
|
||||||
|
if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.assets)) fail('ASSET-MANIFEST-001');
|
||||||
|
const expected = manifest.assets.map(({ fileName }) => fileName).sort();
|
||||||
|
const actual = (await readdir(publishRoot, { withFileTypes: true }))
|
||||||
|
.map((entry) => {
|
||||||
|
if (!entry.isFile()) fail('ASSET-EXACT-001');
|
||||||
|
return entry.name;
|
||||||
|
}).sort();
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) fail('ASSET-EXACT-001');
|
||||||
|
const denylist = await loadDenylist(denylistPath);
|
||||||
|
for (const item of manifest.assets) {
|
||||||
|
if (!item || typeof item.fileName !== 'string' || !/^project-[a-z0-9-]+\.(webp|png|webm|mp4)$/u.test(item.fileName)) fail('ASSET-MANIFEST-001');
|
||||||
|
const path = join(publishRoot, item.fileName);
|
||||||
|
if ((await stat(path)).nlink !== 1) fail('ASSET-EXACT-001');
|
||||||
|
if (await hashFile(path) !== item.sha256) fail('ASSET-HASH-001');
|
||||||
|
if (item.mediaType.startsWith('image/')) await scanImage(path, denylist, { runOcr: true });
|
||||||
|
else await scanBinary(path, denylist);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseArgs = (argv) => {
|
||||||
|
const values = {};
|
||||||
|
let verify = false;
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const key = argv[index];
|
||||||
|
if (key === '--verify') { verify = true; continue; }
|
||||||
|
if (!key?.startsWith('--') || index + 1 >= argv.length) fail('ASSET-ARGUMENT-001');
|
||||||
|
values[key.slice(2)] = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return { verify, values };
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCli = async () => {
|
||||||
|
const { verify, values } = parseArgs(process.argv.slice(2));
|
||||||
|
if (verify) {
|
||||||
|
await verifyPreparedAssets({ publishRoot: values['publish-root'], manifestPath: values.manifest, denylistPath: values.denylist });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prepareAssets({
|
||||||
|
sourceRoot: values['source-root'], mappingPath: values.mapping, denylistPath: values.denylist,
|
||||||
|
recipesPath: values.recipes, stagingRoot: values['staging-root'], reportDir: values['report-dir'],
|
||||||
|
approvalRegisterPath: values.approvals, publishRoot: values['publish-root'], manifestPath: values.manifest,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
runCli().catch((error) => {
|
||||||
|
const message = error instanceof Error && /^[A-Z]+(?:-[A-Z]+)*-\d{3}(?::[a-z0-9-]+)?$/u.test(error.message)
|
||||||
|
? error.message
|
||||||
|
: 'ASSET-UNEXPECTED-001';
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
1
tests/fixtures/assets/malformed.bin
vendored
Normal file
1
tests/fixtures/assets/malformed.bin
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
This is deliberately not a supported media container.
|
||||||
184
tests/integration/prepare-assets.test.ts
Normal file
184
tests/integration/prepare-assets.test.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { mkdtemp, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { BarcodeFormat, MultiFormatWriter } from '@zxing/library';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper.
|
||||||
|
import { hashFile } from '../../scripts/lib/hash-file.mjs';
|
||||||
|
// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper.
|
||||||
|
import { scanImage, scanText } from '../../scripts/lib/privacy-scan.mjs';
|
||||||
|
// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper.
|
||||||
|
import { prepareAssets, verifyPreparedAssets } from '../../scripts/prepare-assets.mjs';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
interface Candidate { id: string; fileName: string; sha256: string; }
|
||||||
|
|
||||||
|
const createRoot = async (): Promise<string> => mkdtemp(join(tmpdir(), 'portfolio-assets-test-'));
|
||||||
|
|
||||||
|
const writeJson = async (path: string, value: unknown): Promise<void> => {
|
||||||
|
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseRecipe = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'project-interface',
|
||||||
|
kind: 'image',
|
||||||
|
output: 'project-interface.webp',
|
||||||
|
maxWidth: 720,
|
||||||
|
rightsHolder: 'Portfolio owner',
|
||||||
|
license: 'All rights reserved',
|
||||||
|
attribution: 'Portfolio owner',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makePng = async (path: string, text = 'SAFE DEMO', includeMetadata = true): Promise<void> => {
|
||||||
|
const svg = Buffer.from(`<svg width="1000" height="600" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1000" height="600" fill="#f7f4f7"/>
|
||||||
|
<text x="80" y="320" font-size="96" font-family="sans-serif" fill="#16131a">${text}</text>
|
||||||
|
</svg>`);
|
||||||
|
const pipeline = sharp(svg).png();
|
||||||
|
if (includeMetadata) await pipeline.withMetadata({ orientation: 6 }).toFile(path);
|
||||||
|
else await pipeline.toFile(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeQrPng = async (path: string): Promise<void> => {
|
||||||
|
const matrix = new MultiFormatWriter().encode('https://private.invalid/account', BarcodeFormat.QR_CODE, 280, 280, new Map());
|
||||||
|
const pixels = Buffer.alloc(matrix.getWidth() * matrix.getHeight() * 3);
|
||||||
|
for (let y = 0; y < matrix.getHeight(); y += 1) {
|
||||||
|
for (let x = 0; x < matrix.getWidth(); x += 1) {
|
||||||
|
const value = matrix.get(x, y) ? 0 : 255;
|
||||||
|
const offset = (y * matrix.getWidth() + x) * 3;
|
||||||
|
pixels[offset] = value;
|
||||||
|
pixels[offset + 1] = value;
|
||||||
|
pixels[offset + 2] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sharp(pixels, { raw: { width: matrix.getWidth(), height: matrix.getHeight(), channels: 3 } }).png().toFile(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeVideo = async (path: string): Promise<void> => {
|
||||||
|
await execFileAsync('ffmpeg', [
|
||||||
|
'-hide_banner', '-loglevel', 'error', '-nostdin', '-y',
|
||||||
|
'-f', 'lavfi', '-i', 'color=c=red:s=640x360:d=1:r=24',
|
||||||
|
'-f', 'lavfi', '-i', 'color=c=blue:s=640x360:d=2:r=24',
|
||||||
|
'-filter_complex', '[0:v][1:v]concat=n=2:v=1:a=0[outv]',
|
||||||
|
'-map', '[outv]', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-threads', '1', path,
|
||||||
|
], { timeout: 20_000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeInputs = async (root: string, recipe: Record<string, unknown>, sourceName = 'source.png') => {
|
||||||
|
const sourceRoot = join(root, 'source');
|
||||||
|
const reportDir = join(root, 'private-report');
|
||||||
|
const stagingRoot = join(root, 'staging');
|
||||||
|
await mkdir(sourceRoot, { recursive: true });
|
||||||
|
const recipesPath = join(root, 'recipes.json');
|
||||||
|
const mappingPath = join(root, 'mapping.json');
|
||||||
|
const denylistPath = join(root, 'denylist.txt');
|
||||||
|
await writeJson(recipesPath, { schema: 'portfolio-asset-recipes/v1', assets: [recipe] });
|
||||||
|
await writeJson(mappingPath, { schema: 'portfolio-private-source-map/v1', sources: { [String(recipe.id)]: sourceName } });
|
||||||
|
await writeFile(denylistPath, 'SECRET CANARY\nprivate-source-name\n', 'utf8');
|
||||||
|
return { sourceRoot, reportDir, stagingRoot, recipesPath, mappingPath, denylistPath };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('deterministic asset preparation', () => {
|
||||||
|
it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => {
|
||||||
|
const root = await createRoot();
|
||||||
|
const inputs = await makeInputs(root, baseRecipe());
|
||||||
|
await makePng(join(inputs.sourceRoot, 'source.png'));
|
||||||
|
|
||||||
|
const first = await prepareAssets(inputs);
|
||||||
|
const firstFile = first.candidates[0];
|
||||||
|
expect(firstFile?.fileName).toMatch(/^project-[a-z0-9-]+\.[a-z0-9]+$/);
|
||||||
|
expect(first.approvalStatus).toBe('pending');
|
||||||
|
expect(await stat(join(inputs.stagingRoot, firstFile!.fileName))).toBeDefined();
|
||||||
|
const firstHash = await hashFile(join(inputs.stagingRoot, firstFile!.fileName));
|
||||||
|
const metadata = await sharp(join(inputs.stagingRoot, firstFile!.fileName)).metadata();
|
||||||
|
expect(metadata.orientation).toBeUndefined();
|
||||||
|
expect(metadata.exif).toBeUndefined();
|
||||||
|
expect(metadata.icc).toBeUndefined();
|
||||||
|
|
||||||
|
const second = await prepareAssets({ ...inputs, stagingRoot: join(root, 'staging-two'), reportDir: join(root, 'report-two') });
|
||||||
|
expect(await hashFile(join(root, 'staging-two', second.candidates[0]!.fileName))).toBe(firstHash);
|
||||||
|
expect(await stat(join(inputs.reportDir, 'contact-sheet.webp'))).toBeDefined();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('returns only opaque rule identifiers for OCR and byte denylist matches', async () => {
|
||||||
|
const root = await createRoot();
|
||||||
|
const secretCanary = 'SECRET CANARY';
|
||||||
|
const imagePath = join(root, 'secret.png');
|
||||||
|
await makePng(imagePath, secretCanary, false);
|
||||||
|
|
||||||
|
expect(() => scanText(Buffer.from(`prefix ${secretCanary} suffix`), [secretCanary])).toThrowError(/PRIVACY-BYTES-001/u);
|
||||||
|
try {
|
||||||
|
await scanImage(imagePath, [secretCanary], { runOcr: true });
|
||||||
|
throw new Error('expected privacy scan to fail');
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
expect(message).toMatch(/PRIVACY-(OCR|BYTES)-001/u);
|
||||||
|
expect(message).not.toContain(secretCanary);
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('rejects QR/barcodes and malformed media closed', async () => {
|
||||||
|
const root = await createRoot();
|
||||||
|
const qrPath = join(root, 'qr.png');
|
||||||
|
await makeQrPng(qrPath);
|
||||||
|
await expect(scanImage(qrPath, [], { runOcr: false })).rejects.toThrow(/PRIVACY-BARCODE-001/u);
|
||||||
|
|
||||||
|
const inputs = await makeInputs(root, baseRecipe(), 'broken.png');
|
||||||
|
await writeFile(join(inputs.sourceRoot, 'broken.png'), 'not an image', 'utf8');
|
||||||
|
await expect(prepareAssets(inputs)).rejects.toThrow(/ASSET-(TYPE|DECODE)-001/u);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it('trims title frames, strips audio, samples video, and emits WebM, MP4, and a poster', async () => {
|
||||||
|
const root = await createRoot();
|
||||||
|
const recipe = baseRecipe({
|
||||||
|
id: 'private-game-loop', kind: 'video', output: 'project-private-game-loop.webm',
|
||||||
|
startSeconds: 1.1, durationSeconds: 1.5, maxWidth: 640,
|
||||||
|
});
|
||||||
|
const inputs = await makeInputs(root, recipe, 'clip.mp4');
|
||||||
|
await makeVideo(join(inputs.sourceRoot, 'clip.mp4'));
|
||||||
|
|
||||||
|
const result = await prepareAssets(inputs);
|
||||||
|
expect(result.candidates.map((candidate: Candidate) => candidate.fileName).sort()).toEqual([
|
||||||
|
'project-private-game-loop-poster.webp',
|
||||||
|
'project-private-game-loop.mp4',
|
||||||
|
'project-private-game-loop.webm',
|
||||||
|
]);
|
||||||
|
const poster = await sharp(join(inputs.stagingRoot, 'project-private-game-loop-poster.webp')).stats();
|
||||||
|
expect(poster.channels[2]!.mean).toBeGreaterThan(poster.channels[0]!.mean * 2);
|
||||||
|
const sampled = await readdir(join(inputs.reportDir, 'video-frames'));
|
||||||
|
expect(sampled.length).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
const second = await prepareAssets({
|
||||||
|
...inputs,
|
||||||
|
stagingRoot: join(root, 'video-staging-two'),
|
||||||
|
reportDir: join(root, 'video-report-two'),
|
||||||
|
});
|
||||||
|
expect(second.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })))
|
||||||
|
.toEqual(result.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })));
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('publishes only when an approval register exactly matches every final hash and rejects extras', async () => {
|
||||||
|
const root = await createRoot();
|
||||||
|
const inputs = await makeInputs(root, baseRecipe());
|
||||||
|
await makePng(join(inputs.sourceRoot, 'source.png'));
|
||||||
|
const pending = await prepareAssets(inputs);
|
||||||
|
const approvalPath = join(root, 'approval.json');
|
||||||
|
await writeJson(approvalPath, {
|
||||||
|
schema: 'portfolio-asset-approvals/v1',
|
||||||
|
assets: [{ id: 'project-interface', files: pending.candidates.map(({ fileName, sha256 }: Candidate) => ({ fileName, sha256 })) }],
|
||||||
|
});
|
||||||
|
const publishRoot = join(root, 'published');
|
||||||
|
const manifestPath = join(root, 'asset-manifest.json');
|
||||||
|
const approved = await prepareAssets({ ...inputs, approvalRegisterPath: approvalPath, publishRoot, manifestPath });
|
||||||
|
expect(approved.approvalStatus).toBe('approved');
|
||||||
|
expect(JSON.parse(await readFile(manifestPath, 'utf8')).assets).toHaveLength(1);
|
||||||
|
await writeFile(join(publishRoot, 'project-unexpected.webp'), 'extra', 'utf8');
|
||||||
|
await expect(verifyPreparedAssets({ publishRoot, manifestPath, denylistPath: inputs.denylistPath })).rejects.toThrow(/ASSET-EXACT-001/u);
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user