Files
ai-video-editor/e2e/offline-export.spec.js

366 lines
17 KiB
JavaScript

import { expect, test } from "@playwright/test";
test("MOV export writes an H.264/AAC QuickTime file without re-encoding fallback", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
const result = await page.evaluate(async () => {
const { exportOfflineVideo } = await import("/src/lib/offlineVideoExport.js");
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
const context = canvas.getContext("2d");
context.fillStyle = "#184f72";
context.fillRect(0, 0, canvas.width, canvas.height);
const source = canvas.toDataURL("image/png");
const sampleRate = 8_000;
const sampleCount = sampleRate;
const wav = new ArrayBuffer(44 + sampleCount * 2);
const view = new DataView(wav);
const write = (offset, value) => [...value].forEach((character, index) => view.setUint8(offset + index, character.charCodeAt(0)));
write(0, "RIFF"); view.setUint32(4, 36 + sampleCount * 2, true); write(8, "WAVEfmt ");
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true); view.setUint16(34, 16, true); write(36, "data");
view.setUint32(40, sampleCount * 2, true);
for (let index = 0; index < sampleCount; index += 1) {
view.setInt16(44 + index * 2, Math.sin(index / sampleRate * Math.PI * 2 * 440) * 8_000, true);
}
const voice = new Blob([wav], { type: "audio/wav" });
const exported = await exportOfflineVideo({
imageSrc: source,
visualType: "image",
visualSegments: [{ id: "visual", src: source, type: "image", duration: 1 }],
voiceAudioSegments: [{ id: "voice", blob: voice, start: 0, duration: 1, volume: 1 }],
sourceAudioBlob: null,
sourceAudioSegments: [],
musicBlob: null,
text: "",
captionSegments: [],
duration: 1,
ratio: { width: 16, height: 9 },
fitMode: "cover",
filter: "none",
captionsEnabled: false,
captionSize: 12,
captionStyle: {},
captionReferenceSize: { width: 320, height: 180 },
sticker: null,
stickerSegments: [],
exportSettings: {
codec: "h264-mov",
width: 320,
height: 180,
frameRate: 30,
videoBitsPerSecond: 1_000_000,
audioBitsPerSecond: 128_000,
},
});
const bytes = new Uint8Array(await exported.blob.arrayBuffer());
const header = new TextDecoder("latin1").decode(bytes.slice(0, 64));
const audioContext = new AudioContext();
const decodedAudio = await audioContext.decodeAudioData(bytes.buffer.slice(0));
await audioContext.close();
const url = URL.createObjectURL(exported.blob);
const video = document.createElement("video");
video.muted = true;
video.src = url;
await new Promise((resolve, reject) => {
video.onloadedmetadata = resolve;
video.onerror = reject;
});
const metadata = {
type: exported.blob.type,
extension: exported.extension,
label: exported.label,
header,
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight,
audioDuration: decodedAudio.duration,
audioBitrate: exported.diagnostics.audioBitrate,
frameCount: exported.diagnostics.frameCount,
size: exported.blob.size,
};
URL.revokeObjectURL(url);
return metadata;
});
expect(result).toMatchObject({
type: "video/quicktime",
extension: "mov",
label: "MOV",
width: 320,
height: 180,
audioBitrate: 128_000,
frameCount: 30,
});
expect(result.header).toContain("ftyp");
expect(result.duration).toBeGreaterThanOrEqual(0.95);
expect(result.duration).toBeLessThanOrEqual(1.05);
expect(result.audioDuration).toBeGreaterThanOrEqual(0.95);
expect(result.size).toBeGreaterThan(10_000);
});
test("five-second timeline exports exactly 150 frames at 30 fps", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
const result = await page.evaluate(async () => {
const { exportOfflineVideo } = await import("/src/lib/offlineVideoExport.js");
const canvas = document.createElement("canvas");
canvas.width = 160;
canvas.height = 90;
const context = canvas.getContext("2d");
context.fillStyle = "#13978c";
context.fillRect(0, 0, canvas.width, canvas.height);
const source = canvas.toDataURL("image/png");
const exported = await exportOfflineVideo({
imageSrc: source,
visualType: "image",
visualSegments: [{ id: "visual", src: source, type: "image", duration: 5 }],
voiceAudioSegments: [],
sourceAudioBlob: null,
sourceAudioSegments: [],
musicBlob: null,
text: "A deliberately long script must not extend this five-second visual.",
captionSegments: [],
duration: 5,
ratio: { width: 16, height: 9 },
fitMode: "cover",
filter: "none",
captionsEnabled: false,
captionSize: 12,
captionStyle: {},
captionReferenceSize: { width: 160, height: 90 },
sticker: null,
stickerSegments: [],
exportSettings: {
codec: "vp9",
width: 160,
height: 90,
frameRate: 30,
videoBitsPerSecond: 1_000_000,
},
});
const url = URL.createObjectURL(exported.blob);
const video = document.createElement("video");
video.muted = true;
video.src = url;
await new Promise((resolve, reject) => {
video.onloadedmetadata = resolve;
video.onerror = reject;
});
const result = {
duration: video.duration,
frameCount: exported.diagnostics.frameCount,
size: exported.blob.size,
};
URL.revokeObjectURL(url);
return result;
});
expect(result.frameCount).toBe(150);
expect(result.duration).toBeGreaterThanOrEqual(4.95);
expect(result.duration).toBeLessThanOrEqual(5.05);
expect(result.size).toBeGreaterThan(1_000);
});
test("active deterministic export stops at the next frame boundary when canceled", async ({ page }) => {
await page.goto("/");
const result = await page.evaluate(async () => {
const { exportOfflineVideo } = await import("/src/lib/offlineVideoExport.js");
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
const context = canvas.getContext("2d");
context.fillStyle = "#13978c";
context.fillRect(0, 0, canvas.width, canvas.height);
const source = canvas.toDataURL("image/png");
const controller = new AbortController();
let renderedFrames = 0;
const startedAt = performance.now();
try {
await exportOfflineVideo({
imageSrc: source,
visualType: "image",
visualSegments: [{ id: "image", src: source, type: "image", duration: 60 }],
voiceAudioSegments: [],
sourceAudioBlob: null,
sourceAudioSegments: [],
musicBlob: null,
text: "",
captionSegments: [],
duration: 60,
ratio: { width: 16, height: 9 },
fitMode: "cover",
filter: "none",
captionsEnabled: false,
captionSize: 12,
captionStyle: {},
captionReferenceSize: { width: 320, height: 180 },
sticker: null,
stickerSegments: [],
signal: controller.signal,
onProgress: ({ phaseKey }) => {
if (phaseKey === "exportOfflineRendering") {
renderedFrames += 1;
controller.abort();
}
},
exportSettings: {
codec: "vp9",
width: 320,
height: 180,
frameRate: 30,
videoBitsPerSecond: 1_000_000,
},
});
return { completed: true };
} catch (error) {
return {
completed: false,
name: error?.name,
renderedFrames,
elapsed: performance.now() - startedAt,
};
}
});
expect(result).toMatchObject({ completed: false, name: "AbortError", renderedFrames: 1 });
expect(result.elapsed).toBeLessThan(5_000);
});
test("offline export preserves stickers, captions, voice audio, dimensions and duration", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
const result = await page.evaluate(async () => {
const { exportOfflineVideo } = await import("/src/lib/offlineVideoExport.js");
const makeImage = (draw) => {
const canvas = document.createElement("canvas");
canvas.width = 320; canvas.height = 180;
draw(canvas.getContext("2d"), canvas);
return canvas.toDataURL("image/png");
};
const background = makeImage((context, canvas) => {
context.fillStyle = "#c20d18"; context.fillRect(0, 0, canvas.width, canvas.height);
});
const sticker = makeImage((context) => {
context.clearRect(0, 0, 320, 180);
context.fillStyle = "#00ffff"; context.fillRect(120, 50, 80, 80);
});
const overlay = makeImage((context, canvas) => {
context.fillStyle = "#246bff"; context.fillRect(0, 0, canvas.width, canvas.height);
});
const sampleRate = 48_000;
const frames = sampleRate;
const wav = new ArrayBuffer(44 + frames * 2);
const view = new DataView(wav);
const text = (offset, value) => [...value].forEach((char, index) => view.setUint8(offset + index, char.charCodeAt(0)));
text(0, "RIFF"); view.setUint32(4, 36 + frames * 2, true); text(8, "WAVEfmt ");
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true); view.setUint16(34, 16, true); text(36, "data"); view.setUint32(40, frames * 2, true);
for (let index = 0; index < frames; index += 1) view.setInt16(44 + index * 2, Math.sin(index / sampleRate * Math.PI * 2 * 440) * 8000, true);
const voice = new Blob([wav], { type: "audio/wav" });
const exported = await exportOfflineVideo({
imageSrc: background, visualType: "image",
visualSegments: [{ id: "visual", src: background, type: "image", duration: 1 }],
voiceAudioSegments: [{ id: "voice", blob: voice, start: 0, duration: 1, volume: 1 }],
sourceAudioBlob: null, sourceAudioSegments: [], musicBlob: null,
text: "", captionSegments: [{ id: "caption", text: "E2E CAPTION", start: 0, end: 1 }],
duration: 1, ratio: { width: 16, height: 9 }, fitMode: "cover", filter: "none",
captionsEnabled: true, captionPosition: "bottom", captionPlacement: "bottom", captionSize: 12,
captionStyle: { textColor: "#ffffff", backgroundColor: "#000000", backgroundOpacity: 0.8, fontWeight: 700 },
captionReferenceSize: { width: 320, height: 180 },
sticker: null,
stickerSegments: [{ id: "sticker", src: sticker, start: 0, duration: 1, x: 50, y: 50, scale: 2, opacity: 1 }],
visualOverlaySegments: [{ id: "overlay", src: overlay, type: "image", start: 0, duration: 1, layer: 1, keyframes: [{ time: 0, x: 30, y: -25, scale: 0.3, rotation: 0, opacity: 1 }] }],
exportSettings: { codec: "vp9", width: 320, height: 180, frameRate: 30, videoBitsPerSecond: 3_000_000, audioBitsPerSecond: 128_000 },
});
const audioContext = new AudioContext();
const decodedAudio = await audioContext.decodeAudioData((await exported.blob.arrayBuffer()).slice(0));
await audioContext.close();
const url = URL.createObjectURL(exported.blob);
const video = document.createElement("video");
video.muted = true; video.src = url;
await new Promise((resolve, reject) => { video.onloadedmetadata = resolve; video.onerror = reject; });
video.currentTime = 0.5;
await new Promise((resolve, reject) => { video.onseeked = resolve; video.onerror = reject; });
const sample = document.createElement("canvas"); sample.width = 320; sample.height = 180;
const context = sample.getContext("2d"); context.drawImage(video, 0, 0);
const center = [...context.getImageData(160, 90, 1, 1).data];
const lower = [...context.getImageData(160, 156, 1, 1).data];
const overlayPixel = [...context.getImageData(256, 45, 1, 1).data];
const metadata = {
width: video.videoWidth, height: video.videoHeight,
duration: decodedAudio.duration, hasAudio: decodedAudio.length > 0,
mediaDuration: video.duration, center, lower, overlayPixel, size: exported.blob.size,
diagnostics: exported.diagnostics,
};
URL.revokeObjectURL(url);
return metadata;
});
expect(result.width).toBe(320);
expect(result.height).toBe(180);
expect(result.duration).toBeGreaterThanOrEqual(0.95);
expect(result.mediaDuration).toBeGreaterThanOrEqual(0.95);
expect(result.hasAudio).toBe(true);
expect(result.size).toBeGreaterThan(10_000);
expect(result.center[1]).toBeGreaterThan(result.center[0]);
expect(result.center[2]).toBeGreaterThan(result.center[0]);
expect(result.lower[0] + result.lower[1] + result.lower[2]).toBeGreaterThan(40);
expect(result.overlayPixel[2]).toBeGreaterThan(result.overlayPixel[0]);
expect(result.diagnostics.frameCount).toBe(30);
expect(result.diagnostics.audioBitrate).toBe(128_000);
});
test("offline export decodes video sequentially instead of seeking every output frame", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
const result = await page.evaluate(async () => {
const { exportOfflineVideo } = await import("/src/lib/offlineVideoExport.js");
const sourceCanvas = document.createElement("canvas"); sourceCanvas.width = 160; sourceCanvas.height = 90;
const sourceContext = sourceCanvas.getContext("2d");
sourceContext.fillStyle = "#000"; sourceContext.fillRect(0, 0, 160, 90);
sourceContext.fillStyle = "#fff"; sourceContext.beginPath(); sourceContext.arc(80, 45, 25, 0, Math.PI * 2); sourceContext.fill();
const sourceImage = sourceCanvas.toDataURL("image/png");
const sourceExport = await exportOfflineVideo({
imageSrc: sourceImage, visualType: "image", visualSegments: [{ id: "image", src: sourceImage, type: "image", duration: 0.6 }],
voiceAudioSegments: [], sourceAudioBlob: null, sourceAudioSegments: [], musicBlob: null, text: "", captionSegments: [],
duration: 0.6, ratio: { width: 16, height: 9 }, fitMode: "cover", filter: "none", captionsEnabled: false,
captionSize: 12, captionStyle: {}, captionReferenceSize: { width: 160, height: 90 }, sticker: null, stickerSegments: [],
exportSettings: { codec: "vp9", width: 160, height: 90, frameRate: 24, videoBitsPerSecond: 1_000_000 },
});
const sourceBlob = sourceExport.blob;
const sourceUrl = URL.createObjectURL(sourceBlob);
const exported = await exportOfflineVideo({
imageSrc: sourceUrl, visualType: "video",
visualSegments: [{ id: "video", src: sourceUrl, blob: sourceBlob, type: "video", duration: 0.6, sourceDuration: 0.6, sourceStart: 0, playbackRate: 1 }],
voiceAudioSegments: [], sourceAudioBlob: null, sourceAudioSegments: [], musicBlob: null,
text: "", captionSegments: [], duration: 0.6, ratio: { width: 16, height: 9 },
fitMode: "cover", filter: "none", captionsEnabled: false, captionSize: 12,
captionStyle: {}, captionReferenceSize: { width: 160, height: 90 }, sticker: null, stickerSegments: [],
exportSettings: { codec: "vp9", width: 160, height: 90, frameRate: 24, videoBitsPerSecond: 1_000_000 },
});
const exportedUrl = URL.createObjectURL(exported.blob);
const video = document.createElement("video"); video.muted = true; video.src = exportedUrl;
await new Promise((resolve, reject) => { video.onloadedmetadata = resolve; video.onerror = reject; });
video.currentTime = 0.3;
await new Promise((resolve, reject) => { video.onseeked = resolve; video.onerror = reject; });
const checkCanvas = document.createElement("canvas"); checkCanvas.width = 160; checkCanvas.height = 90;
const checkContext = checkCanvas.getContext("2d"); checkContext.drawImage(video, 0, 0);
const pixels = checkContext.getImageData(0, 0, 160, 90).data;
let minX = 160; let maxX = 0; let minY = 90; let maxY = 0;
for (let y = 0; y < 90; y += 1) for (let x = 0; x < 160; x += 1) {
const offset = (y * 160 + x) * 4;
if (pixels[offset] > 180 && pixels[offset + 1] > 180 && pixels[offset + 2] > 180) {
minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y);
}
}
URL.revokeObjectURL(sourceUrl); URL.revokeObjectURL(exportedUrl);
return { size: exported.blob.size, diagnostics: exported.diagnostics, shapeRatio: (maxX - minX + 1) / (maxY - minY + 1) };
});
expect(result.size).toBeGreaterThan(500);
expect(result.diagnostics.videoDecodeModes).toEqual(["sequential-webcodecs"]);
expect(result.diagnostics.frameCount).toBe(15);
expect(result.shapeRatio).toBeGreaterThan(0.9);
expect(result.shapeRatio).toBeLessThan(1.1);
});