feat(overview-artifact): 產生可預覽總覽與截圖 fallback

This commit is contained in:
2026-09-18 15:59:34 +08:00
parent c545f11ec1
commit 867a23497f
16 changed files with 605 additions and 324 deletions
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* Capture an overview using an available non-managed backend.
* Managed browser preview remains the preferred platform-level path; this CLI handles
* Firefox and deterministic SVG rasterization when those binaries are installed.
*/
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { ScriptError, main, parseFlags } from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['output'],
optional: ['url', 'svg', 'backend', 'width', 'height'],
});
const output = resolve(flags.output);
mkdirSync(dirname(output), { recursive: true });
const backend = chooseBackend(flags.backend, flags.url, flags.svg);
if (backend === 'firefox') captureFirefox(flags.url, output, flags.width, flags.height);
else captureSvg(flags.svg, output, flags.width, flags.height);
return { backend, output };
});
function chooseBackend(requested, url, svg) {
if (requested && requested !== 'auto' && requested !== 'firefox' && requested !== 'svg') {
throw new ScriptError('BACKEND_UNKNOWN', `不支援的 capture backend:${requested}`);
}
if (requested === 'firefox') {
requireBinary('firefox');
if (!url) throw new ScriptError('URL_REQUIRED', 'Firefox backend 需要 --url');
return 'firefox';
}
if (requested === 'svg') {
requireSvgInput(svg);
requireAnyBinary(['rsvg-convert', 'resvg']);
return 'svg';
}
if (url && hasBinary('firefox')) return 'firefox';
if (svg && (hasBinary('rsvg-convert') || hasBinary('resvg'))) return 'svg';
throw new ScriptError('NO_CAPTURE_BACKEND', '沒有可用的 Firefox 或 SVG rasterizer;請使用平台 preview,或安裝 firefox、rsvg-convert、resvg');
}
function captureFirefox(url, output, width, height) {
const args = ['--headless'];
if (width) args.push('--window-size', `${width}${height ? `,${height}` : ''}`);
args.push('--screenshot', output, url);
const result = spawnSync('firefox', args, { encoding: 'utf8' });
if (result.status !== 0 || !existsSync(output)) throw new ScriptError('CAPTURE_FAILED', `Firefox 截圖失敗:${result.stderr || result.stdout || '沒有輸出檔案'}`);
}
function captureSvg(svg, output, width, height) {
requireSvgInput(svg);
if (hasBinary('rsvg-convert')) {
const args = [`--output=${output}`];
if (width) args.push(`--width=${width}`);
if (height) args.push(`--height=${height}`);
args.push(resolve(svg));
const result = spawnSync('rsvg-convert', args, { encoding: 'utf8' });
if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `rsvg-convert 失敗:${result.stderr || result.stdout}`);
} else {
const args = [];
if (width) args.push('-w', String(width));
args.push(resolve(svg), output);
const result = spawnSync('resvg', args, { encoding: 'utf8' });
if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `resvg 失敗:${result.stderr || result.stdout}`);
}
if (!existsSync(output)) throw new ScriptError('CAPTURE_FAILED', 'rasterizer 沒有產生輸出檔案');
}
function requireSvgInput(svg) {
if (!svg || !existsSync(resolve(svg))) throw new ScriptError('SVG_REQUIRED', 'SVG backend 需要存在的 --svg');
}
function requireBinary(binary) { if (!hasBinary(binary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binary}`); }
function requireAnyBinary(binaries) { if (!binaries.some(hasBinary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binaries.join(' 或 ')}`); }
function hasBinary(binary) { try { execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' }); return true; } catch { return false; } }