Compare commits

...
Author SHA1 Message Date
总工 7bd8c29faa HEL-670: merge last-frame export (feat/hel-669-last-frame) 2026-10-01 23:19:08 +08:00
施工员andmultica-agent 4f3293fc02 feat(media): 视频一键导出尾帧 PNG(生成结果页 + 共用播放弹层)(HEL-669)
- 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>
2026-10-01 23:12:34 +08:00
总工 4c87c6dea0 HEL-664: merge F-batch remaining pages & site-wide polish (feat/hel-656-f-pages) 2026-09-30 17:08:05 +08:00
16 changed files with 1193 additions and 4 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs", "test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write ." "format": "prettier --write ."
}, },
+156
View File
@@ -0,0 +1,156 @@
#!/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.");
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import SafeMedia from '$lib/components/SafeMedia.svelte'; import SafeMedia from '$lib/components/SafeMedia.svelte';
import Icon from '$lib/components/ui/Icon.svelte'; import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { t } from '$lib/i18n.svelte'; import { t } from '$lib/i18n.svelte';
import { exportLastFrame } from '$lib/media/lastFrame';
interface Props { interface Props {
src: string | null; src: string | null;
@@ -61,6 +63,17 @@
} }
return alt ? `${alt.trim().replace(/\s+/g, '-').toLowerCase()}.png` : 'media'; return alt ? `${alt.trim().replace(/\s+/g, '-').toLowerCase()}.png` : 'media';
}); });
let frameBusy = $state(false);
async function exportTail() {
if (!src || frameBusy) return;
frameBusy = true;
try {
await exportLastFrame(src);
} finally {
frameBusy = false;
}
}
</script> </script>
<svelte:window onkeydown={src ? onKeydown : undefined} /> <svelte:window onkeydown={src ? onKeydown : undefined} />
@@ -90,6 +103,26 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
<Icon name="zoom-in" size={17} /> <Icon name="zoom-in" size={17} />
</button> </button>
{/if} {/if}
{#if isVideo}
<button
class="tool"
type="button"
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
title={frameBusy ? t('lastFrame.exporting') : `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
disabled={frameBusy}
aria-busy={frameBusy}
onclick={(e) => {
e.stopPropagation();
exportTail();
}}
>
{#if frameBusy}
<Spinner size="sm" />
{:else}
<Icon name="image" size={17} />
{/if}
</button>
{/if}
<a <a
class="tool" class="tool"
href={src} href={src}
@@ -62,7 +62,8 @@
position: fixed; position: fixed;
right: 24px; right: 24px;
bottom: 24px; bottom: 24px;
z-index: 1000; /* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
z-index: 10001;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@@ -8,6 +8,7 @@
import { parseDbTs } from '$lib/dbtime'; import { parseDbTs } from '$lib/dbtime';
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte'; import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte'; import { t } from '$lib/i18n.svelte';
import { exportLastFrame } from '$lib/media/lastFrame';
import { usageLabel } from './schema'; import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf, specOf } from './takes'; import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf, specOf } from './takes';
@@ -129,6 +130,18 @@
} }
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`); const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
let frameBusy = $state(false);
async function exportTail() {
const src = assetUrl(output);
if (!src || frameBusy) return;
frameBusy = true;
try {
await exportLastFrame(src);
} finally {
frameBusy = false;
}
}
</script> </script>
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}> <aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
@@ -240,6 +253,18 @@
{#if output} {#if output}
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a> <a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if} {/if}
{#if output && video}
<button
type="button"
class="a-btn secondary sm"
title={t('lastFrame.hint')}
disabled={frameBusy}
aria-busy={frameBusy}
onclick={exportTail}
>
{frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
</button>
{/if}
{#if st.tone === 'fail'} {#if st.tone === 'fail'}
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button> <button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
{/if} {/if}
+19
View File
@@ -221,6 +221,25 @@ export const de: Dict = {
'lightbox.videoPreview': 'Videovorschau', 'lightbox.videoPreview': 'Videovorschau',
'lightbox.videoUnavailable': 'Video nicht verfügbar', 'lightbox.videoUnavailable': 'Video nicht verfügbar',
'lightbox.zoom100': '100%', 'lightbox.zoom100': '100%',
'lastFrame.export': 'Letztes Bild exportieren',
'lastFrame.exporting': 'Letztes Bild wird exportiert…',
'lastFrame.hint': 'Speichert das letzte Bild in Originalgröße als PNG, z. B. als Startbild-Referenz',
'lastFrame.fileTag': 'letztes-bild',
'lastFrame.done': 'Letztes Bild exportiert {name} ({size})',
'lastFrame.errTimeout': 'Das Video lädt zu lange. Bitte später erneut versuchen.',
'lastFrame.errNotFound': 'Die Videodatei fehlt oder wurde entfernt.',
'lastFrame.errForbidden': 'Keine Berechtigung, dieses Video zu lesen.',
'lastFrame.errNetwork': 'Das Video konnte nicht gelesen werden (Netzwerkfehler).',
'lastFrame.errDecode': 'Das Video ist beschädigt; das Ende lässt sich nicht dekodieren.',
'lastFrame.errUnsupported': 'Dieser Browser unterstützt das Videoformat nicht, oder die Datei ist beschädigt.',
'lastFrame.errCrossOrigin':
'Das Video stammt von einer anderen Website, die das Auslesen von Bildern sperrt (Cross-Origin). Video zuerst herunterladen.',
'lastFrame.errNoPicture': 'Diese Datei enthält kein Videobild.',
'lastFrame.errUnseekable':
'Das Videoende ist nicht erreichbar (Länge unbekannt oder Quelle erlaubt kein Springen); kein Bild exportiert.',
'lastFrame.errBlack': 'Die letzte Sekunde des Videos ist schwarz; kein Bild exportiert.',
'lastFrame.errEmpty': 'Der Browser konnte das Ende nicht dekodieren. Erneut versuchen oder Desktop-Chrome / Edge nutzen.',
'lastFrame.errUnknown': 'Export des letzten Bildes fehlgeschlagen: {msg}',
// --- Artifact / canvas nodes --- // --- Artifact / canvas nodes ---
'artifact.artifactLabel': 'Artefakt', 'artifact.artifactLabel': 'Artefakt',
+19
View File
@@ -221,6 +221,25 @@ export const en = {
'lightbox.videoPreview': 'Video preview', 'lightbox.videoPreview': 'Video preview',
'lightbox.videoUnavailable': 'Video unavailable', 'lightbox.videoUnavailable': 'Video unavailable',
'lightbox.zoom100': '100%', 'lightbox.zoom100': '100%',
'lastFrame.export': 'Export last frame',
'lastFrame.exporting': 'Exporting last frame…',
'lastFrame.hint': 'Save the final frame as a full-size PNG, e.g. as a first-frame reference',
'lastFrame.fileTag': 'last-frame',
'lastFrame.done': 'Exported last frame {name} ({size})',
'lastFrame.errTimeout': 'The video took too long to load. Try again later.',
'lastFrame.errNotFound': 'The video file is missing or was removed.',
'lastFrame.errForbidden': 'You do not have permission to read this video.',
'lastFrame.errNetwork': 'Reading the video failed (network error). Check the connection and retry.',
'lastFrame.errDecode': 'The video is damaged; its ending cannot be decoded.',
'lastFrame.errUnsupported': 'This browser cannot play the video format, or the file is damaged.',
'lastFrame.errCrossOrigin':
'The video comes from another site that blocks reading its frames (cross-origin). Download the video first.',
'lastFrame.errNoPicture': 'This file has no video picture.',
'lastFrame.errUnseekable':
'Cannot reach the end of the video (unknown length, or the source does not allow seeking); no frame exported.',
'lastFrame.errBlack': 'The last second of the video is black; no frame exported.',
'lastFrame.errEmpty': 'The browser could not decode the ending. Retry, or use desktop Chrome / Edge.',
'lastFrame.errUnknown': 'Exporting the last frame failed: {msg}',
// --- Artifact / canvas nodes --- // --- Artifact / canvas nodes ---
'artifact.artifactLabel': 'Artifact', 'artifact.artifactLabel': 'Artifact',
+19
View File
@@ -221,6 +221,25 @@ export const es: Dict = {
'lightbox.videoPreview': 'Vista previa de vídeo', 'lightbox.videoPreview': 'Vista previa de vídeo',
'lightbox.videoUnavailable': 'Vídeo no disponible', 'lightbox.videoUnavailable': 'Vídeo no disponible',
'lightbox.zoom100': '100%', 'lightbox.zoom100': '100%',
'lastFrame.export': 'Exportar último fotograma',
'lastFrame.exporting': 'Exportando último fotograma…',
'lastFrame.hint': 'Guarda el último fotograma como PNG a tamaño completo, p. ej. como referencia de primer fotograma',
'lastFrame.fileTag': 'ultimo-fotograma',
'lastFrame.done': 'Último fotograma exportado {name} ({size})',
'lastFrame.errTimeout': 'El vídeo tardó demasiado en cargar. Inténtalo más tarde.',
'lastFrame.errNotFound': 'El archivo de vídeo no existe o fue eliminado.',
'lastFrame.errForbidden': 'No tienes permiso para leer este vídeo.',
'lastFrame.errNetwork': 'No se pudo leer el vídeo (error de red).',
'lastFrame.errDecode': 'El vídeo está dañado; su final no se puede decodificar.',
'lastFrame.errUnsupported': 'Este navegador no admite el formato del vídeo o el archivo está dañado.',
'lastFrame.errCrossOrigin':
'El vídeo procede de otro sitio que no permite leer sus fotogramas (origen cruzado). Descarga primero el vídeo.',
'lastFrame.errNoPicture': 'Este archivo no tiene imagen de vídeo.',
'lastFrame.errUnseekable':
'No se puede llegar al final del vídeo (duración desconocida o el origen no permite buscar); no se exportó nada.',
'lastFrame.errBlack': 'El último segundo del vídeo es negro; no se exportó ningún fotograma.',
'lastFrame.errEmpty': 'El navegador no pudo decodificar el final. Reintenta o usa Chrome / Edge de escritorio.',
'lastFrame.errUnknown': 'Error al exportar el último fotograma: {msg}',
// --- Artifact / canvas nodes --- // --- Artifact / canvas nodes ---
'artifact.artifactLabel': 'Artefacto', 'artifact.artifactLabel': 'Artefacto',
+19
View File
@@ -221,6 +221,25 @@ export const fr: Dict = {
'lightbox.videoPreview': 'Aperçu de la vidéo', 'lightbox.videoPreview': 'Aperçu de la vidéo',
'lightbox.videoUnavailable': 'Vidéo indisponible', 'lightbox.videoUnavailable': 'Vidéo indisponible',
'lightbox.zoom100': '100 %', 'lightbox.zoom100': '100 %',
'lastFrame.export': 'Exporter la dernière image',
'lastFrame.exporting': 'Export de la dernière image…',
'lastFrame.hint': 'Enregistre la dernière image en PNG pleine taille, p. ex. comme référence de première image',
'lastFrame.fileTag': 'derniere-image',
'lastFrame.done': 'Dernière image exportée {name} ({size})',
'lastFrame.errTimeout': 'Le chargement de la vidéo a expiré. Réessayez plus tard.',
'lastFrame.errNotFound': 'Le fichier vidéo est introuvable ou a été supprimé.',
'lastFrame.errForbidden': "Vous n'avez pas l'autorisation de lire cette vidéo.",
'lastFrame.errNetwork': 'Lecture de la vidéo impossible (erreur réseau).',
'lastFrame.errDecode': 'La vidéo est endommagée ; sa fin ne peut pas être décodée.',
'lastFrame.errUnsupported': 'Ce navigateur ne prend pas en charge ce format vidéo, ou le fichier est endommagé.',
'lastFrame.errCrossOrigin':
"La vidéo provient d'un autre site qui bloque la lecture des images (cross-origin). Téléchargez d'abord la vidéo.",
'lastFrame.errNoPicture': "Ce fichier ne contient pas d'image vidéo.",
'lastFrame.errUnseekable':
"Impossible d'atteindre la fin de la vidéo (durée inconnue ou source sans recherche) ; aucune image exportée.",
'lastFrame.errBlack': 'La dernière seconde de la vidéo est noire ; aucune image exportée.',
'lastFrame.errEmpty': "Le navigateur n'a pas pu décoder la fin. Réessayez ou utilisez Chrome / Edge sur ordinateur.",
'lastFrame.errUnknown': "Échec de l'export de la dernière image : {msg}",
// --- Artifact / canvas nodes --- // --- Artifact / canvas nodes ---
'artifact.artifactLabel': 'Artefact', 'artifact.artifactLabel': 'Artefact',
+17
View File
@@ -221,6 +221,23 @@ export const ja: Dict = {
'lightbox.videoPreview': 'ビデオプレビュー', 'lightbox.videoPreview': 'ビデオプレビュー',
'lightbox.videoUnavailable': 'ビデオを再生できません', 'lightbox.videoUnavailable': 'ビデオを再生できません',
'lightbox.zoom100': '100%', 'lightbox.zoom100': '100%',
'lastFrame.export': '最終フレームを書き出す',
'lastFrame.exporting': '最終フレームを書き出し中…',
'lastFrame.hint': '動画の最後のフレームを元のサイズの PNG で保存します(先頭フレーム参照用)',
'lastFrame.fileTag': '最終フレーム',
'lastFrame.done': '最終フレームを書き出しました {name}({size})',
'lastFrame.errTimeout': '動画の読み込みがタイムアウトしました。後でもう一度お試しください。',
'lastFrame.errNotFound': '動画ファイルが存在しないか削除されています。',
'lastFrame.errForbidden': 'この動画へのアクセス権限がありません。',
'lastFrame.errNetwork': '動画の読み込みに失敗しました(ネットワークエラー)。',
'lastFrame.errDecode': '動画が破損しており、末尾をデコードできません。',
'lastFrame.errUnsupported': 'このブラウザーは動画形式に対応していないか、ファイルが破損しています。',
'lastFrame.errCrossOrigin': '他サイトの動画のためフレームを読み取れません(クロスオリジン制限)。先に動画をダウンロードしてください。',
'lastFrame.errNoPicture': 'このファイルには映像がありません。',
'lastFrame.errUnseekable': '動画の末尾に移動できません(長さ不明、またはシーク非対応)。書き出しませんでした。',
'lastFrame.errBlack': '動画の最後の 1 秒が黒画面のため、書き出しませんでした。',
'lastFrame.errEmpty': '末尾をデコードできませんでした。再試行するか、PC の Chrome / Edge をお使いください。',
'lastFrame.errUnknown': '最終フレームの書き出しに失敗しました:{msg}',
// --- Artifact / canvas nodes --- // --- Artifact / canvas nodes ---
'artifact.artifactLabel': '成果物', 'artifact.artifactLabel': '成果物',
+17
View File
@@ -221,6 +221,23 @@ export const ko: Dict = {
'lightbox.videoPreview': '영상 미리보기', 'lightbox.videoPreview': '영상 미리보기',
'lightbox.videoUnavailable': '영상을 표시할 수 없습니다', 'lightbox.videoUnavailable': '영상을 표시할 수 없습니다',
'lightbox.zoom100': '100%', 'lightbox.zoom100': '100%',
'lastFrame.export': '마지막 프레임 내보내기',
'lastFrame.exporting': '마지막 프레임 내보내는 중…',
'lastFrame.hint': '영상의 마지막 프레임을 원본 크기 PNG로 저장합니다(첫 프레임 참조용)',
'lastFrame.fileTag': '마지막프레임',
'lastFrame.done': '마지막 프레임을 내보냈습니다 {name} ({size})',
'lastFrame.errTimeout': '영상 로딩 시간이 초과되었습니다. 잠시 후 다시 시도하세요.',
'lastFrame.errNotFound': '영상 파일이 없거나 삭제되었습니다.',
'lastFrame.errForbidden': '이 영상에 접근할 권한이 없습니다.',
'lastFrame.errNetwork': '영상을 읽지 못했습니다(네트워크 오류).',
'lastFrame.errDecode': '영상이 손상되어 끝부분을 디코딩할 수 없습니다.',
'lastFrame.errUnsupported': '이 브라우저가 영상 형식을 지원하지 않거나 파일이 손상되었습니다.',
'lastFrame.errCrossOrigin': '다른 사이트의 영상이라 프레임을 읽을 수 없습니다(교차 출처 제한). 먼저 영상을 다운로드하세요.',
'lastFrame.errNoPicture': '이 파일에는 영상 화면이 없습니다.',
'lastFrame.errUnseekable': '영상 끝으로 이동할 수 없습니다(길이 불명 또는 탐색 미지원). 내보내지 않았습니다.',
'lastFrame.errBlack': '영상의 마지막 1초가 검은 화면이라 내보내지 않았습니다.',
'lastFrame.errEmpty': '끝부분을 디코딩하지 못했습니다. 다시 시도하거나 PC Chrome / Edge를 사용하세요.',
'lastFrame.errUnknown': '마지막 프레임 내보내기 실패: {msg}',
// --- Artifact / canvas nodes --- // --- Artifact / canvas nodes ---
'artifact.artifactLabel': '아티팩트', 'artifact.artifactLabel': '아티팩트',
+17
View File
@@ -221,6 +221,23 @@ export const zh: Dict = {
'lightbox.videoPreview': '视频预览', 'lightbox.videoPreview': '视频预览',
'lightbox.videoUnavailable': '视频不可用', 'lightbox.videoUnavailable': '视频不可用',
'lightbox.zoom100': '100%', 'lightbox.zoom100': '100%',
'lastFrame.export': '导出尾帧',
'lastFrame.exporting': '正在导出尾帧…',
'lastFrame.hint': '把视频最后一帧按原尺寸保存为 PNG,可作首帧参考',
'lastFrame.fileTag': '尾帧',
'lastFrame.done': '已导出尾帧 {name}({size})',
'lastFrame.errTimeout': '视频加载超时,未能导出尾帧,请稍后重试',
'lastFrame.errNotFound': '视频文件不存在或已被移除,无法导出尾帧',
'lastFrame.errForbidden': '没有访问该视频的权限,无法导出尾帧',
'lastFrame.errNetwork': '视频读取失败(网络中断),请检查连接后重试',
'lastFrame.errDecode': '视频文件已损坏,片尾画面无法解码',
'lastFrame.errUnsupported': '当前浏览器不支持该视频格式,或文件已损坏,无法导出尾帧',
'lastFrame.errCrossOrigin': '该视频来自其他站点,浏览器禁止读取画面(跨域限制),请先下载原视频',
'lastFrame.errNoPicture': '该文件没有视频画面,无法导出尾帧',
'lastFrame.errUnseekable': '无法定位到视频结尾(时长未知或来源不支持拖动进度),未导出尾帧',
'lastFrame.errBlack': '视频结尾 1 秒内都是黑屏,未导出尾帧',
'lastFrame.errEmpty': '浏览器未能解码出片尾画面,请重试或换用电脑端 Chrome / Edge',
'lastFrame.errUnknown': '导出尾帧失败:{msg}',
// --- Artifact / canvas nodes --- // --- Artifact / canvas nodes ---
'artifact.artifactLabel': '产物', 'artifact.artifactLabel': '产物',
+343
View File
@@ -0,0 +1,343 @@
/**
* 导出尾帧 (HEL-669): decode a frame just before the end of a video in the
* browser and save it as a PNG at the video's native resolution.
*
* Frames are taken from a hidden, muted <video> of the same URL so the
* player the user is watching never jumps. Media is served same-origin by
* /api/file, so the canvas stays untainted.
*
* The pure helpers at the top touch no DOM and import nothing, so
* scripts/check-export-frame.mjs can run them under plain Node.
*/
export type FrameErrorCode =
| "timeout"
| "not-found"
| "forbidden"
| "network"
| "decode"
| "unsupported"
| "cross-origin"
| "no-picture"
| "unseekable"
| "black"
| "empty";
export class FrameExportError extends Error {
code: FrameErrorCode;
constructor(code: FrameErrorCode, detail?: string) {
super(detail ? `${code}: ${detail}` : code);
this.name = "FrameExportError";
this.code = code;
}
}
/**
* Seconds before the end to try, nearest first. The exact end is often not
* decodable, so the first try is ~1 frame back; later tries step past a
* black fade-out tail.
*/
export const TAIL_OFFSETS = [0.05, 0.12, 0.25, 0.5, 1];
export function tailSeekTimes(duration: number): number[] {
if (!Number.isFinite(duration) || duration <= 0) return [0];
const times: number[] = [];
for (const off of TAIL_OFFSETS) {
const t = Math.max(0, Math.round((duration - off) * 1000) / 1000);
if (!times.includes(t)) times.push(t);
}
return times;
}
/** File name of the video behind a media URL (`/api/file?path=…` or plain). */
export function videoBaseName(src: string): string {
let name = "";
try {
const url = new URL(src, "http://local.invalid/");
const path =
url.searchParams.get("path") ?? decodeURIComponent(url.pathname);
name = path.split(/[\\/]/).pop() ?? "";
} catch {
name = "";
}
name = name.replace(/\.[a-z0-9]{1,5}$/i, "");
name = name.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, "_").trim();
return name || "video";
}
/** `{视频名}_{tag}_{秒}s.png` — names the source video and the frame time. */
export function frameFileName(
src: string,
seconds: number,
tag: string,
): string {
const safeTag = tag.replace(/[\\/:*?"<>|\s]+/g, "-");
return `${videoBaseName(src)}_${safeTag}_${seconds.toFixed(3)}s.png`;
}
/**
* Classify sampled RGBA pixels: "empty" when nothing was painted (the
* decoder handed back no picture), "black" for an all-dark frame, else null.
* A bright highlight anywhere keeps a dark scene from counting as black.
*/
export function blankKind(rgba: ArrayLike<number>): "empty" | "black" | null {
let opaque = 0;
let sum = 0;
let max = 0;
for (let i = 0; i + 3 < rgba.length; i += 4) {
if (rgba[i + 3] < 8) continue;
opaque++;
const luma = 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
sum += luma;
if (luma > max) max = luma;
}
if (opaque === 0) return "empty";
// 16 = limited-range video black when a decoder skips range expansion.
if (max < 40 && sum / opaque < 20) return "black";
return null;
}
/** HTTP status of a failed media fetch → error code (null = not decisive). */
export function statusErrorCode(status: number): FrameErrorCode | null {
if (status === 404 || status === 410) return "not-found";
if (status === 401 || status === 403) return "forbidden";
if (status >= 400) return "network";
return null;
}
// ---- browser side -------------------------------------------------------
export interface LastFrame {
blob: Blob;
width: number;
height: number;
/** Media time of the exported frame, in seconds. */
time: number;
fileName: string;
}
export interface CaptureOptions {
/** Word placed in the file name, e.g. 尾帧. */
fileTag: string;
timeoutMs?: number;
}
const SAMPLE = 64;
function isCrossOrigin(src: string): boolean {
try {
const url = new URL(src, location.href);
if (url.protocol === "blob:" || url.protocol === "data:") return false;
return url.origin !== location.origin;
} catch {
return false;
}
}
/** Probe the URL so a failed load can say 不存在 / 无权限 / 跨域 precisely. */
async function classifyLoadFailure(
video: HTMLVideoElement,
src: string,
crossOrigin: boolean,
): Promise<FrameExportError> {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), 5000);
try {
const res = await fetch(src, {
headers: { Range: "bytes=0-0" },
mode: crossOrigin ? "cors" : "same-origin",
signal: ctl.signal,
});
ctl.abort();
const code = statusErrorCode(res.status);
if (code) return new FrameExportError(code, `HTTP ${res.status}`);
} catch {
if (crossOrigin) return new FrameExportError("cross-origin");
return new FrameExportError("network");
} finally {
clearTimeout(timer);
}
const mediaCode = video.error?.code;
if (mediaCode === MediaError.MEDIA_ERR_NETWORK) {
return new FrameExportError("network", video.error?.message);
}
if (mediaCode === MediaError.MEDIA_ERR_DECODE) {
return new FrameExportError("decode", video.error?.message);
}
return new FrameExportError("unsupported", video.error?.message);
}
export async function captureLastFrame(
src: string,
{ fileTag, timeoutMs = 30000 }: CaptureOptions,
): Promise<LastFrame> {
const crossOrigin = isCrossOrigin(src);
const video = document.createElement("video");
if (crossOrigin) video.crossOrigin = "anonymous";
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.setAttribute("aria-hidden", "true");
// Kept in the DOM (some engines never paint a detached video) but unseen.
Object.assign(video.style, {
position: "fixed",
left: "0",
top: "0",
width: "1px",
height: "1px",
opacity: "0",
pointerEvents: "none",
});
document.body.appendChild(video);
const deadline = Date.now() + timeoutMs;
/** Resolve on `event`; reject on a media error or the shared deadline. */
const until = (event: string) =>
new Promise<void>((resolve, reject) => {
const done = (fn: () => void) => {
clearTimeout(timer);
video.removeEventListener(event, onEvent);
video.removeEventListener("error", onError);
fn();
};
const onEvent = () => done(resolve);
const onError = () =>
done(() => {
classifyLoadFailure(video, src, crossOrigin).then(reject, reject);
});
const timer = setTimeout(
() => done(() => reject(new FrameExportError("timeout"))),
Math.max(0, deadline - Date.now()),
);
video.addEventListener(event, onEvent);
video.addEventListener("error", onError);
});
const seek = async (time: number) => {
const settled = until("seeked");
video.currentTime = time;
await settled;
};
/** Wait until the seeked frame has actually been decoded and presented. */
const presented = () =>
new Promise<void>((resolve) => {
const fallback = setTimeout(resolve, 250);
const finish = () => {
clearTimeout(fallback);
resolve();
};
if (typeof video.requestVideoFrameCallback === "function") {
video.requestVideoFrameCallback(() => finish());
} else {
requestAnimationFrame(() => requestAnimationFrame(finish));
}
});
try {
const metadata = until("loadedmetadata");
video.src = src;
video.load();
await metadata;
if (!video.videoWidth || !video.videoHeight) {
throw new FrameExportError("no-picture");
}
// MediaRecorder WebM reports Infinity until the end has been read. When
// the server cannot seek either, the end is unreachable — fail rather
// than hand back the first frame.
let duration = video.duration;
if (!Number.isFinite(duration) || duration <= 0) {
await seek(1e101);
duration = video.duration;
if (!Number.isFinite(duration) || duration <= 0) {
throw new FrameExportError("unseekable", "duration unknown");
}
}
const width = video.videoWidth;
const height = video.videoHeight;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
const sample = document.createElement("canvas");
sample.width = SAMPLE;
sample.height = SAMPLE;
const sampleCtx = sample.getContext("2d", { willReadFrequently: true });
if (!ctx || !sampleCtx)
throw new FrameExportError("empty", "no 2d context");
let lastBlank: "empty" | "black" = "empty";
for (const time of tailSeekTimes(duration)) {
await seek(time);
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
// iOS Safari only decodes after a play(); muted inline play is allowed.
await video.play().catch(() => {});
video.pause();
await seek(time);
}
if (Math.abs(video.currentTime - time) > 0.5) {
throw new FrameExportError(
"unseekable",
`asked ${time}s, landed at ${video.currentTime}s`,
);
}
await presented();
ctx.clearRect(0, 0, width, height);
ctx.drawImage(video, 0, 0, width, height);
sampleCtx.clearRect(0, 0, SAMPLE, SAMPLE);
sampleCtx.drawImage(canvas, 0, 0, SAMPLE, SAMPLE);
let pixels: Uint8ClampedArray;
try {
pixels = sampleCtx.getImageData(0, 0, SAMPLE, SAMPLE).data;
} catch (err) {
throw new FrameExportError("cross-origin", String(err));
}
const blank = blankKind(pixels);
if (blank) {
lastBlank = blank;
continue;
}
const blob = await new Promise<Blob | null>((resolve) => {
try {
canvas.toBlob(resolve, "image/png");
} catch {
resolve(null);
}
});
if (!blob) throw new FrameExportError("empty", "toBlob returned nothing");
const shown = video.currentTime;
return {
blob,
width,
height,
time: shown,
fileName: frameFileName(src, shown, fileTag),
};
}
throw new FrameExportError(lastBlank);
} finally {
video.removeAttribute("src");
video.load();
video.remove();
}
}
export function downloadBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.rel = "noopener";
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
+50
View File
@@ -0,0 +1,50 @@
import { t } from "$lib/i18n.svelte";
import { toast } from "$lib/toast";
import {
captureLastFrame,
downloadBlob,
FrameExportError,
type FrameErrorCode,
} from "./exportFrame";
const ERROR_KEYS: Record<FrameErrorCode, string> = {
timeout: "lastFrame.errTimeout",
"not-found": "lastFrame.errNotFound",
forbidden: "lastFrame.errForbidden",
network: "lastFrame.errNetwork",
decode: "lastFrame.errDecode",
unsupported: "lastFrame.errUnsupported",
"cross-origin": "lastFrame.errCrossOrigin",
"no-picture": "lastFrame.errNoPicture",
unseekable: "lastFrame.errUnseekable",
black: "lastFrame.errBlack",
empty: "lastFrame.errEmpty",
};
/**
* 导出尾帧 for one video URL: capture, download the PNG and report the
* outcome as a toast. Never throws. Resolves true when a file was saved.
*/
export async function exportLastFrame(src: string): Promise<boolean> {
try {
const frame = await captureLastFrame(src, {
fileTag: t("lastFrame.fileTag"),
});
downloadBlob(frame.blob, frame.fileName);
toast.success(
t("lastFrame.done", {
name: frame.fileName,
size: `${frame.width}×${frame.height}`,
}),
);
return true;
} catch (err) {
if (err instanceof FrameExportError) {
toast.error(t(ERROR_KEYS[err.code]), 6000);
} else {
const msg = err instanceof Error ? err.message : String(err);
toast.error(t("lastFrame.errUnknown", { msg }), 6000);
}
return false;
}
}
+6 -2
View File
@@ -11,6 +11,8 @@ Mode file e2e/fake_mode.json:
can be driven all-green (every class present) or missing-node (catalogue can be driven all-green (every class present) or missing-node (catalogue
omits classes). Absent → {} (every node "missing"). omits classes). Absent → {} (every node "missing").
- no_outputs: history entries complete with no files (test-run no-output pass) - no_outputs: history entries complete with no files (test-run no-output pass)
- video_file: absolute path of an mp4 served (and named) as the video output
instead of tiny.mp4 (HEL-669 last-frame export runs real clips through it)
""" """
from __future__ import annotations from __future__ import annotations
import json, threading, uuid import json, threading, uuid
@@ -64,7 +66,8 @@ class Handler(BaseHTTPRequestHandler):
return return
if self.path.startswith("/view"): if self.path.startswith("/view"):
if "fmt=mp4" in self.path or ".mp4" in self.path: if "fmt=mp4" in self.path or ".mp4" in self.path:
self._raw(200, MP4.read_bytes(), "video/mp4") src = read_mode().get("video_file")
self._raw(200, Path(src).read_bytes() if src else MP4.read_bytes(), "video/mp4")
else: else:
self._raw(200, PNG_1PX, "image/png") self._raw(200, PNG_1PX, "image/png")
return return
@@ -82,7 +85,8 @@ class Handler(BaseHTTPRequestHandler):
self.server.accepted.append(pid) self.server.accepted.append(pid)
videos = mode.get("videos") is True videos = mode.get("videos") is True
no_outputs = mode.get("no_outputs") is True no_outputs = mode.get("no_outputs") is True
out_name = "out-1.mp4" if videos else "out-1.png" video_file = mode.get("video_file")
out_name = (Path(video_file).stem + ".mp4" if video_file else "out-1.mp4") if videos else "out-1.png"
key = "videos" if videos else "images" key = "videos" if videos else "images"
outputs = {} if no_outputs else { outputs = {} if no_outputs else {
self._output_node(body): {key: [{"filename": out_name, "subfolder": "", "type": "output"}]} self._output_node(body): {key: [{"filename": out_name, "subfolder": "", "type": "output"}]}
+450
View File
@@ -0,0 +1,450 @@
/* HEL-669 导出尾帧 browser validation against the isolated stack:
* vite dev (5173) → backend (8247) → fake ComfyUI (18188), seeded by seed.py.
*
* node e2e/lastframe.mjs [dir-with-real-generated-mp4s]
*
* Every clip reaches the UI through the normal generate → worker download
* path (fake ComfyUI serves it via the `video_file` mode). Real clips come
* from the optional directory; portrait / square / black-tail / all-black /
* WebM clips are synthesised in the browser with MediaRecorder.
* Prints PASS/FAIL per check; exported PNGs + screenshots go to
* e2e/shots/lastframe/. */
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(ROOT, 'shots', 'lastframe');
const SAMPLES = path.join(OUT, 'samples');
fs.mkdirSync(SAMPLES, { recursive: true });
const API = 'http://127.0.0.1:8247';
const WEB = 'http://127.0.0.1:5173';
const MODE_FILE = path.join(ROOT, 'fake_mode.json');
const REAL_DIR = process.argv[2] ? path.resolve(process.argv[2]) : null;
let failures = 0;
function ok(name, cond, extra = '') {
console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
if (!cond) failures++;
}
const api = async (p, init) => {
const r = await fetch(API + p, { headers: { 'Content-Type': 'application/json' }, ...init });
return r.json();
};
const fileUrl = (p) => `/api/file?path=${encodeURIComponent(p)}`;
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Generate a studio video job whose output is `file`; returns the saved asset path. */
async function generateWith(file, prompt) {
fs.writeFileSync(MODE_FILE, JSON.stringify({ mode: 'ok', delay_sec: 0, videos: true, video_file: file }));
const r = await api('/api/playground/generate', {
method: 'POST',
body: JSON.stringify({ workflow_id: 2, input_values: { 1: prompt, 2: 6 }, source: 'studio-video' }),
});
const id = r.job.id;
for (let i = 0; i < 150; i++) {
const j = (await api('/api/playground/jobs')).find((x) => x.id === id);
if (j?.status === 'done') return j.output_paths[0];
if (j?.status === 'failed') throw new Error(`job ${id} failed: ${j.error}`);
await sleep(200);
}
throw new Error(`job ${id} timed out`);
}
/** PNG width/height from the IHDR chunk. */
function pngSize(buf) {
const sig = buf.subarray(0, 8).toString('hex') === '89504e470d0a1a0a';
return sig ? { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) } : null;
}
/** Browser-side: synthesise a clip; returns base64 bytes. */
async function synth(page, spec) {
return page.evaluate(async ({ w, h, seconds, fps, mime, mode }) => {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
const total = Math.round(seconds * fps);
const paint = (i) => {
const left = (total - i) / fps;
if (mode === 'allblack' || (mode === 'fadeblack' && left <= 0.4)) ctx.fillStyle = '#000';
else if (mode === 'marker' && left <= 0.5) ctx.fillStyle = '#d0208c';
else ctx.fillStyle = mode === 'fadeblack' ? '#e08a1e' : '#00b4c8';
ctx.fillRect(0, 0, w, h);
if (mode !== 'allblack' && !(mode === 'fadeblack' && left <= 0.4)) {
ctx.fillStyle = '#fff';
ctx.fillRect(((i * 7) % w) | 0, (h / 3) | 0, (w / 10) | 0, (h / 10) | 0);
ctx.font = `${(h / 12) | 0}px sans-serif`;
ctx.fillText(`#${i}`, 12, h - 24);
}
};
paint(0);
const stream = canvas.captureStream(fps);
const rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 4e6 });
const chunks = [];
rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);
const stopped = new Promise((r) => (rec.onstop = r));
rec.start(100);
for (let i = 0; i < total; i++) {
paint(i);
await new Promise((r) => setTimeout(r, 1000 / fps));
}
await new Promise((r) => setTimeout(r, 150));
rec.stop();
await stopped;
const buf = new Uint8Array(await new Blob(chunks).arrayBuffer());
let s = '';
for (let i = 0; i < buf.length; i += 0x8000) s += String.fromCharCode(...buf.subarray(i, i + 0x8000));
return btoa(s);
}, spec);
}
/** Browser-side: stats of a PNG + the frame an independent player shows at `at`. */
async function inspectPng(page, b64, src, at) {
return page.evaluate(
async ({ b64, src, at }) => {
const S = 64;
const grab = (el) => {
const c = document.createElement('canvas');
c.width = S;
c.height = S;
const x = c.getContext('2d');
x.drawImage(el, 0, 0, S, S);
return x.getImageData(0, 0, S, S).data;
};
const img = new Image();
img.src = `data:image/png;base64,${b64}`;
await img.decode();
const a = grab(img);
let sum = 0;
for (let i = 0; i < a.length; i += 4) sum += 0.2126 * a[i] + 0.7152 * a[i + 1] + 0.0722 * a[i + 2];
const c = (S / 2) * S * 4 + (S / 2) * 4;
const center = [a[c], a[c + 1], a[c + 2]];
const diffAt = async (t) => {
const v = document.createElement('video');
v.muted = true;
v.src = src;
await new Promise((r, j) => ((v.onloadeddata = r), (v.onerror = j)));
let d = v.duration;
if (!Number.isFinite(d)) {
v.currentTime = 1e101;
await new Promise((r) => (v.onseeked = r));
d = v.duration;
}
v.currentTime = t < 0 ? Math.max(0, d + t) : t;
await new Promise((r) => (v.onseeked = r));
await new Promise((r) => setTimeout(r, 120));
const b = grab(v);
let diff = 0;
for (let i = 0; i < a.length; i += 4) diff += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
return { diff: diff / (S * S * 3), duration: d, w: v.videoWidth, h: v.videoHeight };
};
const tail = await diffAt(at);
const start = await diffAt(0);
return { mean: sum / (S * S), center, tail, start };
},
{ b64, src, at },
);
}
/** Verify one downloaded PNG against its source clip. */
async function verifyDownload(page, label, download, assetPath, expect = {}) {
const name = download.suggestedFilename();
const dest = path.join(OUT, name);
await download.saveAs(dest);
const buf = fs.readFileSync(dest);
const size = pngSize(buf);
const base = path.basename(assetPath).replace(/\.[^.]+$/, '');
const m = name.match(/_尾帧_(\d+\.\d{3})s\.png$/);
ok(`${label}: 文件名对应原视频与尾帧时间`, name.startsWith(base + '_尾帧_') && !!m, name);
ok(`${label}: 是 PNG`, !!size);
const st = await inspectPng(page, buf.toString('base64'), fileUrl(assetPath), -0.05);
ok(
`${label}: 尺寸等于视频原生尺寸`,
size && size.w === st.tail.w && size.h === st.tail.h,
`${size?.w}×${size?.h} vs 视频 ${st.tail.w}×${st.tail.h}`,
);
const t = m ? Number(m[1]) : NaN;
const back = st.tail.duration - t;
if (expect.fellBack) {
ok(`${label}: 片尾黑屏时回退到非黑帧`, back > 0.3 && back <= 1.05, `t=${t}s, 片长 ${st.tail.duration.toFixed(3)}s`);
} else {
ok(`${label}: 取的是片尾(距结尾 ≤0.06s)`, back >= 0 && back <= 0.06, `t=${t}s, 片长 ${st.tail.duration.toFixed(3)}s`);
ok(`${label}: 与独立播放器片尾画面一致`, st.tail.diff < 6, `平均差 ${st.tail.diff.toFixed(2)},与首帧差 ${st.start.diff.toFixed(2)}`);
}
ok(`${label}: 非黑帧/空白帧`, st.mean > 20, `平均亮度 ${st.mean.toFixed(1)}`);
if (expect.center) {
const [r, g, b] = st.center;
ok(`${label}: 画面为片尾标记色`, expect.center([r, g, b]), `中心像素 rgb(${r},${g},${b})`);
}
return { name, dest, size };
}
/** Lightweight side servers: no-CORS, CORS, and a never-answering one. */
function sideServers() {
const code = `
import http.server, socketserver, sys, threading, time, os
os.chdir(sys.argv[1])
class NoCors(http.server.SimpleHTTPRequestHandler):
def log_message(self, *a): pass
class Cors(NoCors):
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
super().end_headers()
class Hang(http.server.BaseHTTPRequestHandler):
def do_GET(self): time.sleep(60)
def log_message(self, *a): pass
for port, h in ((18190, NoCors), (18192, Cors), (18191, Hang)):
s = socketserver.ThreadingTCPServer(('127.0.0.1', port), h)
s.daemon_threads = True
threading.Thread(target=s.serve_forever, daemon=True).start()
print('up', flush=True)
time.sleep(3600)
`;
const proc = spawn('python3', ['-c', code, SAMPLES], { stdio: ['ignore', 'pipe', 'inherit'] });
return new Promise((resolve) => proc.stdout.once('data', () => resolve(proc)));
}
const isMagenta = ([r, g, b]) => r > 150 && g < 90 && b > 90;
const lastToast = (page) => page.locator('.toast-host .toast .msg').last();
const run = async () => {
const settings = await api('/api/settings');
const ASSETS = settings.assets_dir;
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
const page = await ctx.newPage();
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message));
await page.goto(`${WEB}/video`);
await page.waitForSelector('#desk-prompt', { timeout: 20000 });
// ---------- 样本:真实生成视频 + 合成横/竖/方/黑尾 ----------
const clips = [];
if (REAL_DIR) {
for (const f of fs.readdirSync(REAL_DIR).filter((x) => x.endsWith('.mp4')).sort()) {
clips.push({ label: `真实·${f}`, file: path.join(REAL_DIR, f), prompt: `尾帧验证 ${f}`, real: true });
}
}
const specs = [
{ key: 'portrait', w: 720, h: 1280, mime: 'video/mp4;codecs=avc1', mode: 'marker', ext: 'mp4' },
{ key: 'square', w: 768, h: 768, mime: 'video/mp4;codecs=avc1', mode: 'marker', ext: 'mp4' },
{ key: 'landscape-webm', w: 1280, h: 720, mime: 'video/webm;codecs=vp9', mode: 'marker', ext: 'webm' },
{ key: 'fadeblack', w: 960, h: 540, mime: 'video/mp4;codecs=avc1', mode: 'fadeblack', ext: 'mp4' },
{ key: 'allblack', w: 640, h: 360, mime: 'video/mp4;codecs=avc1', mode: 'allblack', ext: 'mp4' },
];
const synthFiles = {};
for (const s of specs) {
const b64 = await synth(page, { ...s, seconds: 2.4, fps: 24 });
const f = path.join(SAMPLES, `synth-${s.key}.${s.ext}`);
fs.writeFileSync(f, Buffer.from(b64, 'base64'));
synthFiles[s.key] = f;
}
ok('合成竖屏/方形/WebM/黑尾样本', Object.values(synthFiles).every((f) => fs.statSync(f).size > 1000));
clips.push({ label: '竖屏 720×1280', file: synthFiles.portrait, prompt: '尾帧验证 竖屏', center: isMagenta });
clips.push({ label: '方形 768×768', file: synthFiles.square, prompt: '尾帧验证 方形', center: isMagenta });
clips.push({ label: '黑尾 960×540', file: synthFiles.fadeblack, prompt: '尾帧验证 黑尾', fellBack: true });
for (const c of clips) c.asset = await generateWith(c.file, c.prompt);
ok('样本均经平台生成流程落盘', clips.every((c) => c.asset && fs.existsSync(c.asset)), `${clips.length} 段`);
const hashes = Object.fromEntries(clips.map((c) => [c.asset, sha(c.asset)]));
const jobsBefore = (await api('/api/playground/jobs')).length;
// ---------- 生成结果页(检视区)----------
await page.reload();
await page.waitForSelector('.take', { timeout: 20000 });
const exportBtn = page.locator('.inspector .actions button', { hasText: '导出尾帧' });
for (const c of clips) {
await page.locator('.take', { hasText: c.prompt }).first().click();
await page.waitForSelector('.inspector .stage-media video', { timeout: 10000 });
await page.waitForTimeout(400);
// The user's own player must not jump while we take the frame.
await page.evaluate(async () => {
const v = document.querySelector('.inspector .stage-media video');
v.pause();
if (v.readyState < 1) await new Promise((r) => (v.onloadedmetadata = r));
v.currentTime = 0.5;
await new Promise((r) => (v.onseeked = r));
});
const [dl] = await Promise.all([page.waitForEvent('download', { timeout: 40000 }), exportBtn.click()]);
await verifyDownload(page, `检视区·${c.label}`, dl, c.asset, c);
const player = await page.evaluate(() => {
const v = document.querySelector('.inspector .stage-media video');
return { t: v.currentTime, paused: v.paused };
});
ok(`检视区·${c.label}: 用户播放器位置未被改动`, Math.abs(player.t - 0.5) < 0.01 && player.paused, `t=${player.t}`);
await page.waitForTimeout(200);
ok(`检视区·${c.label}: 成功提示`, ((await lastToast(page).textContent()) || '').startsWith('已导出尾帧'));
}
await page.screenshot({ path: path.join(OUT, '01-inspector.png') });
// 加载中:视频响应被拖慢时按钮进入忙碌态且不可重复点
const slow = clips[0];
await page.locator('.take', { hasText: slow.prompt }).first().click();
await page.waitForTimeout(500);
await page.route('**/api/file?**', async (route) => {
await sleep(1500);
await route.continue();
});
const dlSlow = page.waitForEvent('download', { timeout: 40000 });
await exportBtn.click();
await page.waitForTimeout(300);
const busyBtn = page.locator('.inspector .actions button', { hasText: '正在导出尾帧' });
ok('加载中:按钮显示「正在导出尾帧…」且禁用', (await busyBtn.count()) === 1 && (await busyBtn.isDisabled()));
await page.screenshot({ path: path.join(OUT, '02-inspector-busy.png') });
await dlSlow;
await page.unroute('**/api/file?**');
await page.waitForTimeout(200);
ok('加载结束:按钮恢复可用', (await exportBtn.count()) === 1 && !(await exportBtn.isDisabled()));
ok('导出不改原视频文件', clips.every((c) => sha(c.asset) === hashes[c.asset]));
ok('导出不新增/改动生成记录', (await api('/api/playground/jobs')).length === jobsBefore);
// 图片结果(生图台)不出现导出尾帧
fs.writeFileSync(MODE_FILE, JSON.stringify({ mode: 'ok', delay_sec: 0 }));
await api('/api/playground/generate', {
method: 'POST',
body: JSON.stringify({ workflow_id: 1, input_values: { 1: '尾帧验证 图片', 2: 42 }, source: 'studio-image' }),
});
await sleep(1500);
await page.goto(`${WEB}/image`);
await page.locator('.take', { hasText: '尾帧验证 图片' }).first().click();
await page.waitForSelector('.inspector .stage-media img', { timeout: 10000 });
ok('图片结果不显示导出尾帧', (await page.locator('.inspector .actions button', { hasText: '导出尾帧' }).count()) === 0);
// ---------- 资料库 → 共用播放弹层 ----------
await page.goto(`${WEB}/library`);
await page.waitForSelector('.card .media', { timeout: 20000 });
for (const c of clips.filter((x) => x.real).slice(0, 1).concat(clips.filter((x) => x.center).slice(0, 1))) {
const name = path.basename(c.asset);
await page.locator(`.card .media[title="${name}"]`).first().click();
await page.waitForSelector('.backdrop .player-frame video', { timeout: 10000 });
const tool = page.locator('.backdrop .toolbar button[aria-label="导出尾帧"]');
ok(`弹层·${c.label}: 工具栏有导出尾帧按钮`, (await tool.count()) === 1);
const [dl] = await Promise.all([page.waitForEvent('download', { timeout: 40000 }), tool.click()]);
await verifyDownload(page, `弹层·${c.label}`, dl, c.asset, c);
ok(`弹层·${c.label}: 点击后弹层不关闭`, await page.locator('.backdrop').isVisible());
await page.waitForTimeout(250);
const toastOnTop = await page.evaluate(() => {
const el = document.querySelector('.toast-host .toast:last-child');
if (!el) return false;
const r = el.getBoundingClientRect();
return el.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2));
});
ok(`弹层·${c.label}: 提示浮在弹层之上可见`, toastOnTop);
await page.screenshot({ path: path.join(OUT, `03-lightbox-${c.center ? 'square' : 'real'}.png`) });
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
}
// ---------- 失败场景(真实提示链路:exportLastFrame → Toast)----------
fs.mkdirSync(path.join(ASSETS, 'e2e-lastframe'), { recursive: true });
const junk = path.join(ASSETS, 'e2e-lastframe', 'corrupt.mp4');
fs.writeFileSync(junk, crypto.randomBytes(200000));
const realSrc = clips.find((c) => c.real)?.asset ?? clips[0].asset;
const truncated = path.join(ASSETS, 'e2e-lastframe', 'truncated.mp4');
const whole = fs.readFileSync(realSrc);
fs.writeFileSync(truncated, whole.subarray(0, Math.floor(whole.length * 0.35)));
const allBlack = path.join(ASSETS, 'e2e-lastframe', 'allblack.mp4');
fs.copyFileSync(synthFiles.allblack, allBlack);
const webm = path.join(ASSETS, 'e2e-lastframe', 'recorded.webm');
fs.copyFileSync(synthFiles['landscape-webm'], webm);
const servers = await sideServers();
await page.goto(`${WEB}/video`);
await page.waitForSelector('#desk-prompt', { timeout: 20000 });
const tryExport = async (src) => {
const dl = page.waitForEvent('download', { timeout: 2000 }).catch(() => null);
const res = await page.evaluate(async (s) => (await import('/src/lib/media/lastFrame.ts')).exportLastFrame(s), src);
await page.waitForTimeout(150);
return { res, msg: (await lastToast(page).textContent())?.trim(), dl: await dl };
};
const failures_ = [
['文件不存在', fileUrl(path.join(ASSETS, 'e2e-lastframe', 'missing.mp4')), '视频文件不存在或已被移除,无法导出尾帧'],
['无权限(越界路径 403)', fileUrl('/etc/hostname'), '没有访问该视频的权限,无法导出尾帧'],
['文件损坏(随机字节)', fileUrl(junk), '当前浏览器不支持该视频格式,或文件已损坏,无法导出尾帧'],
['跨域且无 CORS', 'http://127.0.0.1:18190/synth-square.mp4', '该视频来自其他站点,浏览器禁止读取画面(跨域限制),请先下载原视频'],
['整段片尾黑屏', fileUrl(allBlack), '视频结尾 1 秒内都是黑屏,未导出尾帧'],
];
for (const [label, src, want] of failures_) {
const r = await tryExport(src);
ok(`失败·${label}: 中文提示且不下载`, r.res === false && r.msg === want && !r.dl, r.msg);
}
const tr = await tryExport(fileUrl(truncated));
ok('失败·截断的真实视频: 给出中文失败提示或仍导出有效帧', tr.res === false ? /无法|损坏|不支持/.test(tr.msg || '') : !!tr.dl, tr.msg);
await page.screenshot({ path: path.join(OUT, '04-error-toast.png') });
const timeout = await page.evaluate(async () => {
const m = await import('/src/lib/media/exportFrame.ts');
const t0 = performance.now();
try {
await m.captureLastFrame('http://127.0.0.1:18191/hang.mp4', { fileTag: '尾帧', timeoutMs: 2500 });
return { code: 'none' };
} catch (e) {
return { code: e.code, ms: performance.now() - t0 };
}
});
ok('失败·加载超时: 按时放弃并报 timeout', timeout.code === 'timeout' && timeout.ms < 6000, JSON.stringify(timeout));
const UNSEEKABLE = '无法定位到视频结尾(时长未知或来源不支持拖动进度),未导出尾帧';
const noRange = await tryExport('http://127.0.0.1:18192/synth-square.mp4');
ok('失败·来源不支持拖动且时长未知: 中文提示,不导出首帧冒充', noRange.res === false && noRange.msg === UNSEEKABLE && !noRange.dl, noRange.msg);
fs.copyFileSync(realSrc, path.join(SAMPLES, path.basename(realSrc)));
const cors = await tryExport(`http://127.0.0.1:18192/${encodeURIComponent(path.basename(realSrc))}`);
if (cors.res) {
ok('跨域(允许 CORS)的真实视频可导出', !!cors.dl, cors.msg);
await verifyDownload(page, '跨域 CORS·真实', cors.dl, realSrc);
} else {
ok('跨域(允许 CORS、无 Range)的真实视频:明确提示无法定位结尾', cors.msg === UNSEEKABLE, cors.msg);
}
const wb = await tryExport(fileUrl(webm));
ok('MediaRecorder WebM(时长未知)可导出', wb.res === true && !!wb.dl, wb.msg);
if (wb.dl) await verifyDownload(page, 'WebM 1280×720', wb.dl, webm, { center: isMagenta });
servers.kill();
// ---------- 手机 ----------
const phone = await browser.newContext({
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
acceptDownloads: true,
});
const mp = await phone.newPage();
await mp.goto(`${WEB}/video`);
await mp.waitForSelector('.take', { timeout: 20000 });
const pc = clips.find((c) => c.label.startsWith('竖屏'));
await mp.locator('.take', { hasText: pc.prompt }).first().tap();
await mp.waitForTimeout(600);
const mBtn = mp.locator('.inspector .actions button', { hasText: '导出尾帧' });
const box = await mBtn.boundingBox();
ok('手机:检视区导出尾帧按钮在屏内可点', !!box && box.x >= 0 && box.x + box.width <= 390 && box.height >= 28, JSON.stringify(box));
await mp.screenshot({ path: path.join(OUT, '05-phone-inspector.png') });
const [mdl] = await Promise.all([mp.waitForEvent('download', { timeout: 40000 }), mBtn.tap()]);
await verifyDownload(mp, '手机·竖屏', mdl, pc.asset, pc);
await mp.goto(`${WEB}/library`);
await mp.waitForSelector('.card .media', { timeout: 20000 });
await mp.locator(`.card .media[title="${path.basename(pc.asset)}"]`).first().tap();
await mp.waitForSelector('.backdrop .player-frame video', { timeout: 10000 });
const mTool = mp.locator('.backdrop .toolbar button[aria-label="导出尾帧"]');
const tbox = await mTool.boundingBox();
ok('手机:弹层导出尾帧按钮在屏内', !!tbox && tbox.x >= 0 && tbox.x + tbox.width <= 390, JSON.stringify(tbox));
const [mdl2] = await Promise.all([mp.waitForEvent('download', { timeout: 40000 }), mTool.tap()]);
await verifyDownload(mp, '手机·弹层', mdl2, pc.asset, pc);
await mp.waitForTimeout(250);
await mp.screenshot({ path: path.join(OUT, '06-phone-lightbox.png') });
await browser.close();
console.log(failures ? `\n${failures} check(s) FAILED` : '\nALL PASS');
process.exit(failures ? 1 : 0);
};
run().catch((e) => {
console.error(e);
process.exit(1);
});