- lib/media/exportFrame.ts:隐藏 video 取片尾前约 1 帧,黑帧/空帧逐级回退(≤1s), 原生尺寸 canvas 导出 PNG;加载/损坏/404/403/跨域/超时/无法定位结尾分类报错 - lib/media/lastFrame.ts:下载 + 中文 Toast 反馈 - Inspector.svelte 操作区、ImageLightbox.svelte 视频工具栏加「导出尾帧」 - ToastHost 层级提到灯箱之上,弹层内反馈可见 - 七语言文案;npm test 新增 check-export-frame;e2e/lastframe.mjs 浏览器验收 Co-authored-by: multica-agent <github@multica.ai>
157 lines
4.3 KiB
JavaScript
157 lines
4.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Pure-logic checks for 导出尾帧 (lib/media/exportFrame.ts): which media
|
|
* times get tried near the end, how the PNG is named, and when a sampled
|
|
* frame counts as black / empty. The decode + download path itself runs in
|
|
* a real browser (e2e/lastframe.mjs).
|
|
*
|
|
* Run with `npm test` (or `node scripts/check-export-frame.mjs`).
|
|
*/
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const MODULE = join(HERE, "..", "src", "lib", "media", "exportFrame.ts");
|
|
|
|
const {
|
|
tailSeekTimes,
|
|
videoBaseName,
|
|
frameFileName,
|
|
blankKind,
|
|
statusErrorCode,
|
|
TAIL_OFFSETS,
|
|
} = await import(pathToFileURL(MODULE).href);
|
|
|
|
let failures = 0;
|
|
function check(name, cond, extra = "") {
|
|
if (cond) console.log(`ok ${name}`);
|
|
else {
|
|
failures += 1;
|
|
console.error(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
|
|
}
|
|
}
|
|
|
|
// ---- seek targets -------------------------------------------------------
|
|
|
|
const t6 = tailSeekTimes(6.04);
|
|
check(
|
|
"first try is ~1 frame before the end, never the end itself",
|
|
t6[0] < 6.04 && 6.04 - t6[0] <= 0.05 + 1e-9,
|
|
JSON.stringify(t6),
|
|
);
|
|
check(
|
|
"later tries step strictly further back",
|
|
t6.every((t, i) => i === 0 || t < t6[i - 1]),
|
|
JSON.stringify(t6),
|
|
);
|
|
check(
|
|
"tries stay within ~1s of the end",
|
|
t6.length === TAIL_OFFSETS.length && 6.04 - t6[t6.length - 1] <= 1 + 1e-9,
|
|
);
|
|
check(
|
|
"short clip clamps to 0 without duplicates",
|
|
JSON.stringify(tailSeekTimes(0.1)) === "[0.05,0]",
|
|
);
|
|
check(
|
|
"unknown / zero duration falls back to t=0",
|
|
JSON.stringify(tailSeekTimes(NaN)) === "[0]" &&
|
|
JSON.stringify(tailSeekTimes(0)) === "[0]",
|
|
);
|
|
|
|
// ---- file names ---------------------------------------------------------
|
|
|
|
const api =
|
|
"/api/file?path=" +
|
|
encodeURIComponent(
|
|
"/opt/aigc-home/data/assets/1/video/05_ref2va_4step_sla_00035_.mp4",
|
|
);
|
|
check(
|
|
"base name from /api/file?path=…",
|
|
videoBaseName(api) === "05_ref2va_4step_sla_00035_",
|
|
);
|
|
check(
|
|
"base name from a Windows path",
|
|
videoBaseName(
|
|
"/api/file?path=" +
|
|
encodeURIComponent("D:\\calliope\\assets\\镜头 01.webm"),
|
|
) === "镜头 01",
|
|
);
|
|
check(
|
|
"base name from a plain URL",
|
|
videoBaseName("http://x.test/media/clip-9.mov?x=1") === "clip-9",
|
|
);
|
|
check(
|
|
"unsafe characters are replaced",
|
|
!/[:*?"<>|]/.test(
|
|
videoBaseName("/api/file?path=" + encodeURIComponent('a:b*c?"d<e>f|g.mp4')),
|
|
),
|
|
);
|
|
check(
|
|
"blob URL without a name falls back to 'video'",
|
|
videoBaseName("blob:http://x.test/") === "video",
|
|
);
|
|
const fname = frameFileName(api, 5.958, "尾帧");
|
|
check(
|
|
"PNG name names the video, the tag and the frame time",
|
|
fname === "05_ref2va_4step_sla_00035__尾帧_5.958s.png",
|
|
fname,
|
|
);
|
|
check(
|
|
"spaces in the tag become dashes",
|
|
frameFileName(api, 1, "last frame").includes("_last-frame_1.000s.png"),
|
|
);
|
|
|
|
// ---- black / empty detection -------------------------------------------
|
|
|
|
function frame(n, rgba) {
|
|
const out = new Uint8ClampedArray(n * 4);
|
|
for (let i = 0; i < n; i++) out.set(rgba(i), i * 4);
|
|
return out;
|
|
}
|
|
const N = 64 * 64;
|
|
check(
|
|
"fully transparent sample → empty",
|
|
blankKind(frame(N, () => [0, 0, 0, 0])) === "empty",
|
|
);
|
|
check(
|
|
"pure black → black",
|
|
blankKind(frame(N, () => [0, 0, 0, 255])) === "black",
|
|
);
|
|
check(
|
|
"near-black encoder noise (16..18) → black",
|
|
blankKind(frame(N, (i) => [16 + (i % 3), 16, 16, 255])) === "black",
|
|
);
|
|
check(
|
|
"mid-grey picture → not blank",
|
|
blankKind(frame(N, () => [128, 128, 128, 255])) === null,
|
|
);
|
|
check(
|
|
"dim but visible picture (≈50) → not blank",
|
|
blankKind(frame(N, () => [50, 48, 52, 255])) === null,
|
|
);
|
|
check(
|
|
"dark night scene with a bright highlight → not blank",
|
|
blankKind(
|
|
frame(N, (i) => (i === 100 ? [250, 240, 200, 255] : [4, 4, 6, 255])),
|
|
) === null,
|
|
);
|
|
|
|
// ---- HTTP status classification ----------------------------------------
|
|
|
|
check("404 → not-found", statusErrorCode(404) === "not-found");
|
|
check(
|
|
"403 → forbidden",
|
|
statusErrorCode(403) === "forbidden" && statusErrorCode(401) === "forbidden",
|
|
);
|
|
check("500 → network", statusErrorCode(500) === "network");
|
|
check(
|
|
"206 / 200 are not errors",
|
|
statusErrorCode(206) === null && statusErrorCode(200) === null,
|
|
);
|
|
|
|
if (failures) {
|
|
console.error(`\n${failures} export-frame check(s) failed.`);
|
|
process.exit(1);
|
|
}
|
|
console.log("export-frame checks passed.");
|