Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bd8c29faa | ||
|
|
4f3293fc02 | ||
|
|
4c87c6dea0 |
@@ -7,7 +7,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"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 .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
@@ -61,6 +63,17 @@
|
||||
}
|
||||
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>
|
||||
|
||||
<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} />
|
||||
</button>
|
||||
{/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
|
||||
class="tool"
|
||||
href={src}
|
||||
|
||||
@@ -62,7 +62,8 @@
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 1000;
|
||||
/* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
import { usageLabel } from './schema';
|
||||
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf, specOf } from './takes';
|
||||
|
||||
@@ -129,6 +130,18 @@
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
|
||||
@@ -240,6 +253,18 @@
|
||||
{#if output}
|
||||
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
|
||||
{/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'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
|
||||
{/if}
|
||||
|
||||
@@ -221,6 +221,25 @@ export const de: Dict = {
|
||||
'lightbox.videoPreview': 'Videovorschau',
|
||||
'lightbox.videoUnavailable': 'Video nicht verfügbar',
|
||||
'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.artifactLabel': 'Artefakt',
|
||||
|
||||
@@ -221,6 +221,25 @@ export const en = {
|
||||
'lightbox.videoPreview': 'Video preview',
|
||||
'lightbox.videoUnavailable': 'Video unavailable',
|
||||
'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.artifactLabel': 'Artifact',
|
||||
|
||||
@@ -221,6 +221,25 @@ export const es: Dict = {
|
||||
'lightbox.videoPreview': 'Vista previa de vídeo',
|
||||
'lightbox.videoUnavailable': 'Vídeo no disponible',
|
||||
'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.artifactLabel': 'Artefacto',
|
||||
|
||||
@@ -221,6 +221,25 @@ export const fr: Dict = {
|
||||
'lightbox.videoPreview': 'Aperçu de la vidéo',
|
||||
'lightbox.videoUnavailable': 'Vidéo indisponible',
|
||||
'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.artifactLabel': 'Artefact',
|
||||
|
||||
@@ -221,6 +221,23 @@ export const ja: Dict = {
|
||||
'lightbox.videoPreview': 'ビデオプレビュー',
|
||||
'lightbox.videoUnavailable': 'ビデオを再生できません',
|
||||
'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.artifactLabel': '成果物',
|
||||
|
||||
@@ -221,6 +221,23 @@ export const ko: Dict = {
|
||||
'lightbox.videoPreview': '영상 미리보기',
|
||||
'lightbox.videoUnavailable': '영상을 표시할 수 없습니다',
|
||||
'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.artifactLabel': '아티팩트',
|
||||
|
||||
@@ -221,6 +221,23 @@ export const zh: Dict = {
|
||||
'lightbox.videoPreview': '视频预览',
|
||||
'lightbox.videoUnavailable': '视频不可用',
|
||||
'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.artifactLabel': '产物',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -11,6 +11,8 @@ Mode file e2e/fake_mode.json:
|
||||
can be driven all-green (every class present) or missing-node (catalogue
|
||||
omits classes). Absent → {} (every node "missing").
|
||||
- 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
|
||||
import json, threading, uuid
|
||||
@@ -64,7 +66,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if self.path.startswith("/view"):
|
||||
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:
|
||||
self._raw(200, PNG_1PX, "image/png")
|
||||
return
|
||||
@@ -82,7 +85,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.server.accepted.append(pid)
|
||||
videos = mode.get("videos") 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"
|
||||
outputs = {} if no_outputs else {
|
||||
self._output_node(body): {key: [{"filename": out_name, "subfolder": "", "type": "output"}]}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user