Release v0.7.0: expand media compatibility and export

This commit is contained in:
haixin.yang
2026-07-24 14:12:31 +08:00
parent a0892e5367
commit 4f1916d2a6
48 changed files with 3760 additions and 288 deletions
+7
View File
@@ -20,6 +20,13 @@ test.describe("Auto Edit desktop video smoke", () => {
destroy() {},
}),
};
window.Translator = {
availability: async () => "available",
create: async ({ targetLanguage }) => ({
translate: async (text) => targetLanguage === "zh" ? `中文:${text}` : text,
destroy() {},
}),
};
});
await page.goto("/");
const languageIntro = page.locator(".language-intro");
+93
View File
@@ -0,0 +1,93 @@
import { expect, test } from "@playwright/test";
for (const pipeline of ["offline", "compatible"]) {
test(`${pipeline} export crops audio source offsets with the selected range`, async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
const result = await page.evaluate(async (selectedPipeline) => {
const [{ exportOfflineVideo }, { exportBrowserVideo }] = await Promise.all([
import("/src/lib/offlineVideoExport.js"),
import("/src/lib/media.js"),
]);
const canvas = document.createElement("canvas");
canvas.width = 160;
canvas.height = 90;
const context = canvas.getContext("2d");
context.fillStyle = "#143548";
context.fillRect(0, 0, canvas.width, canvas.height);
const image = canvas.toDataURL("image/png");
const sampleRate = 8_000;
const sampleCount = sampleRate * 2;
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) {
const frequency = index < sampleRate ? 220 : 880;
view.setInt16(44 + index * 2, Math.sin(index / sampleRate * Math.PI * 2 * frequency) * 12_000, true);
}
const voice = new Blob([wav], { type: "audio/wav" });
const options = {
imageSrc: image,
visualType: "image",
visualSegments: [{ id: "image", src: image, type: "image", duration: 2 }],
voiceAudioSegments: [{ id: "voice", blob: voice, start: 0, duration: 2, volume: 1 }],
sourceAudioBlob: null,
sourceAudioSegments: [],
musicBlob: null,
text: "",
captionSegments: [],
duration: 0.5,
timelineOffset: 1,
captionTargetDuration: 2,
ratio: { width: 16, height: 9 },
fitMode: "cover",
filter: "none",
captionsEnabled: false,
captionSize: 12,
captionStyle: {},
captionReferenceSize: { width: 160, height: 90 },
sticker: null,
stickerSegments: [],
visualOverlaySegments: [],
exportSettings: {
codec: "vp9",
width: 160,
height: 90,
frameRate: 24,
videoBitsPerSecond: 1_000_000,
audioBitsPerSecond: 128_000,
keyFrameInterval: 1,
},
};
const exported = selectedPipeline === "offline"
? await exportOfflineVideo(options)
: await exportBrowserVideo(options);
const audioContext = new AudioContext();
const decoded = await audioContext.decodeAudioData((await exported.blob.arrayBuffer()).slice(0));
const samples = decoded.getChannelData(0);
let positiveCrossings = 0;
for (let index = 1; index < samples.length; index += 1) {
if (samples[index - 1] <= 0 && samples[index] > 0) positiveCrossings += 1;
}
const result = { duration: decoded.duration, positiveCrossings, sampleRate: decoded.sampleRate };
await audioContext.close();
return result;
}, pipeline);
expect(result.duration).toBeGreaterThan(0.35);
expect(result.duration).toBeLessThan(0.8);
expect(result.positiveCrossings).toBeGreaterThan(300);
});
}
+52
View File
@@ -0,0 +1,52 @@
import { expect, test } from "@playwright/test";
const ONE_PIXEL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
"base64",
);
for (const viewport of [
{ name: "desktop", width: 1280, height: 720 },
{ name: "mobile", width: 412, height: 915 },
]) {
test(`${viewport.name} can cancel an active compatibility export without downloading`, async ({ page }) => {
test.setTimeout(60_000);
await page.setViewportSize(viewport);
await page.addInitScript(() => {
localStorage.clear();
localStorage.setItem("ai-voiceover-ui-language", "zh");
localStorage.setItem("timeline-studio-first-visual-guide-seen-v1", "1");
// Exercise cancellation without depending on the removed pipeline selector.
// Disabling WebCodecs makes automatic export use the cancellable
// MediaRecorder compatibility path in every browser environment.
Object.defineProperty(window, "VideoEncoder", {
configurable: true,
value: undefined,
});
});
const downloads = [];
page.on("download", (download) => downloads.push(download.suggestedFilename()));
await page.goto("/");
await page.locator('input[type="file"][multiple]').setInputFiles({
name: "cancel-export.png",
mimeType: "image/png",
buffer: ONE_PIXEL_PNG,
});
await expect(page.locator(".image-clip")).toBeVisible();
await page.getByRole("button", { name: "导出视频" }).click();
const settings = page.getByRole("dialog");
const start = settings.getByRole("button", { name: "开始导出" });
await expect(start).toBeEnabled();
await start.click();
const progress = page.getByRole("dialog", { name: "导出中" });
await expect(progress).toBeVisible();
await progress.getByRole("button", { name: "取消导出" }).click({ force: true });
await expect(progress).toBeHidden({ timeout: 5_000 });
await expect(page.locator(".toast")).toHaveText("导出已取消");
await page.waitForTimeout(500);
expect(downloads).toEqual([]);
});
}
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from "@playwright/test";
for (const pipeline of ["offline", "compatible"]) {
test(`${pipeline} export renders the selected timeline range and rebases duration`, async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
const result = await page.evaluate(async (selectedPipeline) => {
const [{ exportOfflineVideo }, { exportBrowserVideo }, { ALL_FORMATS, BlobSource, CanvasSink, EncodedPacketSink, Input }] = await Promise.all([
import("/src/lib/offlineVideoExport.js"),
import("/src/lib/media.js"),
import("/node_modules/.vite/deps/mediabunny.js"),
]);
const makeImage = (color) => {
const canvas = document.createElement("canvas");
canvas.width = 160;
canvas.height = 90;
const context = canvas.getContext("2d");
context.fillStyle = color;
context.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
};
const red = makeImage("#e4212b");
const blue = makeImage("#1769ef");
const green = makeImage("#19d18f");
const options = {
imageSrc: red,
visualType: "image",
visualSegments: [
{ id: "red", src: red, type: "image", duration: 1 },
{ id: "blue", src: blue, type: "image", duration: 1 },
],
voiceAudioSegments: [],
sourceAudioBlob: null,
sourceAudioSegments: [],
musicBlob: null,
text: "",
captionSegments: [],
duration: 0.5,
timelineOffset: 1,
captionTargetDuration: 2,
ratio: { width: 16, height: 9 },
fitMode: "cover",
filter: "none",
captionsEnabled: false,
captionSize: 12,
captionStyle: {},
captionReferenceSize: { width: 160, height: 90 },
sticker: null,
stickerSegments: [],
visualOverlaySegments: [{
id: "overlay",
src: green,
type: "image",
start: 1,
duration: 0.5,
layer: 1,
keyframes: [{ time: 0, x: 25, y: 0, scale: 0.25, rotation: 0, opacity: 1 }],
}],
exportSettings: {
codec: "vp9",
width: 160,
height: 90,
frameRate: 24,
videoBitsPerSecond: 1_000_000,
keyFrameInterval: 1,
},
};
const exported = selectedPipeline === "offline"
? await exportOfflineVideo(options)
: await exportBrowserVideo(options);
const input = new Input({ source: new BlobSource(exported.blob), formats: ALL_FORMATS });
const track = await input.getPrimaryVideoTrack();
const lastPacket = await new EncodedPacketSink(track).getPacket(Infinity);
const duration = lastPacket.timestamp + lastPacket.duration;
const sink = new CanvasSink(track);
const frame = await sink.getCanvas(0.2);
const sample = frame.canvas;
const context = sample.getContext("2d");
const pixel = [...context.getImageData(20, 45, 1, 1).data];
const overlayPixel = [...context.getImageData(120, 45, 1, 1).data];
const metadata = {
duration,
width: sample.width,
height: sample.height,
pixel,
overlayPixel,
size: exported.blob.size,
};
return metadata;
}, pipeline);
expect(result.width).toBe(160);
expect(result.height).toBe(90);
expect(result.duration).toBeGreaterThan(0.35);
expect(result.duration).toBeLessThan(0.8);
expect(result.pixel[2]).toBeGreaterThan(result.pixel[0] * 2);
expect(result.overlayPixel[1]).toBeGreaterThan(result.overlayPixel[0] * 2);
expect(result.overlayPixel[1]).toBeGreaterThan(result.overlayPixel[2]);
expect(result.size).toBeGreaterThan(500);
});
}
+83
View File
@@ -0,0 +1,83 @@
import { expect, test } from "@playwright/test";
const VIEWPORTS = [
{ name: "desktop", width: 1280, height: 720 },
{ name: "mobile", width: 412, height: 915 },
];
for (const viewport of VIEWPORTS) {
test(`${viewport.name} export settings stay simple`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.addInitScript(() => {
if (!sessionStorage.getItem("simple-export-test-initialized")) {
localStorage.clear();
sessionStorage.setItem("simple-export-test-initialized", "1");
}
localStorage.setItem("ai-voiceover-ui-language", "zh");
});
await page.goto("/");
await page.getByRole("button", { name: "导出视频" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("button", { name: "请先添加画面素材" })).toBeVisible();
await expect(dialog.getByRole("textbox", { name: "文件名" })).toBeVisible();
await expect(dialog.getByRole("combobox", { name: "分辨率" })).toHaveValue("1080");
await expect(dialog.getByRole("combobox", { name: "导出格式" })).toHaveValue("h264");
await expect(dialog.getByRole("combobox", { name: "导出格式" }).locator("option")).toContainText([
"MP4 · H.264",
"MOV · H.264",
"WebM · VP9",
"WebM · VP8",
]);
await expect(dialog.getByRole("combobox", { name: "视频码率" })).toHaveValue("auto");
await expect(dialog.getByRole("combobox", { name: "音频码率" })).toHaveValue("192000");
await expect(dialog.getByText("导出范围")).toHaveCount(0);
await expect(dialog.getByText("帧率")).toHaveCount(0);
await expect(dialog.getByText("画质")).toHaveCount(0);
await expect(dialog.getByText("关键帧间隔")).toHaveCount(0);
await expect(dialog.getByText("导出策略")).toHaveCount(0);
await expect(dialog.getByText("导出预设")).toHaveCount(0);
await expect(dialog.getByText("批量导出")).toHaveCount(0);
await expect(dialog.getByText("最近导出")).toHaveCount(0);
await dialog.getByRole("textbox", { name: "文件名" }).fill("Launch / Final.mp4");
await dialog.getByRole("combobox", { name: "导出格式" }).selectOption("h264-mov");
await dialog.getByRole("combobox", { name: "视频码率" }).selectOption("20");
await dialog.getByRole("combobox", { name: "音频码率" }).selectOption("320000");
await expect(dialog).toContainText("MOV");
await expect(dialog).toContainText("H.264 + AAC");
await expect(dialog).toContainText("20 Mbps");
const layout = await dialog.evaluate((element) => {
const rect = element.getBoundingClientRect();
const scrollableDescendants = Array.from(element.querySelectorAll("*")).filter((node) => {
const style = getComputedStyle(node);
return /(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1;
});
return {
rect: rect.toJSON(),
viewport: { width: innerWidth, height: innerHeight },
scrollableDescendants: scrollableDescendants.length,
};
});
expect(layout.rect.left).toBeGreaterThanOrEqual(0);
expect(layout.rect.right).toBeLessThanOrEqual(layout.viewport.width);
expect(layout.rect.top).toBeGreaterThanOrEqual(0);
expect(layout.rect.bottom).toBeLessThanOrEqual(layout.viewport.height);
expect(layout.scrollableDescendants).toBeLessThanOrEqual(1);
if (viewport.name === "desktop") {
await expect.poll(() => page.evaluate(() => localStorage.getItem("timeline-studio-export-settings-v1"))).toContain("20000000");
await page.reload();
await page.getByRole("button", { name: "导出视频" }).click();
const restored = page.getByRole("dialog");
await expect(restored.getByRole("textbox", { name: "文件名" })).toHaveValue("Launch - Final");
await expect(restored.getByRole("combobox", { name: "导出格式" })).toHaveValue("h264-mov");
await expect(restored.getByRole("combobox", { name: "视频码率" })).toHaveValue("20");
await expect(restored.getByRole("combobox", { name: "音频码率" })).toHaveValue("320000");
}
});
}
+229 -1
View File
@@ -1,5 +1,232 @@
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("/");
@@ -45,7 +272,7 @@ test("offline export preserves stickers, captions, voice audio, dimensions and d
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 },
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));
@@ -81,6 +308,7 @@ test("offline export preserves stickers, captions, voice audio, dimensions and d
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 }) => {
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
export default [
{
ignores: ["dist/**", "node_modules/**", "public/vendor/**", ".npm-cache/**"],
ignores: ["dist/**", "node_modules/**", "public/vendor/**", "src/vendor/**", ".npm-cache/**"],
},
js.configs.recommended,
{
+9 -2
View File
@@ -1,12 +1,12 @@
{
"name": "web-player",
"version": "0.6.0",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web-player",
"version": "0.6.0",
"version": "0.7.0",
"license": "MIT",
"dependencies": {
"@diffusionstudio/vits-web": "^1.0.3",
@@ -14,6 +14,7 @@
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@huggingface/transformers": "^3.8.1",
"@libav.js/variant-webcodecs": "^6.9.8",
"@mediabunny/aac-encoder": "^1.50.8",
"@phosphor-icons/react": "^2.1.10",
"fflate": "^0.8.3",
@@ -1620,6 +1621,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@libav.js/variant-webcodecs": {
"version": "6.9.8",
"resolved": "https://registry.npmmirror.com/@libav.js/variant-webcodecs/-/variant-webcodecs-6.9.8.tgz",
"integrity": "sha512-kU8IMhJFO4LpwEFQyNzjG7OO3OIa0HZMj30YEhSXiziY4PHHpFWCCcx5ETT1eNeZCjZvuG6UbUIVl3Bf2NXuAw==",
"license": "LGPL-2.1"
},
"node_modules/@mediabunny/aac-encoder": {
"version": "1.50.8",
"resolved": "https://registry.npmmirror.com/@mediabunny/aac-encoder/-/aac-encoder-1.50.8.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web-player",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"description": "A local-first browser AI video editor for voiceovers, captions, talking avatars, and multi-track timeline export.",
"license": "MIT",
@@ -34,6 +34,7 @@
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@huggingface/transformers": "^3.8.1",
"@libav.js/variant-webcodecs": "^6.9.8",
"@mediabunny/aac-encoder": "^1.50.8",
"@phosphor-icons/react": "^2.1.10",
"fflate": "^0.8.3",
+45 -9
View File
@@ -66,7 +66,13 @@ import { getImageThumbnailCount, getVisualSegmentsTotal, normalizeTimedSegmentId
import { normalizeVisualTransform, removeVisualPropertyKeyframe, updateVisualSegmentPlaybackRate, upsertVisualKeyframe, upsertVisualPropertyKeyframe } from "./lib/visualEffects.js";
import { getLinkedSourceAudioEnd, getLinkedSourceAudioSegments, shouldMuteEmbeddedVideoAudio } from "./lib/sourceAudioSync.js";
import { getTimelineInitialContentZoom } from "./lib/timelineScale.js";
import { getExportBitrate, getExportDimensions } from "./lib/exportSettings.js";
import {
getExportContentDuration,
getExportDimensions,
getEffectiveExportBitrate,
loadExportSettings,
saveExportSettings,
} from "./lib/exportSettings.js";
import { createVisualOverlaySegment, getVisualOverlayPreset, updateVisualOverlayTransform } from "./lib/visualOverlayTimeline.js";
import { getMobileClipPanelOrigin } from "./lib/mobileClipActions.js";
@@ -77,7 +83,10 @@ export function App() {
const mobilePanelTimerRef = useRef(null);
const [mobilePanelOrigin, setMobilePanelOrigin] = useState("");
const [showExportMenu, setShowExportMenu] = useState(false);
const [exportSettings, setExportSettings] = useState({ resolution: "1080", frameRate: 30, codec: "h264", quality: "high" });
const [exportSettings, setExportSettings] = useState(() => loadExportSettings());
useEffect(() => {
saveExportSettings(exportSettings);
}, [exportSettings]);
const [captionVoiceFocusRequest, setCaptionVoiceFocusRequest] = useState(0);
const [selectedSourceAudioSegmentId, setSelectedSourceAudioSegmentId] = useState("");
const [selectedMusicSegmentId, setSelectedMusicSegmentId] = useState("");
@@ -169,7 +178,7 @@ export function App() {
assetDropPulseTimerRef, audioRef, audioSegmentRefs, audioUrlRef, autoRatioSourceKeyRef,
avatarMotionCacheRef, avatarMotionWorkerRef, avatarRenderWorkerRef,
avatarTestAudioImportedRef, avatarTestImportedRef, currentTimeRef, draggedAssetIdRef,
exportStartRef, fileInputRef, imageUrlRefs, musicRef, musicUrlRef, pointerAssetDragRef,
exportAbortControllerRef, exportStartRef, fileInputRef, imageUrlRefs, musicRef, musicUrlRef, pointerAssetDragRef,
previewCanvasRef, previewShellRef, previewVideoRef, projectFileInputRef, sourceAudioRef,
sourceAudioUrlRef, suppressAssetClickRef, suppressTimelineClipClickRef,
timelineClipDragRef, timelineDurationRef, trackScrollRef, visionAbortControllerRef,
@@ -202,6 +211,12 @@ export function App() {
});
const activeLanguage = uiLanguage || "zh";
const t = useMemo(() => createTranslator(activeLanguage), [activeLanguage]);
const handleCancelExport = () => {
const controller = exportAbortControllerRef.current;
if (!controller || controller.signal.aborted) return;
setExportPhase(t("exportCanceling"));
controller.abort();
};
const trOption = (name, option) => {
if (option?.kind === "stickerCategory") {
return activeLanguage !== "zh" && option.nameEn ? option.nameEn : name;
@@ -675,7 +690,7 @@ export function App() {
const extractVideoSourceAudio = useSourceAudioExtraction({
clearSourceAudioTrack, notify, replaceSourceAudio, setProgress, setStatus, setStatusText,
setVisualSegments, sourceAudioBlob, sourceAudioDuration,
setVisualSegments, sourceAudioBlob, sourceAudioDuration, t,
});
const generateCaptionsFromSourceAudio = useAutoCaptions({
@@ -687,7 +702,7 @@ export function App() {
const handleFiles = useFileUpload({
appendVisualAssetToTimeline, imageUrlRefs, notify, setSelectedLibraryAssetId, setSelectedTrack, setUserAssets,
updateVisualAssetInTimeline, visualSegments,
updateVisualAssetInTimeline, visualSegments, t,
onFirstVisualAutoAdded: requestFirstVisualGuide,
});
@@ -764,9 +779,21 @@ export function App() {
linkedSourceAudioSegments, sourceAudioDuration, sourceAudioLinked, sourceAudioStart, stickerSegments, timelineClipDrag,
timelineDuration, visualSegments,
});
const exportContentDuration = useMemo(() => getExportContentDuration({
visualDuration: imageDuration,
voiceDuration: voiceTrackDuration,
captionDuration,
sourceAudioDuration: sourceAudioBlob ? sourceAudioTimelineEnd : 0,
musicDuration: musicBlob ? musicTimelineEnd : 0,
stickerDuration,
overlaySegments: visualOverlaySegments,
}), [
captionDuration, imageDuration, musicBlob, musicTimelineEnd, sourceAudioBlob,
sourceAudioTimelineEnd, stickerDuration, visualOverlaySegments, voiceTrackDuration,
]);
const handleExportVideo = useVideoExport({
audioSegments, captionDuration, captionPlacement, captionPosition, captionSegments,
captionSize, captionStyle, captionsEnabled, exporting, exportStartRef, fitMode,
audioSegments, captionDuration, captionPlacement, captionPosition, captionSegments, captionTargetDuration,
captionSize, captionStyle, captionsEnabled, exporting, exportAbortControllerRef, exportStartRef, fitMode,
imageDuration, imageSrc, musicBlob, musicDuration, musicSegments, musicStart, musicTimelineEnd, musicVolume, notify,
previewFrameSize, ratio, renderedVisualSegments, script, selectedFilter,
selectedSticker, selectedTransitionId, setExporting, setExportPhase,
@@ -775,7 +802,7 @@ export function App() {
trackVisibility, visionRecords, visualType, voiceTrackDuration, volume, exportSettings: {
...exportSettings,
...getExportDimensions(ratio, Number(exportSettings.resolution)),
videoBitsPerSecond: getExportBitrate(Number(exportSettings.resolution), exportSettings.quality, exportSettings.frameRate),
videoBitsPerSecond: getEffectiveExportBitrate(exportSettings),
},
visualOverlaySegments, t,
});
@@ -833,6 +860,7 @@ export function App() {
setShowExportMenu={setShowExportMenu}
exportSettings={exportSettings}
setExportSettings={setExportSettings}
timelineDuration={exportContentDuration}
showSettings={showSettings}
setShowSettings={setShowSettings}
activeLanguage={activeLanguage}
@@ -1230,7 +1258,15 @@ export function App() {
{mobilePanel ? <button className="mobile-sheet-backdrop" type="button" aria-label={t("close", "关闭")} onClick={() => changeMobilePanel("")} /> : null}
<AssetDragPreview preview={assetDragPreview} t={t} />
<ExportProgressOverlay exporting={exporting} percent={exportPercent} phase={exportPhase} elapsedSeconds={exportElapsedSeconds} t={t} />
<ExportProgressOverlay
exporting={exporting}
percent={exportPercent}
phase={exportPhase}
elapsedSeconds={exportElapsedSeconds}
onCancel={handleCancelExport}
canceling={exportPhase === t("exportCanceling")}
t={t}
/>
{showFirstVisualGuide && !shouldShowLanguageIntro ? (
<FirstVisualGuide
language={activeLanguage}
+8 -3
View File
@@ -14,14 +14,19 @@ export function AssetDragPreview({ preview, t }) {
</div>;
}
export function ExportProgressOverlay({ exporting, percent, phase, elapsedSeconds, t }) {
export function ExportProgressOverlay({ exporting, percent, phase, elapsedSeconds, onCancel, canceling, t }) {
if (!exporting) return null;
return <div className="export-progress-overlay" role="status" aria-live="polite"><div className="export-progress-card">
<div className="export-progress-header"><span>{t("exportInProgress")}</span><strong>{percent}%</strong></div>
return <div className="export-progress-overlay" role="dialog" aria-modal="true" aria-labelledby="export-progress-title"><div className="export-progress-card">
<div className="export-progress-header"><span id="export-progress-title">{t("exportInProgress")}</span><strong>{percent}%</strong></div>
<div className="export-progress-bar" role="progressbar" aria-label={t("exportProgress")} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}>
<span style={{ width: `${percent}%` }} />
</div>
<div className="export-progress-meta"><span>{phase || t("preparingExport")}</span><span>{formatClock(elapsedSeconds)}</span></div>
{percent < 100 ? (
<button className="export-progress-cancel" type="button" disabled={canceling} onClick={onCancel}>
{canceling ? t("exportCanceling") : t("exportCancel")}
</button>
) : null}
</div></div>;
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useState } from "react";
import { FileArrowDown } from "@phosphor-icons/react";
import {
formatEstimatedFileSize,
getExportEstimate,
getExportFormatProfile,
getExportRuntimeCapabilities,
getExportTechnicalSummary,
probeExportRuntimeCapabilities,
} from "../lib/exportSettings.js";
const VIDEO_BITRATE_OPTIONS = [
["auto", 0],
["5", 5_000_000],
["8", 8_000_000],
["12", 12_000_000],
["20", 20_000_000],
["40", 40_000_000],
];
const SIMPLE_EXPORT_DEFAULTS = Object.freeze({
frameRate: 30,
quality: "high",
pipeline: "auto",
audio: "mix",
captions: "burned",
range: "full",
keyFrameInterval: 2,
});
const withSimpleExportDefaults = (settings) => ({
...settings,
...SIMPLE_EXPORT_DEFAULTS,
});
export function ExportSettingsPanel({
t,
ratio,
imageSrc,
timelineDuration,
exportSettings,
setExportSettings,
handleExportVideo,
onClose,
}) {
const summary = getExportTechnicalSummary(exportSettings, ratio);
const estimate = getExportEstimate({ ...exportSettings, range: "full" }, ratio, timelineDuration);
const format = getExportFormatProfile(exportSettings.codec);
const selectedVideoBitrate = exportSettings.bitrateMode === "custom"
? String(Math.round((Number(exportSettings.customVideoBitsPerSecond) || 12_000_000) / 1_000_000))
: "auto";
const [capabilities, setCapabilities] = useState(() => getExportRuntimeCapabilities());
const [checking, setChecking] = useState(false);
useEffect(() => {
let active = true;
setChecking(true);
probeExportRuntimeCapabilities(withSimpleExportDefaults(exportSettings), ratio).then((next) => {
if (!active) return;
setCapabilities(next);
setChecking(false);
});
return () => { active = false; };
}, [exportSettings, ratio]);
const runtimeAvailable = exportSettings.codec === "h264-mov"
? capabilities.deterministic
: capabilities.deterministic || capabilities.compatible;
const update = (patch) => setExportSettings((current) => withSimpleExportDefaults({ ...current, ...patch }));
return (
<>
<div className="export-settings-card export-settings-card-simple">
<div className="export-settings-heading">
<div><strong>{t("videoExport")}</strong><small>{t("videoExportHint")}</small></div>
<span>{format.container}</span>
</div>
<label className="export-setting-field">
<span>{t("exportFileName")}</span>
<input
maxLength={96}
value={exportSettings.fileName || ""}
placeholder="ai-voiceover"
onChange={(event) => update({ fileName: event.target.value })}
/>
</label>
<div className="export-setting-grid">
<label className="export-setting-field">
<span>{t("exportResolution")}</span>
<select value={exportSettings.resolution} onChange={(event) => update({ resolution: event.target.value })}>
<option value="720">720p</option>
<option value="1080">1080p</option>
<option value="1440">2K</option>
<option value="2160">4K</option>
</select>
</label>
<label className="export-setting-field">
<span>{t("exportFormat")}</span>
<select value={exportSettings.codec} onChange={(event) => update({ codec: event.target.value })}>
<option value="h264">MP4 · H.264</option>
<option value="h264-mov">MOV · H.264</option>
<option value="vp9">WebM · VP9</option>
<option value="vp8">WebM · VP8</option>
</select>
</label>
</div>
<div className="export-setting-grid">
<label className="export-setting-field">
<span>{t("exportVideoBitrate")}</span>
<select value={selectedVideoBitrate} onChange={(event) => {
const option = VIDEO_BITRATE_OPTIONS.find(([id]) => id === event.target.value);
update(option?.[0] === "auto"
? { bitrateMode: "auto" }
: { bitrateMode: "custom", customVideoBitsPerSecond: option?.[1] || 12_000_000 });
}}>
{VIDEO_BITRATE_OPTIONS.map(([id]) => (
<option key={id} value={id}>{id === "auto" ? t("exportBitrateAuto") : `${id} Mbps`}</option>
))}
</select>
</label>
<label className="export-setting-field">
<span>{t("exportAudioBitrate")}</span>
<select
value={exportSettings.audioBitsPerSecond || 192_000}
onChange={(event) => update({ audioBitsPerSecond: Number(event.target.value) })}
>
<option value="128000">128 kbps</option>
<option value="192000">192 kbps</option>
<option value="256000">256 kbps</option>
<option value="320000">320 kbps</option>
</select>
</label>
</div>
<div className="export-technical-summary export-technical-summary-simple">
<span>{summary.width} × {summary.height}</span>
<span>30 fps</span>
<span>{summary.bitrateMbps} Mbps</span>
<span>{format.video} + {format.audio}</span>
<span> {formatEstimatedFileSize(estimate.estimatedBytes)}</span>
<span>{timelineDuration.toFixed(1)}s</span>
</div>
<div className="export-settings-note">
{t(exportSettings.codec === "h264-mov"
? runtimeAvailable ? "exportPipelineDeterministicHint" : "exportRuntimeUnavailable"
: "exportPipelineAutoHint")}
</div>
</div>
<div className="export-settings-footer">
<button
className="export-confirm-button"
type="button"
disabled={!imageSrc || checking || !runtimeAvailable || timelineDuration <= 0}
onClick={() => {
onClose();
handleExportVideo({
settings: withSimpleExportDefaults(exportSettings),
});
}}
>
<FileArrowDown size={17} weight="bold" />
{imageSrc ? t("startExport") : t("addVisualBeforeExport")}
</button>
</div>
</>
);
}
+12 -16
View File
@@ -14,6 +14,7 @@ import {
import { RATIO_OPTIONS } from "../config/editor.js";
import { APP_LANGUAGES, saveLanguagePreference } from "../i18n.js";
import { ExportSettingsPanel } from "./ExportSettingsPanel.jsx";
import { IconButton, Popover } from "./ui.jsx";
export function Topbar({
@@ -38,6 +39,7 @@ export function Topbar({
setShowExportMenu,
exportSettings,
setExportSettings,
timelineDuration,
showSettings,
setShowSettings,
activeLanguage,
@@ -165,22 +167,16 @@ export function Topbar({
</button>
{showExportMenu ? (
<Popover className="export-settings-popover" closeLabel={t("close")} onClose={() => setShowExportMenu(false)}>
<div className="export-settings-card">
<div className="export-settings-heading"><div><strong>{t("videoExport")}</strong><small>{t("videoExportHint")}</small></div><span>{exportSettings.codec === "h264" ? "MP4" : "WebM"}</span></div>
<div className="export-setting-field">
<span>{t("exportResolution")}</span>
<select value={exportSettings.resolution} onChange={(event) => setExportSettings((value) => ({ ...value, resolution: event.target.value }))}>
<option value="720">720p · {t("resolutionHd")}</option><option value="1080">1080p · {t("resolutionFullHd")}</option><option value="1440">2K · {t("resolutionQhd")}</option><option value="2160">4K · {t("resolutionUhd")}</option>
</select>
</div>
<div className="export-setting-grid">
<label className="export-setting-field"><span>{t("exportFrameRate")}</span><select value={exportSettings.frameRate} onChange={(event) => setExportSettings((value) => ({ ...value, frameRate: Number(event.target.value) }))}><option value="24">24 fps</option><option value="30">30 fps</option><option value="60">60 fps</option></select></label>
<label className="export-setting-field"><span>{t("exportCodec")}</span><select value={exportSettings.codec} onChange={(event) => setExportSettings((value) => ({ ...value, codec: event.target.value }))}><option value="h264">H.264 · MP4</option><option value="vp9">VP9 · WebM</option><option value="vp8">VP8 · WebM</option></select></label>
</div>
<div className="export-setting-field"><span>{t("exportQuality")}</span><div className="export-quality-options">{[["standard", "exportQualityStandard"], ["high", "exportQualityHigh"], ["ultra", "exportQualityUltra"]].map(([id, labelKey]) => <button className={exportSettings.quality === id ? "is-selected" : ""} type="button" key={id} onClick={() => setExportSettings((value) => ({ ...value, quality: id }))}>{t(labelKey)}</button>)}</div></div>
<div className="export-settings-note">{t("exportQualityHint")}</div>
<button className="export-confirm-button" type="button" disabled={!imageSrc} onClick={() => { setShowExportMenu(false); handleExportVideo(); }}><FileArrowDown size={17} weight="bold" />{imageSrc ? t("startExport") : t("addVisualBeforeExport")}</button>
</div>
<ExportSettingsPanel
t={t}
ratio={ratio}
imageSrc={imageSrc}
timelineDuration={timelineDuration}
exportSettings={exportSettings}
setExportSettings={setExportSettings}
handleExportVideo={handleExportVideo}
onClose={() => setShowExportMenu(false)}
/>
</Popover>
) : null}
</div>
+8 -2
View File
@@ -80,16 +80,22 @@ function AutoEditReviewDialog({ t, autoEdit }) {
);
}
function AutoEditPanel({ t, hasVisual, language, autoEdit }) {
function AutoEditPanel({ t, hasVisual, autoEdit }) {
const availability = autoEdit?.support?.availability || "unknown";
const ready = availability === "available" || availability === "downloadable" || availability === "downloading";
const downloadProgress = Math.max(0, Math.min(100, Number(autoEdit?.support?.progress) || 0));
const isPreparingSupport = availability === "downloading" && Number.isFinite(autoEdit?.support?.progress);
const supportActionLabel = availability === "downloading"
? `${t("autoEditDownloadingModel")}${downloadProgress ? ` ${downloadProgress}%` : ""}`
: availability === "downloadable" ? t("autoEditDownloadModel") : t("autoEditCheckSupport");
return (<>
<div className="auto-edit-panel">
<section className="auto-edit-intro"><Scissors size={28} weight="duotone" /><div><strong>{t("autoEditCreateTitle")}</strong><span>{t("autoEditCreateDesc")}</span></div></section>
<section className="auto-edit-status-card">
<div><span>{t("autoEditBrowserModel")}</span><strong className={`auto-edit-availability is-${availability}`}>{t(`autoEditStatus_${availability}`)}</strong></div>
<p>{t("autoEditPrivacyHint")}</p>
<button className="panel-secondary" type="button" disabled={autoEdit?.job?.running || availability === "checking"} onClick={autoEdit?.checkSupport}>{t("autoEditCheckSupport")}</button>
<button className="panel-secondary" type="button" disabled={autoEdit?.job?.running || availability === "checking" || isPreparingSupport} onClick={availability === "downloadable" || availability === "downloading" ? autoEdit?.prepareSupport : autoEdit?.checkSupport}>{supportActionLabel}</button>
{isPreparingSupport ? <progress max="100" value={downloadProgress} aria-label={supportActionLabel} /> : null}
</section>
<div className="auto-edit-flow"><span>1</span><p><strong>{t("autoEditStepScenes")}</strong><small>{t("autoEditStepScenesHint")}</small></p><span>2</span><p><strong>{t("autoEditStepCaptions")}</strong><small>{t("autoEditStepCaptionsHint")}</small></p><span>3</span><p><strong>{t("autoEditStepTimeline")}</strong><small>{t("autoEditStepTimelineHint")}</small></p></div>
{autoEdit?.job?.running ? <div className="auto-edit-progress"><div><span>{autoEdit.job.phase}</span><strong>{autoEdit.job.progress}%</strong></div><progress max="100" value={autoEdit.job.progress} /><button className="panel-secondary" type="button" onClick={autoEdit.cancel}>{t("cancel")}</button></div> : null}
+1 -1
View File
@@ -210,7 +210,7 @@ export function MediaPanel({
ref={fileInputRef}
className="sr-only"
type="file"
accept="image/png,image/jpeg,image/webp,video/mp4,video/webm,video/quicktime,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg"
accept="image/png,image/jpeg,image/webp,video/mp4,video/webm,video/quicktime,video/x-matroska,.mkv,.mka,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg,audio/flac,.ac3"
multiple
onChange={(event) => {
handleFiles(event.target.files);
+47 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createFrameCaptionSession, extractAutoEditFrames, generateFrameCaptions, generateImageVoiceoverText, probeBuiltInAI } from "../lib/autoEdit.js";
import { createAutoEditTranslator, createFrameCaptionSession, extractAutoEditFrames, generateFrameCaptions, generateImageVoiceoverText, probeBuiltInAI } from "../lib/autoEdit.js";
import { getVisualSegmentsTotal, makeId } from "../lib/timeline.js";
export function useAutoEdit({ language, visualSegments, captionSegments, commitCaptionSegments, setCaptionsEnabled, setTrackVisibility, setSelectedSegmentId, setSelectedTrack, notify, t }) {
@@ -20,6 +20,42 @@ export function useAutoEdit({ language, visualSegments, captionSegments, commitC
return result;
}, [language]);
useEffect(() => { checkSupport(); }, [checkSupport]);
const prepareSupport = useCallback(async () => {
const environment = await probeBuiltInAI(language);
if (environment.availability !== "downloadable" && environment.availability !== "downloading") {
setSupport(environment);
return environment;
}
setSupport({ ...environment, availability: "downloading", progress: 0 });
let promptSession = null;
let translator = null;
const needsTranslation = environment.promptLanguage !== environment.language;
let promptProgress = 0;
let translationProgress = 0;
const updateProgress = (kind, loaded) => {
const value = Math.max(0, Math.min(1, Number(loaded) || 0));
if (kind === "prompt") promptProgress = value;
else translationProgress = value;
const progress = Math.round((needsTranslation ? (promptProgress + translationProgress) / 2 : promptProgress) * 100);
setSupport((value) => ({ ...value, availability: "downloading", progress }));
};
try {
[promptSession, translator] = await Promise.all([
createFrameCaptionSession({ language, onDownloadProgress: (loaded) => updateProgress("prompt", loaded) }),
createAutoEditTranslator({ language, onDownloadProgress: (loaded) => updateProgress("translation", loaded) }),
]);
const ready = await probeBuiltInAI(language);
setSupport({ ...ready, progress: ready.availability === "available" ? 100 : undefined });
return ready;
} catch (error) {
const failed = { ...environment, availability: "unavailable", reason: error?.name || "model-download-failed" };
setSupport(failed);
return failed;
} finally {
promptSession?.destroy?.();
translator?.destroy?.();
}
}, [language]);
const generateImageCaption = useCallback(async (segment) => {
if (!segment?.src || segment.type === "video" || support.availability !== "available" || job.running) return;
@@ -52,6 +88,7 @@ export function useAutoEdit({ language, visualSegments, captionSegments, commitC
clearCandidateUrls();
setReview({ open: true, candidates: [], captions: [], segments: [], error: "" });
let session = null;
let translator = null;
setJob({ running: true, progress: 2, phase: t("autoEditFindingScenes") });
// Chrome requires LanguageModel.create() to happen during the button's
// transient user activation when the model still needs downloading.
@@ -60,6 +97,11 @@ export function useAutoEdit({ language, visualSegments, captionSegments, commitC
signal: abortRef.current.signal,
onDownloadProgress: (loaded) => setJob({ running: true, progress: Math.max(4, Math.round(loaded * 55)), phase: t("autoEditDownloadingModel") }),
});
const translatorPromise = createAutoEditTranslator({
language,
signal: abortRef.current.signal,
onDownloadProgress: (loaded) => setJob({ running: true, progress: Math.max(4, Math.round(loaded * 55)), phase: t("autoEditDownloadingModel") }),
});
try {
const frames = await extractAutoEditFrames(visualSegments, (progress) => setJob({ running: true, progress, phase: t("autoEditFindingScenes") }), abortRef.current.signal);
const candidates = frames.map((frame, index) => {
@@ -71,8 +113,9 @@ export function useAutoEdit({ language, visualSegments, captionSegments, commitC
setReview((value) => ({ ...value, candidates, segments }));
setJob({ running: true, progress: 60, phase: t("autoEditWritingCaptions") });
session = await sessionPromise;
translator = await translatorPromise;
const captions = await generateFrameCaptions({
frames, duration: getVisualSegmentsTotal(visualSegments), language, session,
frames, duration: getVisualSegmentsTotal(visualSegments), language, session, translator,
onPartial: (partial) => {
const modelProgress = partial.allWindows ? partial.completedWindows / partial.allWindows : 0;
setJob({ running: true, progress: Math.min(96, 60 + Math.round(modelProgress * 36)), phase: t("autoEditWritingCaptions") });
@@ -94,6 +137,7 @@ export function useAutoEdit({ language, visualSegments, captionSegments, commitC
setJob({ running: false, progress: 0, phase: "" });
} finally {
session?.destroy?.();
translator?.destroy?.();
}
}, [checkSupport, clearCandidateUrls, job.running, language, notify, support, t, visualSegments]);
const cancel = () => { abortRef.current?.abort(); setJob({ running: false, progress: 0, phase: "" }); };
@@ -110,5 +154,5 @@ export function useAutoEdit({ language, visualSegments, captionSegments, commitC
notify(t("autoEditDone"));
closeReview();
};
return { support, job, review, checkSupport, run, generateImageCaption, cancel, closeReview, applyCaptions };
return { support, job, review, checkSupport, prepareSupport, run, generateImageCaption, cancel, closeReview, applyCaptions };
}
+1
View File
@@ -14,6 +14,7 @@ export function useEditorRefs() {
avatarTestImportedRef: useRef(false),
currentTimeRef: useRef(0),
draggedAssetIdRef: useRef(""),
exportAbortControllerRef: useRef(null),
exportStartRef: useRef(0),
fileInputRef: useRef(null),
imageUrlRefs: useRef(new Set()),
+63 -14
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { MAX_TIMELINE_DURATION_SECONDS, SUPPORTED_MEDIA_TYPES } from "../config/editor.js";
import { decodeWaveform, extractVideoTrackFrames } from "../lib/media.js";
import { decodeWaveform, extractVideoTrackFrames, normalizeVideoForEditing } from "../lib/media.js";
import { getMediaFileKind, isSupportedMediaFile, MEDIA_BACKENDS, probeMediaCompatibility, selectMediaBackends, shouldProbeWithLibav } from "../lib/mediaCompatibility.js";
import { formatClock, formatTime } from "../lib/timeline.js";
export function shouldAutoAddImportedVisual(assets, visualSegments) {
@@ -11,11 +12,12 @@ export function shouldAutoAddImportedVisual(assets, visualSegments) {
export function useFileUpload(deps) {
return useCallback((files) => {
const mediaFiles = Array.from(files ?? []).filter((file) => SUPPORTED_MEDIA_TYPES.some((type) => file.type.startsWith(type)));
const mediaFiles = Array.from(files ?? []).filter((file) =>
SUPPORTED_MEDIA_TYPES.some((type) => file.type.startsWith(type)) || isSupportedMediaFile(file));
if (!mediaFiles.length) return void deps.notify("请选择图片、视频或音频素材");
const assets = mediaFiles.map((file) => {
const src = URL.createObjectURL(file); deps.imageUrlRefs.current.add(src);
const type = file.type.startsWith("video/") ? "video" : file.type.startsWith("audio/") ? "audio" : "image";
const type = getMediaFileKind(file);
return { id: crypto.randomUUID(), type, src, name: file.name, meta: "读取中", blob: file,
duration: type === "video" ? 0 : 4, width: 0, height: 0, trackFrames: [] };
});
@@ -35,18 +37,65 @@ export function useFileUpload(deps) {
return;
}
if (asset.type === "video") {
const video = document.createElement("video"); video.preload = "metadata";
video.onloadedmetadata = () => {
const duration = Math.min(MAX_TIMELINE_DURATION_SECONDS, Math.max(0.5, video.duration || 1));
const width = video.videoWidth || 0; const height = video.videoHeight || 0;
const patch = { meta: `${width || "?"} x ${height || "?"} · ${formatClock(duration)}`, duration, width, height, type: "video", src: asset.src };
update(asset.id, patch); deps.updateVisualAssetInTimeline(asset.id, patch);
extractVideoTrackFrames(asset.src, { duration, width, height }).then((trackFrames) => {
if (!trackFrames.length) return;
update(asset.id, { trackFrames }); deps.updateVisualAssetInTimeline(asset.id, { trackFrames });
}).catch((error) => console.warn("Video timeline frame extraction failed", error));
const prepareCompatibleVideo = async () => {
update(asset.id, { meta: deps.t("mediaCompatibilityProcessing") });
deps.notify(deps.t("mediaCompatibilityProcessing"));
try {
let probe = null;
try {
probe = await probeMediaCompatibility(asset.blob, { nativeMetadataError: true, decodeAudio: true });
} catch (error) {
console.warn("libav.js media probe failed; using FFmpeg.wasm", error);
}
const videoStream = probe?.streams?.find((stream) => stream.type === "video");
const audioStream = probe?.streams?.find((stream) => stream.type === "audio");
const compatibilityAudioBlob = probe?.decodedAudio?.blob ?? null;
const backends = probe ? selectMediaBackends({
nativeReadable: false, container: probe?.container, videoCodec: videoStream?.codec, audioCodec: audioStream?.codec,
webCodecsVideoSupported: typeof VideoDecoder !== "undefined" && ["h264", "vp8", "vp9", "av1"].includes(videoStream?.codec),
webCodecsAudioSupported: typeof AudioDecoder !== "undefined" && ["aac", "opus", "mp3", "flac"].includes(audioStream?.codec),
libavAudioSupported: Boolean(compatibilityAudioBlob),
}) : { probe: MEDIA_BACKENDS.FFMPEG, video: MEDIA_BACKENDS.FFMPEG,
audio: MEDIA_BACKENDS.FFMPEG, needsNormalization: true };
const normalizedBlob = await normalizeVideoForEditing(asset.blob, asset.name, { decodedAudioBlob: compatibilityAudioBlob });
const normalizedSrc = URL.createObjectURL(normalizedBlob); deps.imageUrlRefs.current.add(normalizedSrc);
applyVideoMetadata({ ...asset, src: normalizedSrc, blob: normalizedBlob, compatibilityAudioBlob }, { probe, backends, originalName: asset.name });
deps.notify(deps.t("mediaCompatibilityReady"));
} catch (error) {
console.error("Media compatibility fallback failed", error);
update(asset.id, { meta: deps.t("mediaCompatibilityFailed") });
deps.notify(deps.t("mediaCompatibilityFailedHint"));
}
};
video.onerror = () => update(asset.id, { meta: "视频读取失败" }); video.src = asset.src; return;
const applyVideoMetadata = (sourceAsset, compatibility = null) => {
if (!compatibility && shouldProbeWithLibav(sourceAsset.blob)) {
void prepareCompatibleVideo();
return;
}
const video = document.createElement("video"); video.preload = "metadata";
video.onloadedmetadata = () => {
const duration = Math.min(MAX_TIMELINE_DURATION_SECONDS, Math.max(0.5, video.duration || 1));
const width = video.videoWidth || 0; const height = video.videoHeight || 0;
const backendLabel = compatibility?.backends?.probe ? ` · ${compatibility.backends.probe}` : "";
const patch = { meta: `${width || "?"} x ${height || "?"} · ${formatClock(duration)}${backendLabel}`,
duration, width, height, type: "video", src: sourceAsset.src, blob: sourceAsset.blob,
compatibilityAudioBlob: sourceAsset.compatibilityAudioBlob ?? null, mediaCompatibility: compatibility };
update(asset.id, patch); deps.updateVisualAssetInTimeline(asset.id, patch);
extractVideoTrackFrames(sourceAsset.src, { duration, width, height }).then((trackFrames) => {
if (!trackFrames.length) return;
update(asset.id, { trackFrames }); deps.updateVisualAssetInTimeline(asset.id, { trackFrames });
}).catch((error) => console.warn("Video timeline frame extraction failed", error));
};
video.onerror = async () => {
if (compatibility || !shouldProbeWithLibav(asset.blob, { nativeMetadataError: true })) {
update(asset.id, { meta: deps.t("mediaCompatibilityFailed") });
return;
}
await prepareCompatibleVideo();
};
video.src = sourceAsset.src;
};
applyVideoMetadata(asset); return;
}
const image = new Image();
image.onload = () => {
+3 -2
View File
@@ -5,9 +5,10 @@ import { attachSourceAudioOffset, getSourceAudioAssetId } from "../lib/sourceAud
export function useSourceAudioExtraction(d) {
return useCallback(async (asset, timelineStart = 0, options = {}) => {
if (!asset?.blob) { d.notify("当前视频素材缺少原文件,无法分离原声"); return false; }
d.setStatus("generating"); d.setStatusText("加载 FFmpeg WASM 分离视频原声"); d.setProgress(12);
d.setStatus("generating"); d.setStatusText(asset.compatibilityAudioBlob ? d.t("mediaCompatibilityProcessing") : "加载 FFmpeg WASM 分离视频原声"); d.setProgress(12);
try {
const extractedBlob = await extractAudioFromVideo(asset.blob, asset.name); d.setStatusText("解析视频原声波形"); d.setProgress(78);
const extractedBlob = asset.compatibilityAudioBlob instanceof Blob ? asset.compatibilityAudioBlob : await extractAudioFromVideo(asset.blob, asset.name);
d.setStatusText("解析视频原声波形"); d.setProgress(78);
const extracted = await decodeWaveform(extractedBlob, 96); if (!extracted.duration) throw new Error("视频没有可识别的音频轨");
const shouldAppend = options.append === true && d.sourceAudioBlob instanceof Blob;
const sourceAudioOffset = shouldAppend ? Math.max(0, Number(d.sourceAudioDuration) || 0) : 0;
+123 -35
View File
@@ -1,34 +1,91 @@
import { useCallback } from "react";
import { downloadBlob, exportBrowserVideo, getSupportedRecordingFormat, transcodeWebmToMp4 } from "../lib/media.js";
import { isExportAbortError, throwIfExportAborted } from "../lib/exportCancellation.js";
import {
getEffectiveExportBitrate,
getExportContentDuration,
getExportDimensions,
getExportRange,
normalizeExportSettings,
sanitizeExportFileName,
} from "../lib/exportSettings.js";
import { downloadBlob, exportBrowserVideo, transcodeWebmToMp4 } from "../lib/media.js";
import { exportOfflineVideo } from "../lib/offlineVideoExport.js";
import { estimateDuration } from "../lib/timeline.js";
import { serializeSrt } from "../lib/subtitles.js";
import { getVisionKey } from "../lib/vision.js";
import { prepareEmbeddedVideoAudio } from "../lib/embeddedVideoAudioExport.js";
export function useVideoExport(d) {
return useCallback(async () => {
if (d.exporting) return;
if (!d.imageSrc) return void d.notify("请先上传或选择图片/视频素材再导出");
return useCallback(async (options = {}) => {
if (d.exporting) return { status: "busy" };
if (!d.imageSrc) {
d.notify(d.t("exportVisualRequired"));
return { status: "blocked", error: d.t("exportVisualRequired") };
}
const requestedSettings = normalizeExportSettings(options.settings || d.exportSettings);
const exportSettings = {
...requestedSettings,
...getExportDimensions(d.ratio, Number(requestedSettings.resolution)),
videoBitsPerSecond: getEffectiveExportBitrate(requestedSettings),
};
const notify = (message) => {
if (!options.suppressNotification) d.notify(message);
};
const controller = new AbortController();
d.exportAbortControllerRef.current = controller;
const { signal } = controller;
d.setExporting(true); d.exportStartRef.current = performance.now(); d.setExportProgress(1);
const localize = (key, params = {}) => Object.entries(params).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
d.t(key),
);
d.setExportPhase(localize("exportPreparing")); d.setStatus("generating");
const format = getSupportedRecordingFormat();
const recordingPhase = localize("exportRecordingStream", { format: format.label });
d.setStatusText(recordingPhase); d.setExportPhase(recordingPhase);
const preparingPhase = localize("exportPreparing");
d.setExportPhase(preparingPhase); d.setStatus("generating"); d.setStatusText(preparingPhase);
const progress = ({ progress, phase, phaseKey, phaseParams }) => {
d.setExportProgress((current) => Math.max(current, Math.min(100, Math.max(0, Math.round(progress)))));
const localizedPhase = phaseKey ? localize(phaseKey, phaseParams) : phase;
if (localizedPhase) d.setExportPhase(localizedPhase);
};
const finish = async (phase) => { d.setExportPhase(phase); d.setExportProgress(100); await new Promise((resolve) => setTimeout(resolve, 450)); };
let actualPipeline = "";
try {
const embeddedVideoAudio = !d.sourceAudioBlob && d.trackVisibility.source !== false
? await prepareEmbeddedVideoAudio(d.renderedVisualSegments, progress)
const exportAudio = exportSettings.audio !== "none";
const captionDelivery = exportSettings.captions || "burned";
const burnCaptions = captionDelivery !== "none" && d.captionsEnabled && d.trackVisibility.caption;
const fullDuration = getExportContentDuration({
visualDuration: d.imageDuration,
voiceDuration: d.voiceTrackDuration,
captionDuration: d.captionDuration,
sourceAudioDuration: d.sourceAudioBlob ? d.sourceAudioTimelineEnd : 0,
musicDuration: d.musicBlob ? d.musicTimelineEnd : 0,
stickerDuration: d.stickerDuration,
overlaySegments: d.visualOverlaySegments,
});
const exportRange = getExportRange(exportSettings, fullDuration);
if (exportRange.duration < 1 / Math.max(24, Number(exportSettings.frameRate) || 30)) {
throw new Error(localize("exportRangeInvalid"));
}
const exportBaseName = sanitizeExportFileName(
exportSettings.fileName,
`ai-voiceover-${d.ratio.id.replace(":", "x")}`,
);
const srt = captionDelivery === "burned-srt" && d.captionsEnabled && d.trackVisibility.caption
? serializeSrt(d.captionSegments, d.captionTargetDuration || d.captionDuration, {
start: exportRange.start,
end: exportRange.end,
})
: "";
const downloadArtifacts = (blob, extension) => {
downloadBlob(blob, `${exportBaseName}.${extension}`);
if (srt) {
progress({ progress: 99, phaseKey: "exportSaveSrt" });
downloadBlob(new Blob(["\uFEFF", srt], { type: "application/x-subrip;charset=utf-8" }), `${exportBaseName}.srt`);
}
};
const embeddedVideoAudio = exportAudio && !d.sourceAudioBlob && d.trackVisibility.source !== false
? await prepareEmbeddedVideoAudio(d.renderedVisualSegments, progress, signal)
: { blob: null, segments: [] };
const exportSourceAudioBlob = d.trackVisibility.source !== false
throwIfExportAborted(signal);
const exportSourceAudioBlob = exportAudio && d.trackVisibility.source !== false
? d.sourceAudioBlob || embeddedVideoAudio.blob
: null;
const exportSourceAudioSegments = d.sourceAudioBlob
@@ -40,17 +97,16 @@ export function useVideoExport(d) {
const record = d.visionRecords[getVisionKey(segment)];
return record ? { ...segment, vision: { ...record.analysis, options: record.options } } : segment;
}),
audioBlob: null, voiceAudioSegments: d.trackVisibility.audio ? d.audioSegments : [], voiceVolume: d.volume,
audioBlob: null, voiceAudioSegments: exportAudio && d.trackVisibility.audio ? d.audioSegments : [], voiceVolume: d.volume,
sourceAudioBlob: exportSourceAudioBlob, sourceAudioVolume: d.sourceAudioBlob ? d.sourceAudioVolume : 1,
sourceAudioSegments: exportSourceAudioSegments,
sourceAudioStart: d.sourceAudioStart, musicBlob: d.trackVisibility.music ? d.musicBlob : null,
sourceAudioStart: d.sourceAudioStart, musicBlob: exportAudio && d.trackVisibility.music ? d.musicBlob : null,
musicVolume: d.musicVolume, musicStart: d.musicStart, musicSegments: d.musicSegments, text: d.script, captionSegments: d.captionSegments,
duration: Math.max(d.trackVisibility.audio ? d.voiceTrackDuration : 0, d.captionDuration,
d.trackVisibility.source && d.sourceAudioBlob ? d.sourceAudioTimelineEnd : 0,
d.trackVisibility.music && d.musicBlob ? d.musicTimelineEnd : 0,
d.trackVisibility.sticker ? d.stickerDuration : 0, d.imageDuration, estimateDuration(d.script)),
duration: exportRange.duration,
timelineOffset: exportRange.start,
captionTargetDuration: d.captionTargetDuration || d.captionDuration,
ratio: d.ratio, fitMode: d.fitMode, filter: d.selectedFilter.css,
captionsEnabled: d.captionsEnabled && d.trackVisibility.caption,
captionsEnabled: burnCaptions,
captionPosition: d.captionPosition, captionPlacement: d.captionPlacement,
captionSize: d.captionSize, captionStyle: d.captionStyle,
captionReferenceSize: d.previewFrameSize.width > 0 && d.previewFrameSize.height > 0 ? d.previewFrameSize
@@ -59,38 +115,70 @@ export function useVideoExport(d) {
sticker: null,
stickerSegments: d.trackVisibility.sticker ? d.stickerSegments : [],
visualOverlaySegments: d.trackVisibility.overlay === false ? [] : d.visualOverlaySegments,
transitionId: "none", exportSettings: d.exportSettings, onProgress: progress,
transitionId: "none", exportSettings, onProgress: progress, signal,
};
let video;
try {
// MediaRecorder cannot produce a trustworthy MOV file. MOV therefore
// stays on the native H.264/AAC WebCodecs path instead of changing format.
const pipeline = exportSettings.codec === "h264-mov"
? "deterministic"
: exportSettings.pipeline || "auto";
if (pipeline === "compatible") {
progress({ progress: 5, phaseKey: "exportCompatibility" });
video = await exportBrowserVideo(exportOptions);
actualPipeline = "compatible";
} else try {
video = await exportOfflineVideo(exportOptions);
actualPipeline = "deterministic";
} catch (offlineError) {
if (isExportAbortError(offlineError)) throw offlineError;
if (pipeline === "deterministic") {
console.error("Deterministic WebCodecs export failed", offlineError);
throw new Error(localize("exportDeterministicFailed"), { cause: offlineError });
}
console.warn("Offline WebCodecs export unavailable; using compatibility recorder", offlineError);
progress({ progress: 5, phaseKey: "exportCompatibility" });
video = await exportBrowserVideo(exportOptions);
actualPipeline = "compatible";
}
const name = `ai-voiceover-${d.ratio.id.replace(":", "x")}`;
if (d.exportSettings.codec !== "h264") {
if (exportSettings.codec !== "h264") {
progress({ progress: 99, phaseKey: "exportSaveFile", phaseParams: { format: video.label } });
downloadBlob(video.blob, `${name}.${video.extension}`);
downloadArtifacts(video.blob, video.extension);
d.setStatus("done"); d.setStatusText(localize("exportComplete")); await finish(localize("exportComplete"));
d.notify(`${video.label} 视频已导出`); return;
notify(localize(srt ? "exportVideoAndSrtComplete" : "exportVideoComplete", { format: video.label }));
return { status: "success", extension: video.extension, byteSize: video.blob.size, actualPipeline };
}
if (video.nativeMp4) {
progress({ progress: 98, phaseKey: "exportSaveFile", phaseParams: { format: "MP4" } }); downloadBlob(video.blob, `${name}.mp4`);
d.setStatus("done"); d.setStatusText(localize("exportComplete")); await finish(localize("exportComplete")); d.notify(localize("exportComplete")); return;
progress({ progress: 98, phaseKey: "exportSaveFile", phaseParams: { format: "MP4" } }); downloadArtifacts(video.blob, "mp4");
d.setStatus("done"); d.setStatusText(localize("exportComplete")); await finish(localize("exportComplete")); notify(localize(srt ? "exportVideoAndSrtComplete" : "exportComplete", { format: "MP4" }));
return { status: "success", extension: "mp4", byteSize: video.blob.size, actualPipeline };
}
d.setStatusText("当前浏览器不支持原生 MP4,加载 FFmpeg WASM"); progress({ progress: 95, phase: "加载 FFmpeg 转码器" });
d.setStatusText(localize("exportFfmpegLoading")); progress({ progress: 95, phaseKey: "exportFfmpegLoading" });
try {
d.setStatusText("转码 MP4"); progress({ progress: 96, phase: "转码 MP4" });
const mp4 = await transcodeWebmToMp4(video.blob); progress({ progress: 99, phaseKey: "exportSaveFile", phaseParams: { format: "MP4" } });
downloadBlob(mp4, `${name}.mp4`); d.setStatus("done"); d.setStatusText(localize("exportComplete")); await finish(localize("exportComplete")); d.notify(localize("exportComplete"));
d.setStatusText(localize("exportFfmpegTranscoding")); progress({ progress: 96, phaseKey: "exportFfmpegTranscoding" });
const mp4 = await transcodeWebmToMp4(video.blob, { signal }); progress({ progress: 99, phaseKey: "exportSaveFile", phaseParams: { format: "MP4" } });
downloadArtifacts(mp4, "mp4"); d.setStatus("done"); d.setStatusText(localize("exportComplete")); await finish(localize("exportComplete")); notify(localize(srt ? "exportVideoAndSrtComplete" : "exportComplete", { format: "MP4" }));
return { status: "success", extension: "mp4", byteSize: mp4.size, actualPipeline };
} catch (error) {
console.error(error); progress({ progress: 99, phase: "保存 WebM 兜底文件" }); downloadBlob(video.blob, `${name}.webm`);
d.setStatus("done"); d.setStatusText("WebM 兜底已导出"); await finish("WebM 兜底已导出"); d.notify("MP4 转码失败,已导出 WebM 兜底");
if (isExportAbortError(error)) throw error;
console.error(error); progress({ progress: 99, phaseKey: "exportWebmFallbackSaving" }); downloadArtifacts(video.blob, "webm");
const fallbackComplete = localize("exportWebmFallbackComplete");
d.setStatus("done"); d.setStatusText(fallbackComplete); await finish(fallbackComplete); notify(localize("exportWebmFallbackNotice"));
return { status: "success", extension: "webm", byteSize: video.blob.size, actualPipeline };
}
} catch (error) {
console.error(error); d.setStatus("error"); d.setStatusText(error instanceof Error ? error.message : localize("exportFailed")); d.setExportPhase(localize("exportFailed"));
} finally { d.setExporting(false); d.setExportProgress(0); }
if (isExportAbortError(error)) {
const canceled = localize("exportCanceled");
d.setStatus("ready"); d.setStatusText(canceled); d.setExportPhase(canceled); notify(canceled);
return { status: "canceled", actualPipeline };
} else {
const message = error instanceof Error ? error.message : localize("exportFailed");
console.error(error); d.setStatus("error"); d.setStatusText(message); d.setExportPhase(localize("exportFailed"));
return { status: "failed", actualPipeline, error: message };
}
} finally {
if (d.exportAbortControllerRef.current === controller) d.exportAbortControllerRef.current = null;
d.setExporting(false); d.setExportProgress(0);
}
}, [d]);
}
+252 -6
View File
@@ -1,5 +1,19 @@
import { I18N_COMPLETION_COPY } from "./i18nCompletion.js";
const MEDIA_COMPATIBILITY_COPY = {
zh: { mediaCompatibilityProcessing: "正在分析并兼容处理该媒体…", mediaCompatibilityReady: "兼容媒体已准备完成", mediaCompatibilityFailed: "兼容处理失败", mediaCompatibilityFailedHint: "无法读取该媒体,请尝试转换为 MP4/H.264/AAC" },
en: { mediaCompatibilityProcessing: "Analyzing and preparing compatible media…", mediaCompatibilityReady: "Compatible media is ready", mediaCompatibilityFailed: "Compatibility processing failed", mediaCompatibilityFailedHint: "This media could not be read. Try MP4 with H.264/AAC." },
ja: { mediaCompatibilityProcessing: "メディアを解析し互換処理しています…", mediaCompatibilityReady: "互換メディアの準備が完了しました", mediaCompatibilityFailed: "互換処理に失敗しました", mediaCompatibilityFailedHint: "メディアを読み込めません。MP4/H.264/AAC をお試しください。" },
ko: { mediaCompatibilityProcessing: "미디어를 분석하고 호환 처리하는 중…", mediaCompatibilityReady: "호환 미디어가 준비되었습니다", mediaCompatibilityFailed: "호환 처리에 실패했습니다", mediaCompatibilityFailedHint: "미디어를 읽을 수 없습니다. MP4/H.264/AAC를 사용해 보세요." },
es: { mediaCompatibilityProcessing: "Analizando y preparando contenido compatible…", mediaCompatibilityReady: "El contenido compatible está listo", mediaCompatibilityFailed: "Error de compatibilidad", mediaCompatibilityFailedHint: "No se pudo leer el archivo. Prueba MP4 con H.264/AAC." },
fr: { mediaCompatibilityProcessing: "Analyse et préparation du média compatible…", mediaCompatibilityReady: "Le média compatible est prêt", mediaCompatibilityFailed: "Échec du traitement de compatibilité", mediaCompatibilityFailedHint: "Impossible de lire ce média. Essayez un MP4 H.264/AAC." },
de: { mediaCompatibilityProcessing: "Medium wird analysiert und kompatibel aufbereitet…", mediaCompatibilityReady: "Kompatibles Medium ist bereit", mediaCompatibilityFailed: "Kompatibilitätsverarbeitung fehlgeschlagen", mediaCompatibilityFailedHint: "Medium konnte nicht gelesen werden. Versuche MP4 mit H.264/AAC." },
pt: { mediaCompatibilityProcessing: "Analisando e preparando mídia compatível…", mediaCompatibilityReady: "A mídia compatível está pronta", mediaCompatibilityFailed: "Falha no processamento de compatibilidade", mediaCompatibilityFailedHint: "Não foi possível ler a mídia. Tente MP4 com H.264/AAC." },
th: { mediaCompatibilityProcessing: "กำลังวิเคราะห์และเตรียมสื่อให้เข้ากันได้…", mediaCompatibilityReady: "สื่อที่เข้ากันได้พร้อมแล้ว", mediaCompatibilityFailed: "ประมวลผลความเข้ากันได้ไม่สำเร็จ", mediaCompatibilityFailedHint: "ไม่สามารถอ่านสื่อนี้ได้ โปรดลอง MP4 แบบ H.264/AAC" },
vi: { mediaCompatibilityProcessing: "Đang phân tích và chuẩn bị nội dung tương thích…", mediaCompatibilityReady: "Nội dung tương thích đã sẵn sàng", mediaCompatibilityFailed: "Xử lý tương thích thất bại", mediaCompatibilityFailedHint: "Không thể đọc nội dung này. Hãy thử MP4 H.264/AAC." },
ru: { mediaCompatibilityProcessing: "Анализ и подготовка совместимого медиа…", mediaCompatibilityReady: "Совместимое медиа готово", mediaCompatibilityFailed: "Ошибка обработки совместимости", mediaCompatibilityFailedHint: "Не удалось прочитать медиа. Попробуйте MP4 с H.264/AAC." },
};
export const LANGUAGE_STORAGE_KEY = "ai-voiceover-ui-language";
export const APP_LANGUAGES = [
@@ -17,8 +31,8 @@ export const APP_LANGUAGES = [
];
const EXPORT_RENDER_COPY = {
zh: { exportPreparing: "准备导出", exportRecordingStream: "录制 {format} 视频流", exportEmbeddedAudio: "准备视频内嵌音频 {current}/{total}", exportOfflinePreparing: "准备离线渲染", exportOfflineRendering: "离线渲染 {current}/{total}", exportVerifyFile: "验证导出文件", exportPrepareVisuals: "准备导出画面", exportPrepareTracks: "准备画布与轨道", exportMixAudio: "解码并混合音频轨", exportStartRecording: "开始录制视频流", exportRecording: "录制视频流", exportPackageFile: "封装导出文件", exportCompatibility: "切换兼容导出模式", exportSaveFile: "保存 {format} 文件", exportComplete: "导出完成", exportFailed: "导出失败" },
en: { exportPreparing: "Preparing export", exportRecordingStream: "Recording {format} video stream", exportEmbeddedAudio: "Preparing embedded video audio {current}/{total}", exportOfflinePreparing: "Preparing offline render", exportOfflineRendering: "Offline rendering {current}/{total}", exportVerifyFile: "Verifying exported file", exportPrepareVisuals: "Preparing visuals", exportPrepareTracks: "Preparing canvas and tracks", exportMixAudio: "Decoding and mixing audio tracks", exportStartRecording: "Starting video stream recording", exportRecording: "Recording video stream", exportPackageFile: "Packaging export file", exportCompatibility: "Switching to compatibility export", exportSaveFile: "Saving {format} file", exportComplete: "Export complete", exportFailed: "Export failed" },
zh: { exportPreparing: "准备导出", exportRecordingStream: "录制 {format} 视频流", exportEmbeddedAudio: "准备视频内嵌音频 {current}/{total}", exportOfflinePreparing: "准备离线渲染", exportOfflineRendering: "离线渲染 {current}/{total}", exportVerifyFile: "验证导出文件", exportPrepareVisuals: "准备导出画面", exportPrepareTracks: "准备画布与轨道", exportMixAudio: "解码并混合音频轨", exportStartRecording: "开始录制视频流", exportRecording: "录制视频流", exportPackageFile: "封装导出文件", exportCompatibility: "切换兼容导出模式", exportSaveFile: "保存 {format} 文件", exportComplete: "导出完成", exportFailed: "导出失败", exportVisualRequired: "请先上传或选择图片/视频素材再导出", exportDeterministicFailed: "当前设置无法使用精确离线导出,请切换为自动或兼容模式。", exportVideoComplete: "{format} 视频已导出", exportFfmpegLoading: "加载 FFmpeg 兼容转码器", exportFfmpegTranscoding: "正在转码 MP4", exportWebmFallbackSaving: "保存 WebM 兼容文件", exportWebmFallbackComplete: "WebM 兼容文件已导出", exportWebmFallbackNotice: "MP4 转码失败,已导出 WebM 兼容文件" },
en: { exportPreparing: "Preparing export", exportRecordingStream: "Recording {format} video stream", exportEmbeddedAudio: "Preparing embedded video audio {current}/{total}", exportOfflinePreparing: "Preparing offline render", exportOfflineRendering: "Offline rendering {current}/{total}", exportVerifyFile: "Verifying exported file", exportPrepareVisuals: "Preparing visuals", exportPrepareTracks: "Preparing canvas and tracks", exportMixAudio: "Decoding and mixing audio tracks", exportStartRecording: "Starting video stream recording", exportRecording: "Recording video stream", exportPackageFile: "Packaging export file", exportCompatibility: "Switching to compatibility export", exportSaveFile: "Saving {format} file", exportComplete: "Export complete", exportFailed: "Export failed", exportVisualRequired: "Add an image or video before exporting", exportDeterministicFailed: "These settings cannot use deterministic offline export. Switch to Auto or Compatible.", exportVideoComplete: "{format} video exported", exportFfmpegLoading: "Loading the FFmpeg compatibility transcoder", exportFfmpegTranscoding: "Transcoding MP4", exportWebmFallbackSaving: "Saving a compatible WebM file", exportWebmFallbackComplete: "Compatible WebM file exported", exportWebmFallbackNotice: "MP4 transcoding failed; a compatible WebM file was exported" },
ja: { exportPreparing: "書き出しを準備中", exportRecordingStream: "{format} 動画ストリームを録画中", exportEmbeddedAudio: "動画内音声を準備中 {current}/{total}", exportOfflinePreparing: "オフラインレンダリングを準備中", exportOfflineRendering: "オフラインレンダリング {current}/{total}", exportVerifyFile: "書き出しファイルを確認中", exportPrepareVisuals: "映像を準備中", exportPrepareTracks: "キャンバスとトラックを準備中", exportMixAudio: "音声トラックをデコード・ミックス中", exportStartRecording: "動画ストリーム録画を開始中", exportRecording: "動画ストリームを録画中", exportPackageFile: "書き出しファイルを作成中", exportCompatibility: "互換書き出しに切り替え中", exportSaveFile: "{format} ファイルを保存中", exportComplete: "書き出し完了", exportFailed: "書き出し失敗" },
ko: { exportPreparing: "내보내기 준비 중", exportRecordingStream: "{format} 비디오 스트림 녹화 중", exportEmbeddedAudio: "비디오 내장 오디오 준비 중 {current}/{total}", exportOfflinePreparing: "오프라인 렌더링 준비 중", exportOfflineRendering: "오프라인 렌더링 {current}/{total}", exportVerifyFile: "내보낸 파일 확인 중", exportPrepareVisuals: "화면 준비 중", exportPrepareTracks: "캔버스와 트랙 준비 중", exportMixAudio: "오디오 트랙 디코딩 및 믹싱 중", exportStartRecording: "비디오 스트림 녹화 시작 중", exportRecording: "비디오 스트림 녹화 중", exportPackageFile: "내보내기 파일 패키징 중", exportCompatibility: "호환 내보내기로 전환 중", exportSaveFile: "{format} 파일 저장 중", exportComplete: "내보내기 완료", exportFailed: "내보내기 실패" },
es: { exportPreparing: "Preparando exportación", exportRecordingStream: "Grabando flujo de vídeo {format}", exportEmbeddedAudio: "Preparando audio integrado {current}/{total}", exportOfflinePreparing: "Preparando renderizado sin conexión", exportOfflineRendering: "Renderizado sin conexión {current}/{total}", exportVerifyFile: "Verificando archivo exportado", exportPrepareVisuals: "Preparando imagen", exportPrepareTracks: "Preparando lienzo y pistas", exportMixAudio: "Decodificando y mezclando audio", exportStartRecording: "Iniciando grabación del flujo", exportRecording: "Grabando flujo de vídeo", exportPackageFile: "Empaquetando archivo exportado", exportCompatibility: "Cambiando a exportación compatible", exportSaveFile: "Guardando archivo {format}", exportComplete: "Exportación completada", exportFailed: "Error de exportación" },
@@ -29,6 +43,227 @@ const EXPORT_RENDER_COPY = {
vi: { exportPreparing: "Đang chuẩn bị xuất", exportRecordingStream: "Đang ghi luồng video {format}", exportEmbeddedAudio: "Đang chuẩn bị âm thanh nhúng {current}/{total}", exportOfflinePreparing: "Đang chuẩn bị kết xuất ngoại tuyến", exportOfflineRendering: "Kết xuất ngoại tuyến {current}/{total}", exportVerifyFile: "Đang xác minh tệp xuất", exportPrepareVisuals: "Đang chuẩn bị hình ảnh", exportPrepareTracks: "Đang chuẩn bị khung vẽ và rãnh", exportMixAudio: "Đang giải mã và trộn âm thanh", exportStartRecording: "Đang bắt đầu ghi video", exportRecording: "Đang ghi luồng video", exportPackageFile: "Đang đóng gói tệp xuất", exportCompatibility: "Đang chuyển sang xuất tương thích", exportSaveFile: "Đang lưu tệp {format}", exportComplete: "Xuất hoàn tất", exportFailed: "Xuất thất bại" },
};
const EXPORT_OPTIONS_COPY = {
zh: {
exportSaveSrt: "保存 SRT 字幕文件", exportVideoAndSrtComplete: "{format} 视频和 SRT 字幕已导出",
exportFileName: "文件名", exportCaptionDelivery: "字幕交付", exportCaptionsBurned: "烧录进画面", exportCaptionsNone: "不导出字幕", exportCaptionsBurnedSrt: "烧录并附带 SRT",
exportFormat: "导出格式",
exportPipeline: "导出策略",
exportPipelineAuto: "自动(推荐)",
exportPipelineAutoHint: "优先使用逐帧精确导出;不可用时自动切换实时兼容导出。",
exportPipelineDeterministic: "精确离线",
exportPipelineDeterministicHint: "按时间戳逐帧渲染,不自动降级;适合最终成片。",
exportPipelineCompatible: "实时兼容",
exportPipelineCompatibleHint: "按时间线时长实时录制,适合 WebCodecs 支持不完整的设备。",
exportAudio: "音频",
exportAudioMix: "混合全部启用轨道",
exportAudioNone: "静音",
exportAudioBitrate: "音频码率",
exportRuntimeChecking: "正在检查当前编码设置",
exportRuntimeReady: "当前浏览器支持",
exportRuntimeUnavailable: "当前浏览器不支持此策略",
exportEstimatedFrames: "约 {count} 帧",
exportCancel: "取消导出",
exportCanceling: "正在取消…",
exportCanceled: "导出已取消",
exportRange: "导出范围", exportRangeFull: "完整时间线", exportRangeCustom: "自定义范围",
exportRangeStart: "开始时间(秒)", exportRangeEnd: "结束时间(秒)", exportRangeDuration: "成片 {duration} 秒", exportRangeInvalid: "结束时间必须晚于开始时间",
exportVideoBitrate: "视频码率", exportBitrateAuto: "自动", exportBitrateCustom: "手动", exportCustomBitrate: "手动码率(Mbps", exportKeyFrameInterval: "关键帧间隔",
},
en: {
exportSaveSrt: "Saving SRT caption file", exportVideoAndSrtComplete: "{format} video and SRT captions exported",
exportFileName: "File name", exportCaptionDelivery: "Caption delivery", exportCaptionsBurned: "Burn into video", exportCaptionsNone: "No captions", exportCaptionsBurnedSrt: "Burn in + SRT file",
exportFormat: "Export format",
exportPipeline: "Export strategy",
exportPipelineAuto: "Auto (recommended)",
exportPipelineAutoHint: "Uses deterministic frame rendering first, then falls back to real-time compatibility export.",
exportPipelineDeterministic: "Deterministic offline",
exportPipelineDeterministicHint: "Renders exact timeline timestamps without fallback; best for final delivery.",
exportPipelineCompatible: "Real-time compatible",
exportPipelineCompatibleHint: "Records in timeline time for devices with incomplete WebCodecs support.",
exportAudio: "Audio",
exportAudioMix: "Mix enabled tracks",
exportAudioNone: "Muted",
exportAudioBitrate: "Audio bitrate",
exportRuntimeChecking: "Checking the selected encoding settings",
exportRuntimeReady: "Supported by this browser",
exportRuntimeUnavailable: "This browser does not support the selected strategy",
exportEstimatedFrames: "About {count} frames",
exportCancel: "Cancel export",
exportCanceling: "Canceling…",
exportCanceled: "Export canceled",
exportRange: "Export range", exportRangeFull: "Full timeline", exportRangeCustom: "Custom range",
exportRangeStart: "Start time (sec)", exportRangeEnd: "End time (sec)", exportRangeDuration: "{duration}s output", exportRangeInvalid: "End time must be later than start time",
exportVideoBitrate: "Video bitrate", exportBitrateAuto: "Auto", exportBitrateCustom: "Manual", exportCustomBitrate: "Manual bitrate (Mbps)", exportKeyFrameInterval: "Keyframe interval",
},
ja: {
exportSaveSrt: "SRT 字幕ファイルを保存中", exportVideoAndSrtComplete: "{format} 動画と SRT 字幕を書き出しました",
exportFileName: "ファイル名", exportCaptionDelivery: "字幕の出力", exportCaptionsBurned: "映像に焼き込む", exportCaptionsNone: "字幕なし", exportCaptionsBurnedSrt: "焼き込み + SRT",
exportFormat: "書き出し形式", exportPipeline: "書き出し方式", exportPipelineAuto: "自動(推奨)",
exportPipelineAutoHint: "まず正確なフレーム書き出しを使用し、利用できない場合はリアルタイム互換書き出しに切り替えます。",
exportPipelineDeterministic: "正確なオフライン", exportPipelineDeterministicHint: "タイムスタンプどおりに各フレームを描画し、自動的に切り替えません。最終書き出しに適しています。",
exportPipelineCompatible: "リアルタイム互換", exportPipelineCompatibleHint: "WebCodecs の対応が不完全な端末向けに、タイムラインと同じ時間で録画します。",
exportAudio: "音声", exportAudioMix: "有効なトラックをミックス", exportAudioNone: "ミュート", exportAudioBitrate: "音声ビットレート",
exportRuntimeChecking: "選択したエンコード設定を確認中", exportRuntimeReady: "このブラウザーで利用可能", exportRuntimeUnavailable: "このブラウザーは選択した方式に対応していません",
exportEstimatedFrames: "約 {count} フレーム", exportCancel: "書き出しをキャンセル", exportCanceling: "キャンセル中…", exportCanceled: "書き出しをキャンセルしました",
exportRange: "書き出し範囲", exportRangeFull: "タイムライン全体", exportRangeCustom: "カスタム範囲", exportRangeStart: "開始時間(秒)", exportRangeEnd: "終了時間(秒)", exportRangeDuration: "出力 {duration} 秒", exportRangeInvalid: "終了時間は開始時間より後にしてください", exportVideoBitrate: "映像ビットレート", exportBitrateAuto: "自動", exportBitrateCustom: "手動", exportCustomBitrate: "手動ビットレート(Mbps", exportKeyFrameInterval: "キーフレーム間隔",
},
ko: {
exportSaveSrt: "SRT 자막 파일 저장 중", exportVideoAndSrtComplete: "{format} 비디오와 SRT 자막을 내보냈습니다",
exportFileName: "파일 이름", exportCaptionDelivery: "자막 제공 방식", exportCaptionsBurned: "영상에 삽입", exportCaptionsNone: "자막 없음", exportCaptionsBurnedSrt: "영상 삽입 + SRT",
exportFormat: "내보내기 형식", exportPipeline: "내보내기 방식", exportPipelineAuto: "자동(권장)",
exportPipelineAutoHint: "정확한 프레임 렌더링을 먼저 사용하고, 사용할 수 없으면 실시간 호환 내보내기로 전환합니다.",
exportPipelineDeterministic: "정확한 오프라인", exportPipelineDeterministicHint: "타임스탬프별로 프레임을 렌더링하며 자동 전환하지 않습니다. 최종 결과물에 적합합니다.",
exportPipelineCompatible: "실시간 호환", exportPipelineCompatibleHint: "WebCodecs 지원이 불완전한 기기에서 타임라인 시간에 맞춰 녹화합니다.",
exportAudio: "오디오", exportAudioMix: "활성화된 트랙 믹스", exportAudioNone: "음소거", exportAudioBitrate: "오디오 비트레이트",
exportRuntimeChecking: "선택한 인코딩 설정 확인 중", exportRuntimeReady: "현재 브라우저에서 지원됨", exportRuntimeUnavailable: "현재 브라우저는 선택한 방식을 지원하지 않습니다",
exportEstimatedFrames: "약 {count}프레임", exportCancel: "내보내기 취소", exportCanceling: "취소 중…", exportCanceled: "내보내기가 취소되었습니다",
exportRange: "내보내기 범위", exportRangeFull: "전체 타임라인", exportRangeCustom: "사용자 지정 범위", exportRangeStart: "시작 시간(초)", exportRangeEnd: "종료 시간(초)", exportRangeDuration: "결과물 {duration}초", exportRangeInvalid: "종료 시간은 시작 시간보다 늦어야 합니다", exportVideoBitrate: "비디오 비트레이트", exportBitrateAuto: "자동", exportBitrateCustom: "수동", exportCustomBitrate: "수동 비트레이트(Mbps)", exportKeyFrameInterval: "키프레임 간격",
},
es: {
exportSaveSrt: "Guardando archivo de subtítulos SRT", exportVideoAndSrtComplete: "Vídeo {format} y subtítulos SRT exportados",
exportFileName: "Nombre del archivo", exportCaptionDelivery: "Entrega de subtítulos", exportCaptionsBurned: "Integrar en el vídeo", exportCaptionsNone: "Sin subtítulos", exportCaptionsBurnedSrt: "Integrados + archivo SRT",
exportFormat: "Formato de exportación", exportPipeline: "Estrategia de exportación", exportPipelineAuto: "Automática (recomendada)",
exportPipelineAutoHint: "Primero renderiza fotogramas exactos y cambia a la exportación compatible en tiempo real si no está disponible.",
exportPipelineDeterministic: "Sin conexión y exacta", exportPipelineDeterministicHint: "Renderiza cada marca de tiempo sin cambiar de modo; ideal para la entrega final.",
exportPipelineCompatible: "Compatible en tiempo real", exportPipelineCompatibleHint: "Graba siguiendo la duración de la línea de tiempo en dispositivos con compatibilidad WebCodecs incompleta.",
exportAudio: "Sonido", exportAudioMix: "Mezclar pistas activas", exportAudioNone: "Silenciado", exportAudioBitrate: "Tasa de bits de audio",
exportRuntimeChecking: "Comprobando los ajustes de codificación", exportRuntimeReady: "Compatible con este navegador", exportRuntimeUnavailable: "Este navegador no admite la estrategia seleccionada",
exportEstimatedFrames: "Aprox. {count} fotogramas", exportCancel: "Cancelar exportación", exportCanceling: "Cancelando…", exportCanceled: "Exportación cancelada",
exportRange: "Rango de exportación", exportRangeFull: "Línea de tiempo completa", exportRangeCustom: "Rango personalizado", exportRangeStart: "Inicio (s)", exportRangeEnd: "Fin (s)", exportRangeDuration: "Salida de {duration} s", exportRangeInvalid: "El final debe ser posterior al inicio", exportVideoBitrate: "Tasa de bits de vídeo", exportBitrateAuto: "Automática", exportBitrateCustom: "Personalizada", exportCustomBitrate: "Tasa manual (Mbps)", exportKeyFrameInterval: "Intervalo de fotogramas clave",
},
fr: {
exportSaveSrt: "Enregistrement du fichier de sous-titres SRT", exportVideoAndSrtComplete: "Vidéo {format} et sous-titres SRT exportés",
exportFileName: "Nom du fichier", exportCaptionDelivery: "Livraison des sous-titres", exportCaptionsBurned: "Incruster dans la vidéo", exportCaptionsNone: "Sans sous-titres", exportCaptionsBurnedSrt: "Incrustés + fichier SRT",
exportFormat: "Format dexport", exportPipeline: "Stratégie dexport", exportPipelineAuto: "Automatique (recommandé)",
exportPipelineAutoHint: "Utilise dabord le rendu image par image, puis passe à lexport compatible en temps réel si nécessaire.",
exportPipelineDeterministic: "Hors ligne précis", exportPipelineDeterministicHint: "Rend chaque horodatage sans repli automatique, idéal pour la livraison finale.",
exportPipelineCompatible: "Compatible en temps réel", exportPipelineCompatibleHint: "Enregistre selon la durée de la timeline sur les appareils où WebCodecs est incomplet.",
exportAudio: "Son", exportAudioMix: "Mixer les pistes actives", exportAudioNone: "Muet", exportAudioBitrate: "Débit audio",
exportRuntimeChecking: "Vérification des réglages dencodage", exportRuntimeReady: "Pris en charge par ce navigateur", exportRuntimeUnavailable: "Ce navigateur ne prend pas en charge la stratégie choisie",
exportEstimatedFrames: "Environ {count} images", exportCancel: "Annuler lexport", exportCanceling: "Annulation…", exportCanceled: "Export annulé",
exportRange: "Plage dexport", exportRangeFull: "Timeline complète", exportRangeCustom: "Plage personnalisée", exportRangeStart: "Début (s)", exportRangeEnd: "Fin (s)", exportRangeDuration: "Sortie de {duration} s", exportRangeInvalid: "La fin doit être postérieure au début", exportVideoBitrate: "Débit vidéo", exportBitrateAuto: "Automatique", exportBitrateCustom: "Manuel", exportCustomBitrate: "Débit manuel (Mbit/s)", exportKeyFrameInterval: "Intervalle dimages clés",
},
de: {
exportSaveSrt: "SRT-Untertiteldatei wird gespeichert", exportVideoAndSrtComplete: "{format}-Video und SRT-Untertitel exportiert",
exportFileName: "Dateiname", exportCaptionDelivery: "Untertitelausgabe", exportCaptionsBurned: "In Video einbrennen", exportCaptionsNone: "Keine Untertitel", exportCaptionsBurnedSrt: "Einbrennen + SRT-Datei",
exportFormat: "Exportformat", exportPipeline: "Exportstrategie", exportPipelineAuto: "Automatisch (empfohlen)",
exportPipelineAutoHint: "Verwendet zuerst exaktes Frame-Rendering und wechselt bei Bedarf zum kompatiblen Echtzeitexport.",
exportPipelineDeterministic: "Exakt offline", exportPipelineDeterministicHint: "Rendert jeden Zeitstempel ohne automatischen Wechsel; ideal für die finale Ausgabe.",
exportPipelineCompatible: "Echtzeit-kompatibel", exportPipelineCompatibleHint: "Zeichnet in Timeline-Echtzeit auf Geräten mit unvollständiger WebCodecs-Unterstützung auf.",
exportAudio: "Ton", exportAudioMix: "Aktive Spuren mischen", exportAudioNone: "Stumm", exportAudioBitrate: "Audio-Bitrate",
exportRuntimeChecking: "Kodierungseinstellungen werden geprüft", exportRuntimeReady: "Von diesem Browser unterstützt", exportRuntimeUnavailable: "Dieser Browser unterstützt die gewählte Strategie nicht",
exportEstimatedFrames: "Ca. {count} Frames", exportCancel: "Export abbrechen", exportCanceling: "Wird abgebrochen…", exportCanceled: "Export abgebrochen",
exportRange: "Exportbereich", exportRangeFull: "Gesamte Timeline", exportRangeCustom: "Eigener Bereich", exportRangeStart: "Startzeit (s)", exportRangeEnd: "Endzeit (s)", exportRangeDuration: "{duration} s Ausgabe", exportRangeInvalid: "Die Endzeit muss nach der Startzeit liegen", exportVideoBitrate: "Video-Bitrate", exportBitrateAuto: "Automatisch", exportBitrateCustom: "Manuell", exportCustomBitrate: "Manuelle Bitrate (Mbit/s)", exportKeyFrameInterval: "Keyframe-Intervall",
},
pt: {
exportSaveSrt: "Salvando arquivo de legendas SRT", exportVideoAndSrtComplete: "Vídeo {format} e legendas SRT exportados",
exportFileName: "Nome do arquivo", exportCaptionDelivery: "Entrega de legendas", exportCaptionsBurned: "Incorporar no vídeo", exportCaptionsNone: "Sem legendas", exportCaptionsBurnedSrt: "Incorporadas + arquivo SRT",
exportFormat: "Formato de exportação", exportPipeline: "Estratégia de exportação", exportPipelineAuto: "Automática (recomendada)",
exportPipelineAutoHint: "Usa primeiro a renderização exata de quadros e alterna para a exportação compatível em tempo real quando necessário.",
exportPipelineDeterministic: "Offline precisa", exportPipelineDeterministicHint: "Renderiza cada instante sem alternância automática; ideal para a entrega final.",
exportPipelineCompatible: "Compatível em tempo real", exportPipelineCompatibleHint: "Grava no tempo da linha do tempo em dispositivos com suporte incompleto a WebCodecs.",
exportAudio: "Áudio", exportAudioMix: "Mixar faixas ativas", exportAudioNone: "Sem áudio", exportAudioBitrate: "Taxa de bits do áudio",
exportRuntimeChecking: "Verificando as configurações de codificação", exportRuntimeReady: "Compatível com este navegador", exportRuntimeUnavailable: "Este navegador não suporta a estratégia selecionada",
exportEstimatedFrames: "Cerca de {count} quadros", exportCancel: "Cancelar exportação", exportCanceling: "Cancelando…", exportCanceled: "Exportação cancelada",
exportRange: "Intervalo de exportação", exportRangeFull: "Linha do tempo completa", exportRangeCustom: "Intervalo personalizado", exportRangeStart: "Início (s)", exportRangeEnd: "Fim (s)", exportRangeDuration: "Saída de {duration} s", exportRangeInvalid: "O fim deve ser posterior ao início", exportVideoBitrate: "Taxa de bits do vídeo", exportBitrateAuto: "Automática", exportBitrateCustom: "Personalizada", exportCustomBitrate: "Taxa manual (Mbps)", exportKeyFrameInterval: "Intervalo de quadro-chave",
},
th: {
exportSaveSrt: "กำลังบันทึกไฟล์คำบรรยาย SRT", exportVideoAndSrtComplete: "ส่งออกวิดีโอ {format} และคำบรรยาย SRT แล้ว",
exportFileName: "ชื่อไฟล์", exportCaptionDelivery: "การส่งออกคำบรรยาย", exportCaptionsBurned: "ฝังในวิดีโอ", exportCaptionsNone: "ไม่มีคำบรรยาย", exportCaptionsBurnedSrt: "ฝังในวิดีโอ + ไฟล์ SRT",
exportFormat: "รูปแบบการส่งออก", exportPipeline: "กลยุทธ์การส่งออก", exportPipelineAuto: "อัตโนมัติ (แนะนำ)",
exportPipelineAutoHint: "ใช้การเรนเดอร์ทีละเฟรมอย่างแม่นยำก่อน และสลับเป็นการส่งออกแบบเรียลไทม์ที่เข้ากันได้เมื่อจำเป็น",
exportPipelineDeterministic: "ออฟไลน์แบบแม่นยำ", exportPipelineDeterministicHint: "เรนเดอร์ตามเวลาแต่ละเฟรมโดยไม่สลับโหมดอัตโนมัติ เหมาะสำหรับไฟล์ฉบับสมบูรณ์",
exportPipelineCompatible: "เข้ากันได้แบบเรียลไทม์", exportPipelineCompatibleHint: "บันทึกตามเวลาของไทม์ไลน์บนอุปกรณ์ที่รองรับ WebCodecs ไม่สมบูรณ์",
exportAudio: "เสียง", exportAudioMix: "ผสมแทร็กที่เปิดใช้", exportAudioNone: "ปิดเสียง", exportAudioBitrate: "บิตเรตเสียง",
exportRuntimeChecking: "กำลังตรวจสอบการตั้งค่าการเข้ารหัส", exportRuntimeReady: "เบราว์เซอร์นี้รองรับ", exportRuntimeUnavailable: "เบราว์เซอร์นี้ไม่รองรับกลยุทธ์ที่เลือก",
exportEstimatedFrames: "ประมาณ {count} เฟรม", exportCancel: "ยกเลิกการส่งออก", exportCanceling: "กำลังยกเลิก…", exportCanceled: "ยกเลิกการส่งออกแล้ว",
exportRange: "ช่วงการส่งออก", exportRangeFull: "ไทม์ไลน์ทั้งหมด", exportRangeCustom: "ช่วงกำหนดเอง", exportRangeStart: "เวลาเริ่ม (วินาที)", exportRangeEnd: "เวลาสิ้นสุด (วินาที)", exportRangeDuration: "ไฟล์ยาว {duration} วินาที", exportRangeInvalid: "เวลาสิ้นสุดต้องอยู่หลังเวลาเริ่ม", exportVideoBitrate: "บิตเรตวิดีโอ", exportBitrateAuto: "อัตโนมัติ", exportBitrateCustom: "กำหนดเอง", exportCustomBitrate: "บิตเรตกำหนดเอง (Mbps)", exportKeyFrameInterval: "ช่วงคีย์เฟรม",
},
vi: {
exportSaveSrt: "Đang lưu tệp phụ đề SRT", exportVideoAndSrtComplete: "Đã xuất video {format} và phụ đề SRT",
exportFileName: "Tên tệp", exportCaptionDelivery: "Cách xuất phụ đề", exportCaptionsBurned: "Ghi vào video", exportCaptionsNone: "Không có phụ đề", exportCaptionsBurnedSrt: "Ghi vào video + tệp SRT",
exportFormat: "Định dạng xuất", exportPipeline: "Chiến lược xuất", exportPipelineAuto: "Tự động (khuyên dùng)",
exportPipelineAutoHint: "Ưu tiên kết xuất chính xác từng khung hình, sau đó chuyển sang xuất tương thích theo thời gian thực khi cần.",
exportPipelineDeterministic: "Ngoại tuyến chính xác", exportPipelineDeterministicHint: "Kết xuất từng mốc thời gian mà không tự chuyển chế độ; phù hợp cho bản xuất cuối.",
exportPipelineCompatible: "Tương thích thời gian thực", exportPipelineCompatibleHint: "Ghi theo thời lượng dòng thời gian trên thiết bị hỗ trợ WebCodecs chưa đầy đủ.",
exportAudio: "Âm thanh", exportAudioMix: "Trộn các rãnh đang bật", exportAudioNone: "Tắt tiếng", exportAudioBitrate: "Tốc độ bit âm thanh",
exportRuntimeChecking: "Đang kiểm tra thiết lập mã hóa", exportRuntimeReady: "Trình duyệt này hỗ trợ", exportRuntimeUnavailable: "Trình duyệt này không hỗ trợ chiến lược đã chọn",
exportEstimatedFrames: "Khoảng {count} khung hình", exportCancel: "Hủy xuất", exportCanceling: "Đang hủy…", exportCanceled: "Đã hủy xuất",
exportRange: "Phạm vi xuất", exportRangeFull: "Toàn bộ dòng thời gian", exportRangeCustom: "Phạm vi tùy chỉnh", exportRangeStart: "Bắt đầu (giây)", exportRangeEnd: "Kết thúc (giây)", exportRangeDuration: "Đầu ra {duration} giây", exportRangeInvalid: "Thời gian kết thúc phải sau thời gian bắt đầu", exportVideoBitrate: "Tốc độ bit video", exportBitrateAuto: "Tự động", exportBitrateCustom: "Thủ công", exportCustomBitrate: "Tốc độ bit thủ công (Mbps)", exportKeyFrameInterval: "Khoảng khung hình chính",
},
ru: {
exportSaveSrt: "Сохранение файла субтитров SRT", exportVideoAndSrtComplete: "Видео {format} и субтитры SRT экспортированы",
exportFileName: "Имя файла", exportCaptionDelivery: "Экспорт субтитров", exportCaptionsBurned: "Встроить в видео", exportCaptionsNone: "Без субтитров", exportCaptionsBurnedSrt: "Встроить + файл SRT",
exportFormat: "Формат экспорта", exportPipeline: "Стратегия экспорта", exportPipelineAuto: "Автоматически (рекомендуется)",
exportPipelineAutoHint: "Сначала выполняется точный покадровый рендеринг, а при необходимости — совместимый экспорт в реальном времени.",
exportPipelineDeterministic: "Точный офлайн", exportPipelineDeterministicHint: "Рендерит каждый временной штамп без автоматического переключения; подходит для финального файла.",
exportPipelineCompatible: "Совместимый в реальном времени", exportPipelineCompatibleHint: "Записывает по времени таймлайна на устройствах с неполной поддержкой WebCodecs.",
exportAudio: "Аудио", exportAudioMix: "Смешать активные дорожки", exportAudioNone: "Без звука", exportAudioBitrate: "Битрейт аудио",
exportRuntimeChecking: "Проверка настроек кодирования", exportRuntimeReady: "Поддерживается этим браузером", exportRuntimeUnavailable: "Этот браузер не поддерживает выбранную стратегию",
exportEstimatedFrames: "Около {count} кадров", exportCancel: "Отменить экспорт", exportCanceling: "Отмена…", exportCanceled: "Экспорт отменён",
exportRange: "Диапазон экспорта", exportRangeFull: "Весь таймлайн", exportRangeCustom: "Свой диапазон", exportRangeStart: "Начало (с)", exportRangeEnd: "Конец (с)", exportRangeDuration: "Результат {duration} с", exportRangeInvalid: "Конец должен быть позже начала", exportVideoBitrate: "Битрейт видео", exportBitrateAuto: "Авто", exportBitrateCustom: "Вручную", exportCustomBitrate: "Битрейт вручную (Мбит/с)", exportKeyFrameInterval: "Интервал ключевых кадров",
},
};
const EXPORT_EXTRA_STATUS_COPY = {
zh: {
exportVisualRequired: "请先上传或选择图片/视频素材再导出", exportDeterministicFailed: "当前设置无法使用精确离线导出,请切换为自动或兼容模式。",
exportVideoComplete: "{format} 视频已导出", exportFfmpegLoading: "加载 FFmpeg 兼容转码器", exportFfmpegTranscoding: "正在转码 MP4",
exportWebmFallbackSaving: "保存 WebM 兼容文件", exportWebmFallbackComplete: "WebM 兼容文件已导出", exportWebmFallbackNotice: "MP4 转码失败,已导出 WebM 兼容文件",
},
en: {
exportVisualRequired: "Add an image or video before exporting", exportDeterministicFailed: "These settings cannot use deterministic offline export. Switch to Auto or Compatible.",
exportVideoComplete: "{format} video exported", exportFfmpegLoading: "Loading the FFmpeg compatibility transcoder", exportFfmpegTranscoding: "Transcoding MP4",
exportWebmFallbackSaving: "Saving a compatible WebM file", exportWebmFallbackComplete: "Compatible WebM file exported", exportWebmFallbackNotice: "MP4 transcoding failed; a compatible WebM file was exported",
},
ja: {
exportVisualRequired: "書き出す前に画像または動画を追加してください", exportDeterministicFailed: "この設定では正確なオフライン書き出しを使用できません。自動または互換モードに切り替えてください。",
exportVideoComplete: "{format} 動画を書き出しました", exportFfmpegLoading: "FFmpeg 互換トランスコーダーを読み込み中", exportFfmpegTranscoding: "MP4 に変換中",
exportWebmFallbackSaving: "互換 WebM ファイルを保存中", exportWebmFallbackComplete: "互換 WebM ファイルを書き出しました", exportWebmFallbackNotice: "MP4 変換に失敗したため、互換 WebM ファイルを書き出しました",
},
ko: {
exportVisualRequired: "내보내기 전에 이미지 또는 비디오를 추가하세요", exportDeterministicFailed: "현재 설정으로 정확한 오프라인 내보내기를 사용할 수 없습니다. 자동 또는 호환 모드로 전환하세요.",
exportVideoComplete: "{format} 비디오를 내보냈습니다", exportFfmpegLoading: "FFmpeg 호환 트랜스코더 로드 중", exportFfmpegTranscoding: "MP4 변환 중",
exportWebmFallbackSaving: "호환 WebM 파일 저장 중", exportWebmFallbackComplete: "호환 WebM 파일을 내보냈습니다", exportWebmFallbackNotice: "MP4 변환에 실패하여 호환 WebM 파일을 내보냈습니다",
},
es: {
exportVisualRequired: "Añade una imagen o un vídeo antes de exportar", exportDeterministicFailed: "Estos ajustes no permiten la exportación exacta sin conexión. Cambia a Automática o Compatible.",
exportVideoComplete: "Vídeo {format} exportado", exportFfmpegLoading: "Cargando el transcodificador compatible FFmpeg", exportFfmpegTranscoding: "Transcodificando MP4",
exportWebmFallbackSaving: "Guardando archivo WebM compatible", exportWebmFallbackComplete: "Archivo WebM compatible exportado", exportWebmFallbackNotice: "Falló la transcodificación MP4; se exportó un archivo WebM compatible",
},
fr: {
exportVisualRequired: "Ajoutez une image ou une vidéo avant lexport", exportDeterministicFailed: "Ces réglages ne permettent pas lexport hors ligne précis. Choisissez Automatique ou Compatible.",
exportVideoComplete: "Vidéo {format} exportée", exportFfmpegLoading: "Chargement du transcodeur de compatibilité FFmpeg", exportFfmpegTranscoding: "Transcodage MP4",
exportWebmFallbackSaving: "Enregistrement du fichier WebM compatible", exportWebmFallbackComplete: "Fichier WebM compatible exporté", exportWebmFallbackNotice: "Le transcodage MP4 a échoué ; un fichier WebM compatible a été exporté",
},
de: {
exportVisualRequired: "Füge vor dem Export ein Bild oder Video hinzu", exportDeterministicFailed: "Mit diesen Einstellungen ist kein exakter Offlineexport möglich. Wähle Automatisch oder Kompatibel.",
exportVideoComplete: "{format}-Video exportiert", exportFfmpegLoading: "FFmpeg-Kompatibilitätstranscoder wird geladen", exportFfmpegTranscoding: "MP4 wird transkodiert",
exportWebmFallbackSaving: "Kompatible WebM-Datei wird gespeichert", exportWebmFallbackComplete: "Kompatible WebM-Datei exportiert", exportWebmFallbackNotice: "MP4-Transkodierung fehlgeschlagen; eine kompatible WebM-Datei wurde exportiert",
},
pt: {
exportVisualRequired: "Adicione uma imagem ou um vídeo antes de exportar", exportDeterministicFailed: "Estas configurações não permitem a exportação offline precisa. Mude para Automática ou Compatível.",
exportVideoComplete: "Vídeo {format} exportado", exportFfmpegLoading: "Carregando o transcodificador de compatibilidade FFmpeg", exportFfmpegTranscoding: "Transcodificando MP4",
exportWebmFallbackSaving: "Salvando arquivo WebM compatível", exportWebmFallbackComplete: "Arquivo WebM compatível exportado", exportWebmFallbackNotice: "A transcodificação MP4 falhou; um arquivo WebM compatível foi exportado",
},
th: {
exportVisualRequired: "เพิ่มรูปภาพหรือวิดีโอก่อนส่งออก", exportDeterministicFailed: "การตั้งค่านี้ไม่สามารถใช้การส่งออกออฟไลน์แบบแม่นยำได้ โปรดเลือกอัตโนมัติหรือโหมดเข้ากันได้",
exportVideoComplete: "ส่งออกวิดีโอ {format} แล้ว", exportFfmpegLoading: "กำลังโหลดตัวแปลง FFmpeg สำหรับความเข้ากันได้", exportFfmpegTranscoding: "กำลังแปลง MP4",
exportWebmFallbackSaving: "กำลังบันทึกไฟล์ WebM ที่เข้ากันได้", exportWebmFallbackComplete: "ส่งออกไฟล์ WebM ที่เข้ากันได้แล้ว", exportWebmFallbackNotice: "แปลง MP4 ไม่สำเร็จ จึงส่งออกไฟล์ WebM ที่เข้ากันได้แทน",
},
vi: {
exportVisualRequired: "Hãy thêm hình ảnh hoặc video trước khi xuất", exportDeterministicFailed: "Các thiết lập này không thể dùng chế độ ngoại tuyến chính xác. Hãy chuyển sang Tự động hoặc Tương thích.",
exportVideoComplete: "Đã xuất video {format}", exportFfmpegLoading: "Đang tải bộ chuyển mã tương thích FFmpeg", exportFfmpegTranscoding: "Đang chuyển mã MP4",
exportWebmFallbackSaving: "Đang lưu tệp WebM tương thích", exportWebmFallbackComplete: "Đã xuất tệp WebM tương thích", exportWebmFallbackNotice: "Chuyển mã MP4 thất bại; đã xuất tệp WebM tương thích",
},
ru: {
exportVisualRequired: "Добавьте изображение или видео перед экспортом", exportDeterministicFailed: "Эти настройки не поддерживают точный офлайн-экспорт. Выберите автоматический или совместимый режим.",
exportVideoComplete: "Видео {format} экспортировано", exportFfmpegLoading: "Загрузка совместимого транскодера FFmpeg", exportFfmpegTranscoding: "Транскодирование MP4",
exportWebmFallbackSaving: "Сохранение совместимого файла WebM", exportWebmFallbackComplete: "Совместимый файл WebM экспортирован", exportWebmFallbackNotice: "Транскодирование MP4 не удалось; экспортирован совместимый файл WebM",
},
};
const PROJECT_CHROME_COPY = {
zh: { fileMenu: "文件", projectMenuHeading: "项目", newProject: "新建项目", newProjectHint: "从空白时间线开始", importProject: "导入项目包", importProjectHint: "恢复时间线及全部媒体", exportProject: "导出项目包", exportProjectHint: "打包图片、视频和音频", exportVideo: "导出视频", exportSettings: "导出设置", exportCaptions: "导出字幕", enableAudioTrack: "启用配音轨", enableSourceTrack: "启用原声音轨", enableMusicTrack: "启用背景音乐", checkModelCache: "检查模型缓存", language: "语言" },
en: { fileMenu: "File", projectMenuHeading: "Project", newProject: "New project", newProjectHint: "Start with a blank timeline", importProject: "Import project package", importProjectHint: "Restore the timeline and all media", exportProject: "Export project package", exportProjectHint: "Bundle images, video, and audio", exportVideo: "Export video", exportSettings: "Export Settings", exportCaptions: "Export captions", enableAudioTrack: "Enable voice track", enableSourceTrack: "Enable source audio", enableMusicTrack: "Enable background music", checkModelCache: "Check model cache", language: "Language" },
@@ -425,8 +660,17 @@ const SMART_WORKSPACE_COPY = {
};
const AUTO_EDIT_COPY = {
zh: { smartAutoEditHint: "本地生成字幕", autoEditCreateTitle: "从画面生成时间轴字幕", autoEditCreateDesc: "检测镜头变化,抽取代表帧,并交给 Chrome 内置模型生成带时间的字幕。", autoEditBrowserModel: "Chrome 内置模型", autoEditPrivacyHint: "画面在设备本地处理,不上传到项目服务器。首次使用可能需要 Chrome 下载模型。", autoEditLanguageFallback: "Chrome 当前未正式支持中文 Prompt 输出,本次会生成英文字幕;生成后可在字幕轨继续编辑。", autoEditCheckSupport: "检测浏览器支持", autoEditStepScenes: "检测镜头", autoEditStepScenesHint: "按帧差筛选关键画面", autoEditStepCaptions: "理解画面", autoEditStepCaptionsHint: "本地多模态模型生成文案", autoEditStepTimeline: "写入时间轴", autoEditStepTimelineHint: "保留每条字幕的开始与结束时间", autoEditGenerate: "生成画面字幕", autoEditNeedsVisual: "请先添加图片或视频", autoEditFindingScenes: "正在检测镜头变化", autoEditWritingCaptions: "正在生成字幕", autoEditDownloadingModel: "正在下载浏览器模型", autoEditDone: "画面字幕已写入时间轴", autoEditUnavailable: "当前浏览器或设备不支持 Chrome 内置模型", autoEditFailed: "自动剪辑失败", autoEditStatus_unknown: "尚未检测", autoEditStatus_checking: "检测中", autoEditStatus_available: "可用", autoEditStatus_downloadable: "模型待下载", autoEditStatus_downloading: "下载中", autoEditStatus_unavailable: "不可用" },
en: { smartAutoEditHint: "Local captions", autoEditCreateTitle: "Create timed captions from visuals", autoEditCreateDesc: "Detect scene changes, sample representative frames, and use Chrome's built-in model to write timed captions.", autoEditBrowserModel: "Chrome built-in model", autoEditPrivacyHint: "Frames are processed on this device and are not sent to the project server. Chrome may download the model on first use.", autoEditLanguageFallback: "The current UI language is not officially supported by Chrome Prompt API output. Captions will be generated in English and remain editable.", autoEditCheckSupport: "Check browser support", autoEditStepScenes: "Detect scenes", autoEditStepScenesHint: "Select key visuals by frame difference", autoEditStepCaptions: "Understand visuals", autoEditStepCaptionsHint: "Generate copy with the local multimodal model", autoEditStepTimeline: "Write timeline", autoEditStepTimelineHint: "Keep caption start and end times", autoEditGenerate: "Generate visual captions", autoEditNeedsVisual: "Add an image or video first", autoEditFindingScenes: "Detecting scene changes", autoEditWritingCaptions: "Writing captions", autoEditDownloadingModel: "Downloading browser model", autoEditDone: "Visual captions added to the timeline", autoEditUnavailable: "Chrome built-in AI is unavailable on this browser or device", autoEditFailed: "Auto Edit failed", autoEditStatus_unknown: "Not checked", autoEditStatus_checking: "Checking", autoEditStatus_available: "Available", autoEditStatus_downloadable: "Model download needed", autoEditStatus_downloading: "Downloading", autoEditStatus_unavailable: "Unavailable" },
zh: { smartAutoEditHint: "本地生成字幕", autoEditCreateTitle: "从画面生成时间轴字幕", autoEditCreateDesc: "检测镜头变化,抽取代表帧,并交给 Chrome 内置模型生成带时间的字幕。", autoEditBrowserModel: "Chrome 内置模型", autoEditPrivacyHint: "画面在设备本地处理,不上传到项目服务器。首次使用可能需要 Chrome 下载模型。", autoEditLanguageFallback: "Chrome 当前未正式支持中文 Prompt 输出,本次会生成英文字幕;生成后可在字幕轨继续编辑。", autoEditCheckSupport: "检测浏览器支持", autoEditDownloadModel: "下载模型", autoEditStepScenes: "检测镜头", autoEditStepScenesHint: "按帧差筛选关键画面", autoEditStepCaptions: "理解画面", autoEditStepCaptionsHint: "本地多模态模型生成文案", autoEditStepTimeline: "写入时间轴", autoEditStepTimelineHint: "保留每条字幕的开始与结束时间", autoEditGenerate: "生成画面字幕", autoEditNeedsVisual: "请先添加图片或视频", autoEditFindingScenes: "正在检测镜头变化", autoEditWritingCaptions: "正在生成字幕", autoEditDownloadingModel: "正在下载浏览器模型", autoEditDone: "画面字幕已写入时间轴", autoEditUnavailable: "当前浏览器或设备不支持 Chrome 内置模型", autoEditFailed: "自动剪辑失败", autoEditStatus_unknown: "尚未检测", autoEditStatus_checking: "检测中", autoEditStatus_available: "可用", autoEditStatus_downloadable: "模型待下载", autoEditStatus_downloading: "下载中", autoEditStatus_unavailable: "不可用" },
en: { smartAutoEditHint: "Local captions", autoEditCreateTitle: "Create timed captions from visuals", autoEditCreateDesc: "Detect scene changes, sample representative frames, and use Chrome's built-in model to write timed captions.", autoEditBrowserModel: "Chrome built-in model", autoEditPrivacyHint: "Frames are processed on this device and are not sent to the project server. Chrome may download the model on first use.", autoEditLanguageFallback: "The current UI language is not officially supported by Chrome Prompt API output. Captions will be generated in English and remain editable.", autoEditCheckSupport: "Check browser support", autoEditDownloadModel: "Download model", autoEditStepScenes: "Detect scenes", autoEditStepScenesHint: "Select key visuals by frame difference", autoEditStepCaptions: "Understand visuals", autoEditStepCaptionsHint: "Generate copy with the local multimodal model", autoEditStepTimeline: "Write timeline", autoEditStepTimelineHint: "Keep caption start and end times", autoEditGenerate: "Generate visual captions", autoEditNeedsVisual: "Add an image or video first", autoEditFindingScenes: "Detecting scene changes", autoEditWritingCaptions: "Writing captions", autoEditDownloadingModel: "Downloading browser model", autoEditDone: "Visual captions added to the timeline", autoEditUnavailable: "Chrome built-in AI is unavailable on this browser or device", autoEditFailed: "Auto Edit failed", autoEditStatus_unknown: "Not checked", autoEditStatus_checking: "Checking", autoEditStatus_available: "Available", autoEditStatus_downloadable: "Model download needed", autoEditStatus_downloading: "Downloading", autoEditStatus_unavailable: "Unavailable" },
ja: { autoEditDownloadModel: "モデルをダウンロード" },
ko: { autoEditDownloadModel: "모델 다운로드" },
es: { autoEditDownloadModel: "Descargar modelo" },
fr: { autoEditDownloadModel: "Télécharger le modèle" },
de: { autoEditDownloadModel: "Modell herunterladen" },
pt: { autoEditDownloadModel: "Baixar modelo" },
th: { autoEditDownloadModel: "ดาวน์โหลดโมเดล" },
vi: { autoEditDownloadModel: "Tải mô hình" },
ru: { autoEditDownloadModel: "Скачать модель" },
};
const AUTO_EDIT_BUTTON_COPY = {
@@ -1918,6 +2162,8 @@ export function createTranslator(languageId) {
const fallback = UI_COPY.en;
const srtImportCopy = SRT_IMPORT_COPY[languageId] ?? SRT_IMPORT_COPY.en;
const exportCopy = EXPORT_RENDER_COPY[copyLanguage] ?? EXPORT_RENDER_COPY.en;
const exportOptionsCopy = EXPORT_OPTIONS_COPY[languageId] ?? EXPORT_OPTIONS_COPY.en;
const exportExtraStatusCopy = EXPORT_EXTRA_STATUS_COPY[languageId] ?? EXPORT_EXTRA_STATUS_COPY.en;
const assetPreviewCopy = ASSET_PREVIEW_COPY[copyLanguage] ?? ASSET_PREVIEW_COPY.en;
const assetDropCopy = ASSET_DROP_COPY[copyLanguage] ?? ASSET_DROP_COPY.en;
const autoCaptionStatusCopy = AUTO_CAPTION_STATUS_COPY[copyLanguage] ?? AUTO_CAPTION_STATUS_COPY.en;
@@ -1930,7 +2176,7 @@ export function createTranslator(languageId) {
const projectChromeCopy = PROJECT_CHROME_COPY[languageId] ?? PROJECT_CHROME_COPY.en;
const coreLabelCopy = CORE_LABEL_COPY[languageId] ?? CORE_LABEL_COPY.en;
const specializedCopy = Object.assign({}, ...[
EXPORT_RENDER_COPY, PROJECT_CHROME_COPY, CORE_LABEL_COPY, MOBILE_DRAWER_COPY, MOBILE_CLIP_ACTION_COPY,
EXPORT_RENDER_COPY, MEDIA_COMPATIBILITY_COPY, PROJECT_CHROME_COPY, CORE_LABEL_COPY, MOBILE_DRAWER_COPY, MOBILE_CLIP_ACTION_COPY,
VISUAL_EDITOR_COPY, TRANSITION_EDITOR_COPY, ASSET_PREVIEW_COPY, ASSET_DROP_COPY,
AUTO_CAPTION_STATUS_COPY, VISUAL_PANEL_TITLE_COPY, VISUAL_MASK_SHAPE_COPY,
VISUAL_KEYFRAME_ACTION_COPY, VISUAL_TAB_COPY, SOURCE_AUDIO_SYNC_COPY,
@@ -1943,7 +2189,7 @@ export function createTranslator(languageId) {
AUTO_EDIT_RESULT_COPY, IMAGE_AI_CAPTION_COPY, PICTURE_IN_PICTURE_COPY,
SRT_IMPORT_COPY,
].map((source) => source[languageId] ?? {}));
return (key, fallbackText) => coreLabelCopy[key] ?? specializedCopy[key] ?? projectChromeCopy[key] ?? PROJECT_CHROME_COPY.en[key] ?? captionAudioLinkCopy[key] ?? CAPTION_AUDIO_LINK_COPY.en[key] ?? ttsBackendCopy[key] ?? TTS_BACKEND_COPY.en[key] ?? mobileStickerCopy[key] ?? MOBILE_STICKER_COPY.en[key] ?? mobileClipActionCopy[key] ?? MOBILE_CLIP_ACTION_COPY.en[key] ?? mobileDrawerCopy[key] ?? MOBILE_DRAWER_COPY.en[key] ?? srtImportCopy[key] ?? exportCopy[key] ?? EXPORT_RENDER_COPY.en[key] ?? assetPreviewCopy[key] ?? ASSET_PREVIEW_COPY.en[key] ?? assetDropCopy[key] ?? ASSET_DROP_COPY.en[key] ?? autoCaptionStatusCopy[key] ?? AUTO_CAPTION_STATUS_COPY.en[key] ?? completionCopy[key] ?? copy[key] ?? fallback[key] ?? UI_COPY.zh[key] ?? fallbackText ?? key;
return (key, fallbackText) => coreLabelCopy[key] ?? specializedCopy[key] ?? exportOptionsCopy[key] ?? EXPORT_OPTIONS_COPY.en[key] ?? exportExtraStatusCopy[key] ?? EXPORT_EXTRA_STATUS_COPY.en[key] ?? projectChromeCopy[key] ?? PROJECT_CHROME_COPY.en[key] ?? captionAudioLinkCopy[key] ?? CAPTION_AUDIO_LINK_COPY.en[key] ?? ttsBackendCopy[key] ?? TTS_BACKEND_COPY.en[key] ?? mobileStickerCopy[key] ?? MOBILE_STICKER_COPY.en[key] ?? mobileClipActionCopy[key] ?? MOBILE_CLIP_ACTION_COPY.en[key] ?? mobileDrawerCopy[key] ?? MOBILE_DRAWER_COPY.en[key] ?? srtImportCopy[key] ?? exportCopy[key] ?? EXPORT_RENDER_COPY.en[key] ?? assetPreviewCopy[key] ?? ASSET_PREVIEW_COPY.en[key] ?? assetDropCopy[key] ?? ASSET_DROP_COPY.en[key] ?? autoCaptionStatusCopy[key] ?? AUTO_CAPTION_STATUS_COPY.en[key] ?? completionCopy[key] ?? copy[key] ?? fallback[key] ?? UI_COPY.zh[key] ?? fallbackText ?? key;
}
export function translateOptionName(languageId, name) {
+1 -1
View File
@@ -8,7 +8,7 @@ function collectRuntimeTranslationKeys(directory, keys = new Set()) {
for (const name of readdirSync(directory)) {
const path = join(directory, name);
const info = statSync(path);
if (info.isDirectory()) collectRuntimeTranslationKeys(path, keys);
if (info.isDirectory() && name !== "vendor") collectRuntimeTranslationKeys(path, keys);
else if (/\.(?:js|jsx)$/.test(name) && !/\.test\.[^.]+$/.test(name) && name !== "i18nCompletion.js") {
const source = readFileSync(path, "utf8");
for (const match of source.matchAll(/\bt\(\s*["']([^"']+)["']/g)) keys.add(match[1]);
+2 -2
View File
@@ -24,7 +24,7 @@ function collectLegacyMessages(directory, messages = new Set()) {
for (const name of readdirSync(directory)) {
const path = join(directory, name);
const info = statSync(path);
if (info.isDirectory()) collectLegacyMessages(path, messages);
if (info.isDirectory() && name !== "vendor") collectLegacyMessages(path, messages);
else if (/\.(?:js|jsx)$/.test(name) && !/\.test\.[^.]+$/.test(name) && !/i18n|ttsText|asr\.js|workers/.test(path)) {
const ast = parse(readFileSync(path, "utf8"), { sourceType: "module", plugins: ["jsx"] });
traverse(ast, {
@@ -49,5 +49,5 @@ describe("legacy user-visible message localization", () => {
expect(Object.hasOwn(UI_MESSAGE_COPY[id] ?? {}, message), `${id}: ${message}`).toBe(true);
}
}
});
}, 60_000);
});
+68 -13
View File
@@ -24,29 +24,61 @@ const AUTO_EDIT_LANGUAGE_NAMES = {
"pt-BR": "Brazilian Portuguese",
th: "Thai",
vi: "Vietnamese",
ru: "Russian",
};
const PROMPT_API_LANGUAGES = new Set(["en", "ja", "es", "de", "fr"]);
export function getAutoEditLanguage(language = "en") {
return AUTO_EDIT_LANGUAGE_TAGS[language] || language || "en";
}
export function getAutoEditPromptLanguage(language = "en") {
const outputLanguage = getAutoEditLanguage(language);
return PROMPT_API_LANGUAGES.has(outputLanguage) ? outputLanguage : "en";
}
function getTranslatorLanguage(language) {
return getAutoEditLanguage(language).split("-")[0];
}
function getAutoEditLanguageName(language) {
return AUTO_EDIT_LANGUAGE_NAMES[language] || language;
}
function combineAvailability(...values) {
if (values.includes("unavailable")) return "unavailable";
if (values.includes("downloading")) return "downloading";
if (values.includes("downloadable")) return "downloadable";
return values.every((value) => value === "available") ? "available" : "unavailable";
}
export async function probeBuiltInAI(language = "en") {
if (typeof window === "undefined" || !window.LanguageModel) {
return { availability: "unavailable", reason: "api-missing", language: getAutoEditLanguage(language) };
}
const modelLanguage = getAutoEditLanguage(language);
const outputLanguage = getAutoEditLanguage(language);
const promptLanguage = getAutoEditPromptLanguage(language);
const needsTranslation = promptLanguage !== outputLanguage;
try {
const availability = await window.LanguageModel.availability({
const promptAvailability = await window.LanguageModel.availability({
expectedInputs: [{ type: "text", languages: ["en"] }, { type: "image" }],
expectedOutputs: [{ type: "text", languages: [modelLanguage] }],
expectedOutputs: [{ type: "text", languages: [promptLanguage] }],
});
return { availability, reason: "", language: modelLanguage };
if (!needsTranslation) return { availability: promptAvailability, reason: "", language: outputLanguage, promptLanguage };
if (!window.Translator) return { availability: "unavailable", reason: "translator-api-missing", language: outputLanguage, promptLanguage };
const translationAvailability = await window.Translator.availability({
sourceLanguage: "en",
targetLanguage: getTranslatorLanguage(outputLanguage),
});
return {
availability: combineAvailability(promptAvailability, translationAvailability),
reason: "",
language: outputLanguage,
promptLanguage,
};
} catch (error) {
return { availability: "unavailable", reason: error?.name || "probe-failed", language: modelLanguage };
return { availability: "unavailable", reason: error?.name || "probe-failed", language: outputLanguage, promptLanguage };
}
}
@@ -248,7 +280,7 @@ export async function extractAutoEditFrames(segments, onProgress = () => {}, sig
}
export function createFrameCaptionSession({ language, onDownloadProgress, signal }) {
const modelLanguage = getAutoEditLanguage(language);
const modelLanguage = getAutoEditPromptLanguage(language);
const options = {
expectedInputs: [{ type: "text", languages: ["en"] }, { type: "image" }],
expectedOutputs: [{ type: "text", languages: [modelLanguage] }],
@@ -260,10 +292,29 @@ export function createFrameCaptionSession({ language, onDownloadProgress, signal
return window.LanguageModel.create(options);
}
export function createAutoEditTranslator({ language, onDownloadProgress, signal }) {
const outputLanguage = getAutoEditLanguage(language);
if (getAutoEditPromptLanguage(language) === outputLanguage) return null;
return window.Translator.create({
sourceLanguage: "en",
targetLanguage: getTranslatorLanguage(outputLanguage),
monitor(monitor) {
monitor.addEventListener("downloadprogress", (event) => onDownloadProgress?.(event.loaded));
},
signal,
});
}
async function translateText(text, translator) {
if (!translator || !text) return text;
return String(await translator.translate(text)).trim();
}
export async function generateImageVoiceoverText({ src, language = "en", signal }) {
if (!src) throw new Error("image-missing");
const modelLanguage = getAutoEditLanguage(language);
const modelLanguage = getAutoEditPromptLanguage(language);
const session = await createFrameCaptionSession({ language, signal });
const translator = await createAutoEditTranslator({ language, signal });
try {
const response = await fetch(src, { signal });
if (!response.ok) throw new Error("image-load-failed");
@@ -280,13 +331,14 @@ export async function generateImageVoiceoverText({ src, language = "en", signal
] }], { responseConstraint: schema, signal });
const text = String(JSON.parse(result)?.text || "").trim();
if (!text) throw new Error("empty-caption");
return text;
return translateText(text, translator);
} finally {
session.destroy?.();
translator?.destroy?.();
}
}
async function generateCaptionGroup(session, frames, duration, modelLanguage) {
async function generateCaptionGroup(session, frames, duration, modelLanguage, translator) {
const outputLanguage = getAutoEditLanguageName(modelLanguage);
const content = [{ type: "text", value: `Describe every provided candidate frame with one concise on-screen caption. Output only in ${outputLanguage}. Return exactly ${frames.length} captions in the same order as the images. Use only visible evidence; do not invent names or facts. Frame timestamps: ${frames.map((frame) => frame.time.toFixed(2)).join(", ")} seconds.` }];
frames.forEach((frame) => content.push({ type: "image", value: frame.blob }));
@@ -304,9 +356,10 @@ async function generateCaptionGroup(session, frames, duration, modelLanguage) {
] }], { responseConstraint: singleSchema });
descriptions[index] = String(JSON.parse(singleResponse)?.text || "").trim();
}
const translatedDescriptions = await Promise.all(descriptions.map((text) => translateText(text, translator)));
return frames.map((frame, index) => ({
id: makeId("caption"),
text: descriptions[index],
text: translatedDescriptions[index],
start: frame.time,
end: Math.min(duration, Math.max(frame.time + 1.2, frames[index + 1]?.time ?? frame.segmentEnd ?? duration)),
hidden: false,
@@ -324,9 +377,10 @@ function createSlidingWindows(frames, size = 6, overlap = 2) {
return windows;
}
export async function generateFrameCaptions({ frames, duration, language, session: providedSession, onDownloadProgress, onPartial }) {
const modelLanguage = getAutoEditLanguage(language);
export async function generateFrameCaptions({ frames, duration, language, session: providedSession, translator: providedTranslator, onDownloadProgress, onPartial }) {
const modelLanguage = getAutoEditPromptLanguage(language);
const session = providedSession || await createFrameCaptionSession({ language, onDownloadProgress });
const translator = providedTranslator === undefined ? await createAutoEditTranslator({ language, onDownloadProgress }) : providedTranslator;
const groups = [];
frames.forEach((frame) => {
let group = groups.find((item) => item.segmentId === frame.segmentId);
@@ -347,7 +401,7 @@ export async function generateFrameCaptions({ frames, duration, language, sessio
try {
for (let windowIndex = 0; windowIndex < windows.length; windowIndex += 1) {
const window = windows[windowIndex];
const generated = await generateCaptionGroup(session, window.frames, duration, modelLanguage);
const generated = await generateCaptionGroup(session, window.frames, duration, modelLanguage, translator);
// Overlap gives the model context; each window commits only its new time region.
const committed = generated.filter((caption) => (caption.start + caption.end) / 2 < window.commitEnd || windowIndex === windows.length - 1);
groupCaptions.push(...committed);
@@ -366,5 +420,6 @@ export async function generateFrameCaptions({ frames, duration, language, sessio
return allCaptions.sort((a, b) => a.start - b.start);
} finally {
if (!providedSession) session.destroy?.();
if (providedTranslator === undefined) translator?.destroy?.();
}
}
+40 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { generateFrameCaptions, getAdaptiveSceneThreshold, getAspectRatioLabel, getAutoEditLanguage, normalizeClipCaptionTimings, normalizeGeneratedCaptions, selectCandidatesBySegment, selectChangedFrames } from "./autoEdit.js";
import { generateFrameCaptions, getAdaptiveSceneThreshold, getAspectRatioLabel, getAutoEditLanguage, getAutoEditPromptLanguage, normalizeClipCaptionTimings, normalizeGeneratedCaptions, probeBuiltInAI, selectCandidatesBySegment, selectChangedFrames } from "./autoEdit.js";
describe("auto edit", () => {
it("keeps scene changes and clip boundaries", () => {
@@ -36,6 +36,45 @@ describe("auto edit", () => {
expect(getAutoEditLanguage("zh")).toBe("zh-CN");
expect(getAutoEditLanguage("pt")).toBe("pt-BR");
expect(getAutoEditLanguage("ko")).toBe("ko");
expect(getAutoEditPromptLanguage("zh")).toBe("en");
expect(getAutoEditPromptLanguage("ja")).toBe("ja");
});
it("translates Prompt API fallback output into the selected language", async () => {
const session = { prompt: vi.fn().mockResolvedValue('{"captions":[{"text":"A red lantern"}]}') };
const translator = { translate: vi.fn().mockResolvedValue("一盏红灯笼") };
const result = await generateFrameCaptions({
frames: [{ segmentId: "clip-a", segmentStart: 0, segmentEnd: 2, time: 0, blob: {} }],
duration: 2,
language: "zh",
session,
translator,
});
expect(result[0].text).toBe("一盏红灯笼");
expect(translator.translate).toHaveBeenCalledWith("A red lantern");
});
it("reports translated languages as available when both local APIs are ready", async () => {
vi.stubGlobal("window", {
LanguageModel: { availability: vi.fn().mockResolvedValue("available") },
Translator: { availability: vi.fn().mockResolvedValue("available") },
});
await expect(probeBuiltInAI("zh")).resolves.toMatchObject({
availability: "available",
language: "zh-CN",
promptLanguage: "en",
});
expect(window.Translator.availability).toHaveBeenCalledWith({ sourceLanguage: "en", targetLanguage: "zh" });
vi.unstubAllGlobals();
});
it("does not require translation for a Prompt API native language", async () => {
vi.stubGlobal("window", {
LanguageModel: { availability: vi.fn().mockResolvedValue("available") },
});
await expect(probeBuiltInAI("ja")).resolves.toMatchObject({
availability: "available",
language: "ja",
promptLanguage: "ja",
});
vi.unstubAllGlobals();
});
it("labels common source aspect ratios", () => {
expect(getAspectRatioLabel(1080, 1920)).toBe("9:16");
+15 -4
View File
@@ -1,4 +1,5 @@
import { concatenateAudioBlobs, decodeWaveform, extractAudioFromVideo } from "./media.js";
import { isExportAbortError, throwIfExportAborted } from "./exportCancellation.js";
import { getVisualSegmentTimeline } from "./timeline.js";
const getAssetKey = (segment) => segment?.assetId || segment?.src || segment?.id || "";
@@ -27,13 +28,15 @@ export function createEmbeddedVideoAudioSegments(visualSegments = [], audioAsset
});
}
export async function prepareEmbeddedVideoAudio(visualSegments = [], onProgress) {
export async function prepareEmbeddedVideoAudio(visualSegments = [], onProgress, signal) {
throwIfExportAborted(signal);
const candidates = visualSegments.filter((segment) => segment.type === "video" && !segment.sourceAudioDisabled);
const uniqueAssets = [...new Map(candidates.map((segment) => [getAssetKey(segment), segment])).entries()];
if (!uniqueAssets.length) return { blob: null, segments: [] };
const extracted = [];
for (let index = 0; index < uniqueAssets.length; index += 1) {
throwIfExportAborted(signal);
const [key, segment] = uniqueAssets[index];
onProgress?.({
progress: 2 + Math.round((index / uniqueAssets.length) * 3),
@@ -44,16 +47,21 @@ export async function prepareEmbeddedVideoAudio(visualSegments = [], onProgress)
const sourceBlob = segment.blob instanceof Blob
? segment.blob
: segment.src
? await fetch(segment.src).then((response) => {
? await fetch(segment.src, { signal }).then((response) => {
if (!response.ok) throw new Error(`无法读取视频素材:${response.status}`);
return response.blob();
})
: null;
if (!sourceBlob) continue;
const blob = await extractAudioFromVideo(sourceBlob, segment.name || "source-video.mp4");
const blob = segment.compatibilityAudioBlob instanceof Blob
? segment.compatibilityAudioBlob
: await extractAudioFromVideo(sourceBlob, segment.name || "source-video.mp4");
throwIfExportAborted(signal);
const decoded = await decodeWaveform(blob, 24);
throwIfExportAborted(signal);
if (decoded.duration > 0) extracted.push({ key, blob, duration: decoded.duration });
} catch (error) {
if (isExportAbortError(error)) throw error;
console.warn("Embedded video audio extraction skipped", segment.name || segment.id, error);
}
}
@@ -65,8 +73,11 @@ export async function prepareEmbeddedVideoAudio(visualSegments = [], onProgress)
offset += item.duration;
return mapped;
}));
throwIfExportAborted(signal);
const blob = await concatenateAudioBlobs(extracted.map((item) => item.blob));
throwIfExportAborted(signal);
return {
blob: await concatenateAudioBlobs(extracted.map((item) => item.blob)),
blob,
segments: createEmbeddedVideoAudioSegments(visualSegments, audioAssets),
};
}
+29
View File
@@ -0,0 +1,29 @@
export function createExportAbortError() {
const error = new Error("Export canceled");
error.name = "AbortError";
return error;
}
export function isExportAbortError(error) {
return error?.name === "AbortError";
}
export function throwIfExportAborted(signal) {
if (signal?.aborted) throw createExportAbortError();
}
export function waitForExportTimeout(milliseconds, signal, runtime = globalThis) {
throwIfExportAborted(signal);
return new Promise((resolve, reject) => {
let timer;
const abort = () => {
runtime.clearTimeout(timer);
reject(createExportAbortError());
};
timer = runtime.setTimeout(() => {
signal?.removeEventListener("abort", abort);
resolve();
}, Math.max(0, Number(milliseconds) || 0));
signal?.addEventListener("abort", abort, { once: true });
});
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import {
isExportAbortError,
throwIfExportAborted,
waitForExportTimeout,
} from "./exportCancellation.js";
describe("export cancellation", () => {
it("uses a stable AbortError identity", () => {
const controller = new AbortController();
controller.abort();
expect(() => throwIfExportAborted(controller.signal)).toThrow(expect.objectContaining({ name: "AbortError" }));
});
it("interrupts an active export delay and clears its timer", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const pending = waitForExportTimeout(60_000, controller.signal);
controller.abort();
await expect(pending).rejects.toSatisfy(isExportAbortError);
expect(vi.getTimerCount()).toBe(0);
vi.useRealTimers();
});
});
+241
View File
@@ -1,3 +1,37 @@
export const EXPORT_FORMAT_PROFILES = {
h264: { container: "MP4", video: "H.264", audio: "AAC", extension: "mp4" },
"h264-mov": { container: "MOV", video: "H.264", audio: "AAC", extension: "mov" },
vp9: { container: "WebM", video: "VP9", audio: "Opus", extension: "webm" },
vp8: { container: "WebM", video: "VP8", audio: "Opus", extension: "webm" },
};
export const EXPORT_SETTINGS_STORAGE_KEY = "timeline-studio-export-settings-v1";
export const DEFAULT_EXPORT_SETTINGS = {
resolution: "1080",
frameRate: 30,
codec: "h264",
quality: "high",
pipeline: "auto",
audio: "mix",
audioBitsPerSecond: 192_000,
captions: "burned",
fileName: "ai-voiceover",
range: "full",
rangeStart: 0,
rangeEnd: 10,
bitrateMode: "auto",
customVideoBitsPerSecond: 12_000_000,
keyFrameInterval: 2,
};
const COMPATIBLE_RECORDING_MIME_TYPES = [
"video/mp4;codecs=avc1.42E01E,mp4a.40.2",
"video/mp4",
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
];
export function getExportDimensions(ratio, shortEdge) {
const sourceShortEdge = Math.min(ratio.width, ratio.height);
const scale = shortEdge / sourceShortEdge;
@@ -10,3 +44,210 @@ export function getExportBitrate(resolution, quality, frameRate) {
const qualityScale = { standard: 0.65, high: 1, ultra: 1.45 }[quality] || 1;
return Math.round(base * qualityScale * (frameRate / 30) * 1_000_000);
}
export function getEffectiveExportBitrate(settings) {
if (settings.bitrateMode === "custom") {
return Math.max(1_000_000, Math.min(100_000_000, Number(settings.customVideoBitsPerSecond) || 12_000_000));
}
return getExportBitrate(
Number(settings.resolution) || 1080,
settings.quality,
Number(settings.frameRate) || 30,
);
}
export function getExportRange(settings, timelineDuration) {
const fullDuration = Math.max(0, Number(timelineDuration) || 0);
if (settings.range !== "custom") return { start: 0, end: fullDuration, duration: fullDuration };
const start = Math.max(0, Math.min(fullDuration, Number(settings.rangeStart) || 0));
const requestedEnd = Number(settings.rangeEnd);
const end = Math.max(start, Math.min(fullDuration, Number.isFinite(requestedEnd) ? requestedEnd : fullDuration));
return { start, end, duration: Math.max(0, end - start) };
}
export function getExportContentDuration({
visualDuration = 0,
voiceDuration = 0,
captionDuration = 0,
sourceAudioDuration = 0,
musicDuration = 0,
stickerDuration = 0,
overlaySegments = [],
} = {}) {
const overlayDuration = overlaySegments.reduce((end, segment) => Math.max(
end,
Number(segment?.end) || ((Number(segment?.start) || 0) + (Number(segment?.duration) || 0)),
), 0);
return Math.max(
0,
Number(visualDuration) || 0,
Number(voiceDuration) || 0,
Number(captionDuration) || 0,
Number(sourceAudioDuration) || 0,
Number(musicDuration) || 0,
Number(stickerDuration) || 0,
overlayDuration,
);
}
export function getExportFormatProfile(codec) {
return EXPORT_FORMAT_PROFILES[codec] || EXPORT_FORMAT_PROFILES.h264;
}
export function sanitizeExportFileName(value, fallback = "ai-video") {
const sanitized = String(value ?? "")
.trim()
.replace(/\.(?:mp4|mov|webm|srt)$/i, "")
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "-")
.replace(/[.\s]+$/g, "")
.slice(0, 96);
return sanitized || fallback;
}
export function normalizeExportSettings(value = {}) {
const candidate = value && typeof value === "object" ? value : {};
return {
resolution: ["720", "1080", "1440", "2160"].includes(String(candidate.resolution))
? String(candidate.resolution)
: DEFAULT_EXPORT_SETTINGS.resolution,
frameRate: [24, 30, 60].includes(Number(candidate.frameRate))
? Number(candidate.frameRate)
: DEFAULT_EXPORT_SETTINGS.frameRate,
codec: ["h264", "h264-mov", "vp9", "vp8"].includes(candidate.codec) ? candidate.codec : DEFAULT_EXPORT_SETTINGS.codec,
quality: ["standard", "high", "ultra"].includes(candidate.quality) ? candidate.quality : DEFAULT_EXPORT_SETTINGS.quality,
pipeline: ["auto", "deterministic", "compatible"].includes(candidate.pipeline) ? candidate.pipeline : DEFAULT_EXPORT_SETTINGS.pipeline,
audio: ["mix", "none"].includes(candidate.audio) ? candidate.audio : DEFAULT_EXPORT_SETTINGS.audio,
audioBitsPerSecond: [128_000, 192_000, 256_000, 320_000].includes(Number(candidate.audioBitsPerSecond))
? Number(candidate.audioBitsPerSecond)
: DEFAULT_EXPORT_SETTINGS.audioBitsPerSecond,
captions: ["burned", "none", "burned-srt"].includes(candidate.captions)
? candidate.captions
: DEFAULT_EXPORT_SETTINGS.captions,
fileName: sanitizeExportFileName(candidate.fileName, DEFAULT_EXPORT_SETTINGS.fileName),
range: candidate.range === "custom" ? "custom" : DEFAULT_EXPORT_SETTINGS.range,
rangeStart: Math.max(0, Number(candidate.rangeStart) || 0),
rangeEnd: Number.isFinite(Number(candidate.rangeEnd))
? Math.max(0, Number(candidate.rangeEnd))
: DEFAULT_EXPORT_SETTINGS.rangeEnd,
bitrateMode: candidate.bitrateMode === "custom" ? "custom" : DEFAULT_EXPORT_SETTINGS.bitrateMode,
customVideoBitsPerSecond: Math.max(
1_000_000,
Math.min(100_000_000, Number(candidate.customVideoBitsPerSecond) || DEFAULT_EXPORT_SETTINGS.customVideoBitsPerSecond),
),
keyFrameInterval: [1, 2, 5].includes(Number(candidate.keyFrameInterval))
? Number(candidate.keyFrameInterval)
: DEFAULT_EXPORT_SETTINGS.keyFrameInterval,
};
}
export function loadExportSettings(runtime = globalThis) {
try {
const stored = runtime?.localStorage?.getItem(EXPORT_SETTINGS_STORAGE_KEY);
return normalizeExportSettings(stored ? JSON.parse(stored) : DEFAULT_EXPORT_SETTINGS);
} catch {
return { ...DEFAULT_EXPORT_SETTINGS };
}
}
export function saveExportSettings(settings, runtime = globalThis) {
const normalized = normalizeExportSettings(settings);
try {
runtime?.localStorage?.setItem(EXPORT_SETTINGS_STORAGE_KEY, JSON.stringify(normalized));
} catch {
// Export preferences remain in memory when storage is unavailable.
}
return normalized;
}
export function getExportRuntimeCapabilities(runtime = globalThis) {
return {
deterministic: typeof runtime?.VideoEncoder === "function",
compatible: typeof runtime?.MediaRecorder === "function",
};
}
export function getExportVideoEncoderConfig(settings, ratio) {
const resolution = Number(settings.resolution) || 1080;
const frameRate = Number(settings.frameRate) || 30;
const { width, height } = getExportDimensions(ratio, resolution);
const longEdge = Math.max(width, height);
const codec = settings.codec === "vp8"
? "vp8"
: settings.codec === "vp9"
? "vp09.00.10.08"
: longEdge > 2048 || frameRate > 30
? longEdge > 2048
? "avc1.640034"
: "avc1.64002A"
: "avc1.640028";
return {
codec,
width,
height,
bitrate: getEffectiveExportBitrate(settings),
framerate: frameRate,
hardwareAcceleration: "no-preference",
};
}
export async function probeExportRuntimeCapabilities(settings, ratio, runtime = globalThis) {
const baseline = getExportRuntimeCapabilities(runtime);
let deterministic = baseline.deterministic;
if (deterministic && typeof runtime.VideoEncoder.isConfigSupported === "function") {
try {
const result = await runtime.VideoEncoder.isConfigSupported(getExportVideoEncoderConfig(settings, ratio));
deterministic = Boolean(result?.supported);
} catch {
deterministic = false;
}
}
let compatible = baseline.compatible;
if (compatible && typeof runtime.MediaRecorder.isTypeSupported === "function") {
try {
compatible = COMPATIBLE_RECORDING_MIME_TYPES.some((mimeType) => runtime.MediaRecorder.isTypeSupported(mimeType));
} catch {
compatible = false;
}
}
return { deterministic, compatible };
}
export function getExportTechnicalSummary(settings, ratio) {
const resolution = Number(settings.resolution) || 1080;
const frameRate = Number(settings.frameRate) || 30;
const dimensions = getExportDimensions(ratio, resolution);
const bitrate = getEffectiveExportBitrate(settings);
const format = getExportFormatProfile(settings.codec);
return {
...dimensions,
frameRate,
bitrateMbps: Math.round((bitrate / 1_000_000) * 10) / 10,
container: format.container,
video: format.video,
audio: settings.audio === "none" ? null : format.audio,
audioBitrateKbps: settings.audio === "none"
? null
: Math.round((Number(settings.audioBitsPerSecond) || 192_000) / 1000),
};
}
export function getExportEstimate(settings, ratio, duration) {
const summary = getExportTechnicalSummary(settings, ratio);
const safeDuration = getExportRange(settings, duration).duration;
const videoBitsPerSecond = getEffectiveExportBitrate(settings);
const audioBitsPerSecond = settings.audio === "none"
? 0
: Number(settings.audioBitsPerSecond) || 192_000;
return {
duration: safeDuration,
frameCount: Math.ceil(safeDuration * summary.frameRate),
estimatedBytes: Math.ceil(((videoBitsPerSecond + audioBitsPerSecond) * safeDuration / 8) * 1.03),
};
}
export function formatEstimatedFileSize(bytes) {
const value = Math.max(0, Number(bytes) || 0);
if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`;
if (value < 1024 * 1024 * 1024) return `${Math.round((value / (1024 * 1024)) * 10) / 10} MB`;
return `${Math.round((value / (1024 * 1024 * 1024)) * 100) / 100} GB`;
}
+217 -1
View File
@@ -1,6 +1,21 @@
import { describe, expect, it } from "vitest";
import { getExportDimensions } from "./exportSettings.js";
import {
getExportContentDuration,
getExportDimensions,
getEffectiveExportBitrate,
getExportEstimate,
getExportFormatProfile,
getExportRange,
getExportRuntimeCapabilities,
getExportTechnicalSummary,
getExportVideoEncoderConfig,
loadExportSettings,
formatEstimatedFileSize,
probeExportRuntimeCapabilities,
sanitizeExportFileName,
saveExportSettings,
} from "./exportSettings.js";
describe("export dimensions", () => {
it("uses the selected resolution as the short edge for landscape video", () => {
@@ -15,3 +30,204 @@ describe("export dimensions", () => {
expect(getExportDimensions({ width: 1, height: 1 }, 1080)).toEqual({ width: 1080, height: 1080 });
});
});
describe("export format settings", () => {
it("derives duration only from real timeline content instead of script reading estimates", () => {
expect(getExportContentDuration({
visualDuration: 5,
voiceDuration: 0,
captionDuration: 0,
sourceAudioDuration: 0,
musicDuration: 0,
stickerDuration: 0,
})).toBe(5);
expect(getExportContentDuration({
visualDuration: 5,
overlaySegments: [{ start: 4, duration: 3 }],
})).toBe(7);
});
it("keeps container, video, and audio codecs in a valid profile", () => {
expect(getExportFormatProfile("h264")).toEqual({
container: "MP4",
video: "H.264",
audio: "AAC",
extension: "mp4",
});
expect(getExportFormatProfile("vp9").audio).toBe("Opus");
expect(getExportFormatProfile("h264-mov")).toEqual({
container: "MOV",
video: "H.264",
audio: "AAC",
extension: "mov",
});
});
it("builds the technical summary and supports silent exports", () => {
expect(getExportTechnicalSummary({
resolution: "1080",
frameRate: 30,
codec: "h264",
quality: "high",
audio: "none",
}, { width: 16, height: 9 })).toEqual({
width: 1920,
height: 1080,
frameRate: 30,
bitrateMbps: 10,
container: "MP4",
video: "H.264",
audio: null,
audioBitrateKbps: null,
});
});
it("reports deterministic and compatibility runtime availability separately", () => {
expect(getExportRuntimeCapabilities({
VideoEncoder: function VideoEncoder() {},
MediaRecorder: undefined,
})).toEqual({ deterministic: true, compatible: false });
});
it("builds a codec-specific WebCodecs configuration", () => {
expect(getExportVideoEncoderConfig({
resolution: "2160",
frameRate: 60,
codec: "h264",
quality: "high",
}, { width: 16, height: 9 })).toMatchObject({
codec: "avc1.640034",
width: 3840,
height: 2160,
bitrate: 76_000_000,
framerate: 60,
});
});
it("probes the selected deterministic config and a usable recorder fallback", async () => {
const checkedConfigs = [];
class VideoEncoder {
static async isConfigSupported(config) {
checkedConfigs.push(config);
return { supported: config.codec.startsWith("vp09") };
}
}
class MediaRecorder {
static isTypeSupported(mimeType) {
return mimeType === "video/webm";
}
}
await expect(probeExportRuntimeCapabilities({
resolution: "1080",
frameRate: 30,
codec: "vp9",
quality: "high",
}, { width: 16, height: 9 }, { VideoEncoder, MediaRecorder })).resolves.toEqual({
deterministic: true,
compatible: true,
});
expect(checkedConfigs[0]).toMatchObject({ codec: "vp09.00.10.08", width: 1920, height: 1080 });
});
it("sanitizes filenames and removes known output extensions", () => {
expect(sanitizeExportFileName(' launch:final?.MP4 ')).toBe("launch-final-");
expect(sanitizeExportFileName("editor-master.mov")).toBe("editor-master");
expect(sanitizeExportFileName("...")).toBe("ai-video");
});
it("persists only normalized export preferences", () => {
const values = new Map();
const runtime = {
localStorage: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
},
};
saveExportSettings({
codec: "h264-mov",
resolution: "2160",
frameRate: 60,
quality: "ultra",
pipeline: "deterministic",
audio: "mix",
audioBitsPerSecond: 320_000,
captions: "burned-srt",
fileName: "Final / Cut.mov",
unexpected: "discard",
}, runtime);
expect(loadExportSettings(runtime)).toEqual({
codec: "h264-mov",
resolution: "2160",
frameRate: 60,
quality: "ultra",
pipeline: "deterministic",
audio: "mix",
audioBitsPerSecond: 320_000,
captions: "burned-srt",
fileName: "Final - Cut",
range: "full",
rangeStart: 0,
rangeEnd: 10,
bitrateMode: "auto",
customVideoBitsPerSecond: 12_000_000,
keyFrameInterval: 2,
});
});
it("estimates frames and output size from the selected video and audio bitrates", () => {
const estimate = getExportEstimate({
resolution: "1080",
frameRate: 30,
codec: "h264",
quality: "high",
audio: "mix",
audioBitsPerSecond: 192_000,
}, { width: 16, height: 9 }, 60);
expect(estimate.frameCount).toBe(1800);
expect(estimate.estimatedBytes).toBeGreaterThan(75_000_000);
expect(formatEstimatedFileSize(estimate.estimatedBytes)).toMatch(/MB$/);
});
it("clamps a custom export range to the current timeline", () => {
expect(getExportRange({ range: "custom", rangeStart: 2.5, rangeEnd: 8 }, 6)).toEqual({
start: 2.5,
end: 6,
duration: 3.5,
});
expect(getExportRange({ range: "full", rangeStart: 2, rangeEnd: 3 }, 10)).toEqual({
start: 0,
end: 10,
duration: 10,
});
});
it("uses a bounded manual video bitrate when selected", () => {
expect(getEffectiveExportBitrate({
bitrateMode: "custom",
customVideoBitsPerSecond: 16_500_000,
})).toBe(16_500_000);
expect(getEffectiveExportBitrate({
bitrateMode: "custom",
customVideoBitsPerSecond: 400_000_000,
})).toBe(100_000_000);
});
it("uses the custom range and bitrate in output estimates", () => {
const estimate = getExportEstimate({
resolution: "1080",
frameRate: 30,
codec: "h264",
quality: "high",
audio: "none",
range: "custom",
rangeStart: 2,
rangeEnd: 5,
bitrateMode: "custom",
customVideoBitsPerSecond: 8_000_000,
}, { width: 16, height: 9 }, 10);
expect(estimate.duration).toBe(3);
expect(estimate.frameCount).toBe(90);
expect(estimate.estimatedBytes).toBeGreaterThan(3_000_000);
expect(estimate.estimatedBytes).toBeLessThan(3_200_000);
});
});
+74
View File
@@ -0,0 +1,74 @@
function sampleToFloat(format, value) {
switch (format) {
case 0:
case 5:
return (value - 128) / 128;
case 1:
case 6:
return value / 0x8000;
case 2:
case 7:
return value / 0x80000000;
case 3:
case 8:
return value;
default:
throw new Error(`Unsupported libav.js audio sample format: ${format}`);
}
}
export function encodeLibavAudioFramesAsWav(frames) {
const first = frames.find((frame) => frame?.data && frame.nb_samples);
if (!first) throw new Error("libav.js decoded no audio frames");
const channels = Math.max(1, first.channels || first.data.length || 1);
const sampleRate = Math.max(1, first.sample_rate || 48000);
const frameCount = frames.reduce((total, frame) => total + (frame.nb_samples || 0), 0);
const bytesPerSample = 2;
const blockAlign = channels * bytesPerSample;
const output = new ArrayBuffer(44 + frameCount * blockAlign);
const view = new DataView(output);
const writeText = (offset, text) => {
for (let index = 0; index < text.length; index += 1) {
view.setUint8(offset + index, text.charCodeAt(index));
}
};
writeText(0, "RIFF");
view.setUint32(4, output.byteLength - 8, true);
writeText(8, "WAVE");
writeText(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true);
writeText(36, "data");
view.setUint32(40, frameCount * blockAlign, true);
let offset = 44;
for (const frame of frames) {
if (frame.channels !== channels || frame.sample_rate !== sampleRate) {
throw new Error("Audio layout changed during libav.js decoding");
}
const planar = frame.format >= 5;
for (let sampleIndex = 0; sampleIndex < frame.nb_samples; sampleIndex += 1) {
for (let channel = 0; channel < channels; channel += 1) {
const raw = planar
? frame.data[channel][sampleIndex]
: frame.data[sampleIndex * channels + channel];
const sample = Math.max(-1, Math.min(1, sampleToFloat(frame.format, raw)));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
offset += bytesPerSample;
}
}
}
return {
buffer: output,
channels,
sampleRate,
duration: frameCount / sampleRate,
sampleCount: frameCount,
};
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { encodeLibavAudioFramesAsWav } from "./libavAudio.js";
describe("libav audio WAV encoding", () => {
it("interleaves planar float samples into PCM16 WAV", () => {
const result = encodeLibavAudioFramesAsWav([
{
channels: 2,
sample_rate: 48000,
nb_samples: 2,
format: 8,
data: [new Float32Array([-1, 0.5]), new Float32Array([1, -0.5])],
},
]);
const view = new DataView(result.buffer);
expect(String.fromCharCode(...new Uint8Array(result.buffer, 0, 4))).toBe("RIFF");
expect(view.getUint16(22, true)).toBe(2);
expect(view.getUint32(24, true)).toBe(48000);
expect([...Array(4)].map((_, index) => view.getInt16(44 + index * 2, true))).toEqual([
-32768, 32767, 16383, -16384,
]);
});
it("converts packed signed 16-bit samples without changing channel order", () => {
const result = encodeLibavAudioFramesAsWav([
{
channels: 2,
sample_rate: 44100,
nb_samples: 1,
format: 1,
data: new Int16Array([8192, -8192]),
},
]);
const view = new DataView(result.buffer);
expect(view.getInt16(44, true)).toBe(8191);
expect(view.getInt16(46, true)).toBe(-8192);
expect(result.duration).toBeCloseTo(1 / 44100);
});
});
+59
View File
@@ -0,0 +1,59 @@
function runLibavWorker(type, file, { signal } = {}) {
if (!(file instanceof Blob)) return Promise.reject(new TypeError("A media Blob is required"));
if (signal?.aborted)
return Promise.reject(new DOMException("Media compatibility task cancelled", "AbortError"));
return new Promise((resolve, reject) => {
const worker = new Worker(new URL("../workers/libav-compat.worker.js", import.meta.url), {
type: "module",
name: `timeline-studio-libav-${type}`,
});
let settled = false;
const cleanup = () => {
signal?.removeEventListener("abort", abort);
worker.terminate();
};
const finish = (callback, value) => {
if (settled) return;
settled = true;
cleanup();
callback(value);
};
const abort = () =>
finish(reject, new DOMException("Media compatibility task cancelled", "AbortError"));
signal?.addEventListener("abort", abort, { once: true });
worker.onerror = (event) =>
finish(reject, new Error(event.message || "libav.js worker failed"));
worker.onmessage = (event) => {
if (event.data?.type === "result") return finish(resolve, event.data.result);
if (event.data?.type === "error")
return finish(reject, new Error(event.data.message || "libav.js probe failed"));
};
worker.postMessage({ type, file, name: file.name || "input.mkv" });
});
}
export function probeWithLibavWorker(file, options) {
return runLibavWorker("probe", file, options);
}
export async function probeAndDecodeAudioWithLibavWorker(file, options) {
const result = await runLibavWorker("probe-and-decode-audio", file, options);
if (!result.decodedAudio) return result;
const { buffer, ...decodedAudio } = result.decodedAudio;
return {
...result,
decodedAudio: {
...decodedAudio,
blob: new Blob([buffer], { type: "audio/wav" }),
},
};
}
export async function decodeAudioWithLibavWorker(file, options) {
const result = await runLibavWorker("decode-audio", file, options);
return {
...result,
blob: new Blob([result.buffer], { type: "audio/wav" }),
};
}
+324 -127
View File
@@ -3,6 +3,7 @@ import ffmpegCoreWasmURL from "@ffmpeg/core/wasm?url";
import ffmpegClassWorkerURL from "@ffmpeg/ffmpeg/worker?worker&url";
import { AUDIO_RECORDING_FORMATS, EXPORT_RECORDING_FORMATS } from "../config/editor.js";
import { throwIfExportAborted, waitForExportTimeout } from "./exportCancellation.js";
import {
createCaptionSegments,
getSegmentIndexAtTime,
@@ -27,6 +28,7 @@ import {
import { resolveVisualClipAnimation } from "./visualClipAnimations.js";
import { getStickerRenderGeometry } from "./stickerGeometry.js";
import { createPitchPreservedAudioBuffer } from "./pitchPreservingTimeStretch.js";
import { emitMediaBackendDiagnostic, getMediaFileExtension, isLibavCompatibilityEnabled, MEDIA_BACKENDS } from "./mediaCompatibility.js";
export function getAudioRecordingFormat() {
if (typeof MediaRecorder === "undefined") {
@@ -46,6 +48,7 @@ let ffmpegTaskQueue = Promise.resolve();
const VIDEO_TRACK_FRAME_MAX = 120;
const VIDEO_TRACK_FRAME_HEIGHT = 90;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
export function getVideoTrackSampleCount(duration, maxFrames = VIDEO_TRACK_FRAME_MAX) {
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
@@ -143,7 +146,31 @@ export async function decodeWaveform(blob, barCount = 118) {
try {
const buffer = await blob.arrayBuffer();
const decoded = await audioContext.decodeAudioData(buffer.slice(0));
let decoded;
try {
decoded = await audioContext.decodeAudioData(buffer.slice(0));
emitMediaBackendDiagnostic({ phase: "ready", backend: MEDIA_BACKENDS.NATIVE, operation: "audio-decode" });
} catch (nativeError) {
let normalized = null;
let backend = MEDIA_BACKENDS.FFMPEG;
if (isLibavCompatibilityEnabled() && ["ac3", "mka", "mkv"].includes(getMediaFileExtension(blob))) {
try {
const { decodeAudioWithLibavWorker } = await import("./libavCompatibilityClient.js");
normalized = (await decodeAudioWithLibavWorker(blob)).blob;
backend = MEDIA_BACKENDS.LIBAV;
} catch (error) {
console.warn("libav.js audio decode failed; using FFmpeg.wasm", error);
}
}
emitMediaBackendDiagnostic({ phase: "fallback", backend, operation: "audio-decode" });
normalized ??= await transcodeAudioToWav(blob);
try {
decoded = await audioContext.decodeAudioData((await normalized.arrayBuffer()).slice(0));
} catch (fallbackError) {
fallbackError.cause = nativeError;
throw fallbackError;
}
}
const channelData = decoded.getChannelData(0);
const blockSize = Math.max(1, Math.floor(channelData.length / barCount));
const peaks = Array.from({ length: barCount }, (_, index) => {
@@ -746,7 +773,18 @@ export function getSupportedRecordingFormat() {
};
}
function createVideoRecorder(outputStream, { codec = "h264", videoBitsPerSecond = 12_000_000 } = {}) {
function createVideoRecorder(outputStream, {
codec = "h264",
videoBitsPerSecond = 12_000_000,
audioBitsPerSecond = 192_000,
keyFrameInterval = 2,
} = {}) {
const recorderOptions = (mimeType = "") => ({
...(mimeType ? { mimeType } : {}),
videoBitsPerSecond,
audioBitsPerSecond,
videoKeyFrameIntervalDuration: Math.max(250, Number(keyFrameInterval) * 1000 || 2_000),
});
const codecMatch = (format) => {
const mime = format.mimeType.toLowerCase();
if (codec === "vp9") return mime.includes("vp9");
@@ -765,7 +803,7 @@ function createVideoRecorder(outputStream, { codec = "h264", videoBitsPerSecond
try {
return {
recorder: new MediaRecorder(outputStream, { mimeType: format.mimeType, videoBitsPerSecond }),
recorder: new MediaRecorder(outputStream, recorderOptions(format.mimeType)),
format,
};
} catch (error) {
@@ -774,7 +812,7 @@ function createVideoRecorder(outputStream, { codec = "h264", videoBitsPerSecond
}
return {
recorder: new MediaRecorder(outputStream, { videoBitsPerSecond }),
recorder: new MediaRecorder(outputStream, recorderOptions()),
format: {
mimeType: "",
extension: "webm",
@@ -812,10 +850,15 @@ export async function exportBrowserVideo({
captionReferenceSize,
sticker,
stickerSegments = [],
visualOverlaySegments = [],
transitionId,
exportSettings = {},
onProgress,
signal,
timelineOffset = 0,
captionTargetDuration: providedCaptionTargetDuration = 0,
}) {
throwIfExportAborted(signal);
if (!window.MediaRecorder) {
throw new Error("当前浏览器不支持 MediaRecorder,无法导出视频。");
}
@@ -823,6 +866,7 @@ export async function exportBrowserVideo({
if (document.fonts?.ready) {
await document.fonts.ready.catch(() => {});
}
throwIfExportAborted(signal);
onProgress?.({ progress: 4, phaseKey: "exportPrepareVisuals" });
const exportVisualSegments = visualSegments.some((segment) => segment.src)
@@ -874,6 +918,7 @@ export async function exportBrowserVideo({
};
}),
);
throwIfExportAborted(signal);
const stickerSources = Array.from(
new Set([
...(sticker?.src ? [sticker.src] : []),
@@ -883,7 +928,15 @@ export async function exportBrowserVideo({
const stickerImageEntries = await Promise.all(
stickerSources.map(async (src) => [src, await loadImage(src).catch(() => null)]),
);
throwIfExportAborted(signal);
const stickerImageMap = new Map(stickerImageEntries.filter(([, image]) => image));
const visualOverlayItems = await Promise.all(
visualOverlaySegments.filter((segment) => segment.src).map(async (segment) => ({
segment,
visual: segment.type === "video" ? await loadVideo(segment.src) : await loadImage(segment.src),
})),
);
throwIfExportAborted(signal);
onProgress?.({ progress: 8, phaseKey: "exportPrepareTracks" });
const canvas = document.createElement("canvas");
canvas.width = Math.max(2, Math.round(Number(exportSettings.width) || ratio.width));
@@ -893,6 +946,8 @@ export async function exportBrowserVideo({
const canvasStream = canvas.captureStream(exportFrameRate);
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
const rangeStart = Math.max(0, Number(timelineOffset) || 0);
const rangeEnd = rangeStart + Math.max(0, Number(duration) || 0);
let audioContext = null;
let decodedDuration = 0;
const sources = [];
@@ -957,25 +1012,48 @@ export async function exportBrowserVideo({
Number(input.sourceDuration) || decoded.duration - sourceOffset,
decoded.duration - sourceOffset,
));
const outputDuration = Number(input.outputDuration) || sourceDuration / playbackRate;
const originalOutputDuration = Number(input.outputDuration) || sourceDuration / playbackRate;
const visibleStart = Math.max(input.start, rangeStart);
const visibleEnd = Math.min(input.start + originalOutputDuration, rangeEnd);
if (visibleEnd <= visibleStart) return null;
const trimOutput = visibleStart - input.start;
const outputDuration = visibleEnd - visibleStart;
const trimmedSourceOffset = sourceOffset + trimOutput * playbackRate;
const trimmedSourceDuration = Math.min(
decoded.duration - trimmedSourceOffset,
outputDuration * playbackRate,
);
const preservePitch = Math.abs(playbackRate - 1) > 0.0001;
const prepared = preservePitch
? createPitchPreservedAudioBuffer(audioContext, decoded, {
sourceOffset,
sourceDuration,
sourceOffset: trimmedSourceOffset,
sourceDuration: trimmedSourceDuration,
playbackRate,
})
: decoded;
const fadeInRemaining = Math.max(0, (input.fadeIn || 0) - trimOutput);
const tailTrim = Math.max(0, input.start + originalOutputDuration - visibleEnd);
const fadeOutRemaining = Math.max(0, (input.fadeOut || 0) - tailTrim);
return {
...input,
decoded: prepared,
playbackRate: 1,
sourceOffset: preservePitch ? 0 : sourceOffset,
sourceDuration: preservePitch ? outputDuration : sourceDuration,
start: visibleStart - rangeStart,
sourceOffset: preservePitch ? 0 : trimmedSourceOffset,
sourceDuration: preservePitch ? outputDuration : trimmedSourceDuration,
outputDuration,
fadeIn: fadeInRemaining,
fadeOut: fadeOutRemaining,
initialGain: fadeInRemaining > 0 && input.fadeIn > 0
? input.volume * clamp(trimOutput / input.fadeIn, 0, 1)
: input.volume,
finalGain: fadeOutRemaining > 0 && input.fadeOut > 0
? input.volume * clamp(tailTrim / input.fadeOut, 0, 1)
: input.volume,
};
}),
);
).then((items) => items.filter(Boolean));
throwIfExportAborted(signal);
decodedDuration = Math.max(0, ...decodedInputs.filter((input) => input.role === "voice").map((input) => input.start + input.outputDuration));
@@ -984,15 +1062,15 @@ export async function exportBrowserVideo({
const gain = audioContext.createGain();
source.buffer = input.decoded;
source.playbackRate.value = input.playbackRate;
gain.gain.value = input.volume;
gain.gain.value = input.initialGain;
if (input.fadeIn > 0) {
gain.gain.setValueAtTime(0, audioContext.currentTime + input.start);
gain.gain.setValueAtTime(input.initialGain, audioContext.currentTime + input.start);
gain.gain.linearRampToValueAtTime(input.volume, audioContext.currentTime + input.start + input.fadeIn);
}
if (input.fadeOut > 0) {
const fadeStart = audioContext.currentTime + input.start + Math.max(0, input.outputDuration - input.fadeOut);
gain.gain.setValueAtTime(input.volume, fadeStart);
gain.gain.linearRampToValueAtTime(0, audioContext.currentTime + input.start + input.outputDuration);
gain.gain.linearRampToValueAtTime(input.finalGain, audioContext.currentTime + input.start + input.outputDuration);
}
source.connect(gain);
gain.connect(destination);
@@ -1006,24 +1084,25 @@ export async function exportBrowserVideo({
});
}
if (audioContext?.state === "suspended") {
await audioContext.resume();
}
throwIfExportAborted(signal);
const outputStream = new MediaStream([
...canvasStream.getVideoTracks(),
...(destination ? destination.stream.getAudioTracks() : []),
]);
const { recorder, format: recordingFormat } = createVideoRecorder(outputStream, {
codec: exportSettings.codec,
videoBitsPerSecond: Math.max(2_000_000, Number(exportSettings.videoBitsPerSecond) || 12_000_000),
videoBitsPerSecond: Math.max(1_000_000, Number(exportSettings.videoBitsPerSecond) || 12_000_000),
audioBitsPerSecond: Math.max(96_000, Number(exportSettings.audioBitsPerSecond) || 192_000),
keyFrameInterval: exportSettings.keyFrameInterval,
});
const chunks = [];
const exportSegments = captionSegments?.length ? captionSegments : createCaptionSegments(text);
const segments = exportSegments.map((segment) => segment.text);
const totalDuration = Math.max(
duration,
decodedDuration,
...sources.map(({ start, outputDuration }) => start + outputDuration),
1,
);
const captionTargetDuration = decodedDuration || 0;
const totalDuration = Math.max(Number(duration) || 0, 1 / exportFrameRate);
const captionTargetDuration = Number(providedCaptionTargetDuration) || decodedDuration || 0;
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
@@ -1037,12 +1116,13 @@ export async function exportBrowserVideo({
onProgress?.({ progress: 16, phaseKey: "exportStartRecording" });
recorder.start(250);
const startTime = performance.now();
let startTime = 0;
let audioStartTime = 0;
let animationFrame = 0;
let lastProgressUpdate = 0;
let activeVideoItem = null;
const getVisualItemAtTime = (elapsed) => {
const visualIndex = getVisualSegmentIndexAtTime(exportVisualSegments, elapsed);
const getVisualItemAtTime = (timelineTime) => {
const visualIndex = getVisualSegmentIndexAtTime(exportVisualSegments, timelineTime);
const resolvedIndex =
visualIndex >= 0
? visualIndex
@@ -1091,15 +1171,30 @@ export async function exportBrowserVideo({
return elapsed >= start && elapsed < end;
});
};
const getVisualOverlaysAtTime = (timelineTime) => visualOverlayItems
.filter(({ segment }) => timelineTime >= segment.start && timelineTime < segment.start + segment.duration)
.sort((left, right) => (left.segment.layer || 1) - (right.segment.layer || 1));
const syncVisualOverlays = (items, timelineTime) => {
items.forEach(({ segment, visual }) => {
if (segment.type !== "video") return;
const expectedTime = getVisualSourceTime(segment, Math.max(0, timelineTime - segment.start));
if (!visual.seeking && Math.abs((visual.currentTime || 0) - expectedTime) > 0.12) visual.currentTime = expectedTime;
visual.playbackRate = normalizeVisualPlaybackRate(segment.playbackRate);
visual.play().catch(() => {});
});
};
const draw = () => {
const elapsed = Math.min(totalDuration, (performance.now() - startTime) / 1000);
const segmentIndex = getSegmentIndexAtTime(exportSegments, elapsed, captionTargetDuration);
const timelineTime = rangeStart + elapsed;
const segmentIndex = getSegmentIndexAtTime(exportSegments, timelineTime, captionTargetDuration);
const exportCaption =
segmentIndex >= 0 && !exportSegments[segmentIndex]?.hidden ? segments[segmentIndex] : "";
const { item: visualItem, range: visualRange } = getVisualItemAtTime(elapsed);
const localTime = Math.max(0, elapsed - (visualRange?.start ?? 0));
const { item: visualItem, range: visualRange } = getVisualItemAtTime(timelineTime);
const localTime = Math.max(0, timelineTime - (visualRange?.start ?? 0));
const visualSourceTime = syncVideoItem(visualItem, localTime);
const exportStickers = getStickersAtTime(elapsed);
const exportStickers = getStickersAtTime(timelineTime);
const activeVisualOverlays = getVisualOverlaysAtTime(timelineTime);
syncVisualOverlays(activeVisualOverlays, timelineTime);
const exportVisual = visualItem.cutoutVisual || visualItem.visual;
const visualIndex = visualItems.indexOf(visualItem);
const junction = visualItem.segment.transition;
@@ -1107,8 +1202,8 @@ export async function exportBrowserVideo({
? Math.max(0.1, Math.min(Number(junction.duration) || 0.5, (visualRange?.end || 0) - (visualRange?.start || 0)))
: 0;
const transitionStart = (visualRange?.end || 0) - transitionDuration;
const nextVisualItem = transitionDuration > 0 && elapsed >= transitionStart ? visualItems[visualIndex + 1] : null;
const transitionProgress = nextVisualItem ? (elapsed - transitionStart) / transitionDuration : 0;
const nextVisualItem = transitionDuration > 0 && timelineTime >= transitionStart ? visualItems[visualIndex + 1] : null;
const transitionProgress = nextVisualItem ? (timelineTime - transitionStart) / transitionDuration : 0;
if (nextVisualItem?.segment.type === "video") {
const nextTime = Math.max(0, Number(nextVisualItem.segment.sourceStart) || 0) + transitionProgress * transitionDuration;
if (!nextVisualItem.visual.seeking && Math.abs(nextVisualItem.visual.currentTime - nextTime) > 0.05) nextVisualItem.visual.currentTime = nextTime;
@@ -1145,6 +1240,11 @@ export async function exportBrowserVideo({
vision: frameVision,
visualEffects: visualItem.segment,
visualTime: localTime,
visualOverlays: activeVisualOverlays.map(({ segment }) => ({
...segment,
start: segment.start - (visualRange?.start ?? 0),
})),
visualOverlaySources: activeVisualOverlays.map(({ visual }) => visual),
});
if (elapsed === totalDuration || performance.now() - lastProgressUpdate > 180) {
@@ -1155,83 +1255,108 @@ export async function exportBrowserVideo({
});
}
if (elapsed < totalDuration) {
if (!signal?.aborted && elapsed < totalDuration) {
animationFrame = requestAnimationFrame(draw);
}
};
draw();
sources.forEach(({ node, start, sourceOffset, sourceDuration }) => node.start(start, sourceOffset, sourceDuration));
await new Promise((resolve) => {
window.setTimeout(resolve, totalDuration * 1000);
});
cancelAnimationFrame(animationFrame);
visualItems.forEach((item) => {
if (item.segment.type === "video") {
item.visual.pause();
try {
throwIfExportAborted(signal);
// Give MediaRecorder a short warm-up window before the timeline and Web
// Audio sources start. Without it, short exports can lose their first
// audio packet under CPU load and produce a one-timeslice file.
await waitForExportTimeout(60, signal, window);
throwIfExportAborted(signal);
startTime = performance.now();
audioStartTime = audioContext?.currentTime || 0;
draw();
sources.forEach(({ node, start, sourceOffset, sourceDuration }) => node.start(audioStartTime + start, sourceOffset, sourceDuration));
await waitForExportTimeout(totalDuration * 1000, signal, window);
throwIfExportAborted(signal);
const finalTimelineTime = rangeStart + Math.max(0, totalDuration - 1 / exportFrameRate);
const finalSegmentIndex = getSegmentIndexAtTime(exportSegments, finalTimelineTime, captionTargetDuration);
const { item: finalVisualItem, range: finalVisualRange } = getVisualItemAtTime(finalTimelineTime);
const finalStickers = getStickersAtTime(finalTimelineTime);
const finalVisualOverlays = getVisualOverlaysAtTime(finalTimelineTime);
syncVisualOverlays(finalVisualOverlays, finalTimelineTime);
const finalLocalTime = Math.max(0, finalTimelineTime - (finalVisualRange?.start ?? 0));
const finalVisualSourceTime = syncVideoItem(finalVisualItem, finalLocalTime);
const finalResolvedVision = resolveVisionAnalysisAtTime(
finalVisualItem.segment.vision ?? null,
finalVisualSourceTime,
);
const finalFrameVision = finalResolvedVision
? {
...finalResolvedVision,
options: finalVisualItem.segment.vision?.options ?? finalResolvedVision.options,
maskVisual: finalResolvedVision.cutoutUrl
? finalVisualItem.temporalMaskCache?.get(finalResolvedVision.cutoutUrl) ?? null
: null,
}
: null;
drawPreviewFrame(context, finalVisualItem.cutoutVisual || finalVisualItem.visual, canvas, {
subtitle:
finalSegmentIndex >= 0 && !exportSegments[finalSegmentIndex]?.hidden
? segments[finalSegmentIndex]
: "",
progress: 1,
fitMode,
filter,
captionsEnabled,
captionPosition,
captionPlacement,
captionSize,
captionStyle,
captionReferenceSize,
stickers: finalStickers,
stickerImages: finalStickers.map((item) => item?.src ? stickerImageMap.get(item.src) : null),
transitionId,
vision: finalFrameVision,
visualTime: finalLocalTime,
visualOverlays: finalVisualOverlays.map(({ segment }) => ({
...segment,
start: segment.start - (finalVisualRange?.start ?? 0),
})),
visualOverlaySources: finalVisualOverlays.map(({ visual }) => visual),
});
recorder.stop();
onProgress?.({ progress: 94, phaseKey: "exportPackageFile" });
await stopped;
throwIfExportAborted(signal);
const blobType = recorder.mimeType || recordingFormat.mimeType || "video/webm";
return {
blob: new Blob(chunks, { type: blobType }),
extension: recordingFormat.extension,
label: recordingFormat.label,
mimeType: blobType,
nativeMp4: recordingFormat.extension === "mp4",
};
} finally {
cancelAnimationFrame(animationFrame);
if (recorder.state !== "inactive") {
try { recorder.stop(); } catch { /* Recorder may already be stopping. */ }
}
});
const finalSegmentIndex = getSegmentIndexAtTime(exportSegments, totalDuration, captionTargetDuration);
const { item: finalVisualItem, range: finalVisualRange } = getVisualItemAtTime(totalDuration);
const finalStickers = getStickersAtTime(Math.max(0, totalDuration - 0.0001));
const finalLocalTime = Math.max(0, totalDuration - (finalVisualRange?.start ?? 0));
const finalVisualSourceTime = syncVideoItem(finalVisualItem, finalLocalTime);
const finalResolvedVision = resolveVisionAnalysisAtTime(
finalVisualItem.segment.vision ?? null,
finalVisualSourceTime,
);
const finalFrameVision = finalResolvedVision
? {
...finalResolvedVision,
options: finalVisualItem.segment.vision?.options ?? finalResolvedVision.options,
maskVisual: finalResolvedVision.cutoutUrl
? finalVisualItem.temporalMaskCache?.get(finalResolvedVision.cutoutUrl) ?? null
: null,
sources.forEach(({ node }) => {
try { node.stop(); } catch { /* Audio source may already have ended. */ }
});
canvasStream.getTracks().forEach((track) => track.stop());
destination?.stream.getTracks().forEach((track) => track.stop());
await audioContext?.close().catch(() => {});
visualItems.forEach((item) => {
item.temporalMaskCache?.dispose();
if (item.segment.type === "video") {
item.visual.pause();
item.visual.removeAttribute("src");
item.visual.load();
}
: null;
drawPreviewFrame(context, finalVisualItem.cutoutVisual || finalVisualItem.visual, canvas, {
subtitle:
finalSegmentIndex >= 0 && !exportSegments[finalSegmentIndex]?.hidden
? segments[finalSegmentIndex]
: "",
progress: 1,
fitMode,
filter,
captionsEnabled,
captionPosition,
captionPlacement,
captionSize,
captionStyle,
captionReferenceSize,
stickers: finalStickers,
stickerImages: finalStickers.map((item) => item?.src ? stickerImageMap.get(item.src) : null),
transitionId,
vision: finalFrameVision,
});
recorder.stop();
onProgress?.({ progress: 94, phaseKey: "exportPackageFile" });
await stopped;
canvasStream.getTracks().forEach((track) => track.stop());
destination?.stream.getTracks().forEach((track) => track.stop());
await audioContext?.close().catch(() => {});
visualItems.forEach((item) => {
item.temporalMaskCache?.dispose();
if (item.segment.type === "video") {
item.visual.pause();
item.visual.removeAttribute("src");
item.visual.load();
}
});
const blobType = recorder.mimeType || recordingFormat.mimeType || "video/webm";
return {
blob: new Blob(chunks, { type: blobType }),
extension: recordingFormat.extension,
label: recordingFormat.label,
mimeType: blobType,
nativeMp4: recordingFormat.extension === "mp4",
};
});
visualOverlayItems.forEach(({ segment, visual }) => {
if (segment.type !== "video") return;
visual.pause();
visual.removeAttribute("src");
visual.load();
});
}
}
async function getFfmpeg() {
@@ -1348,39 +1473,111 @@ export async function encodePngFrameSequence({ totalFrames, frameRate, produceFr
});
}
export async function transcodeWebmToMp4(webmBlob) {
export async function transcodeWebmToMp4(webmBlob, { signal } = {}) {
return runFfmpegTask(async () => {
const [{ fetchFile }, ffmpeg] = await Promise.all([import("@ffmpeg/util"), getFfmpeg()]);
if (signal?.aborted) throw createAbortError("Export canceled");
let ffmpeg = null;
let terminated = false;
const id = makeId("export");
const inputName = `${id}.webm`;
const outputName = `${id}.mp4`;
await ffmpeg.writeFile(inputName, await fetchFile(webmBlob));
const abort = () => {
terminated = true;
try { ffmpeg?.terminate(); } catch { /* FFmpeg may already be stopped. */ }
ffmpegLoadPromise = null;
};
signal?.addEventListener("abort", abort, { once: true });
try {
await ffmpeg.exec([
"-i",
inputName,
"-c:v",
"libx264",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-movflags",
"faststart",
outputName,
]);
} catch (error) {
await ffmpeg.deleteFile(outputName).catch(() => {});
await ffmpeg.exec(["-i", inputName, "-movflags", "faststart", outputName]);
const [{ fetchFile }, loadedFfmpeg] = await Promise.all([import("@ffmpeg/util"), getAbortableFfmpeg(signal)]);
ffmpeg = loadedFfmpeg;
if (signal?.aborted) throw createAbortError("Export canceled");
await ffmpeg.writeFile(inputName, await fetchFile(webmBlob));
try {
await ffmpeg.exec([
"-i",
inputName,
"-c:v",
"libx264",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-movflags",
"faststart",
outputName,
]);
} catch {
if (signal?.aborted) throw createAbortError("Export canceled");
await ffmpeg.deleteFile(outputName).catch(() => {});
await ffmpeg.exec(["-i", inputName, "-movflags", "faststart", outputName]);
}
if (signal?.aborted) throw createAbortError("Export canceled");
const data = await ffmpeg.readFile(outputName);
return new Blob([data], { type: "video/mp4" });
} finally {
signal?.removeEventListener("abort", abort);
if (!terminated && ffmpeg) {
await ffmpeg.deleteFile(inputName).catch(() => {});
await ffmpeg.deleteFile(outputName).catch(() => {});
}
}
const data = await ffmpeg.readFile(outputName);
await ffmpeg.deleteFile(inputName).catch(() => {});
await ffmpeg.deleteFile(outputName).catch(() => {});
});
}
return new Blob([data], { type: "video/mp4" });
export async function normalizeVideoForEditing(videoBlob, filename = "source-video.mkv", { decodedAudioBlob = null } = {}) {
return runFfmpegTask(async () => {
const [{ fetchFile }, ffmpeg] = await Promise.all([import("@ffmpeg/util"), getFfmpeg()]);
const id = makeId("compat-video");
const extension = filename.split(".").pop()?.toLowerCase().replace(/[^a-z0-9]/g, "") || "mkv";
const inputName = `${id}.${extension}`;
const audioInputName = `${id}-libav-audio.wav`;
const outputName = `${id}.mp4`;
await ffmpeg.writeFile(inputName, await fetchFile(videoBlob));
if (decodedAudioBlob) await ffmpeg.writeFile(audioInputName, await fetchFile(decodedAudioBlob));
const inputArgs = decodedAudioBlob ? ["-i", inputName, "-i", audioInputName] : ["-i", inputName];
const audioMap = decodedAudioBlob ? ["-map", "1:a:0"] : ["-map", "0:a:0?"];
try {
try {
await ffmpeg.exec([
...inputArgs, "-map", "0:v:0", ...audioMap,
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-movflags", "faststart", outputName,
]);
} catch {
await ffmpeg.deleteFile(outputName).catch(() => {});
await ffmpeg.exec([
...inputArgs, "-map", "0:v:0", ...audioMap,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k", "-movflags", "faststart", outputName,
]);
}
const data = await ffmpeg.readFile(outputName);
return new Blob([data], { type: "video/mp4" });
} finally {
await ffmpeg.deleteFile(inputName).catch(() => {});
if (decodedAudioBlob) await ffmpeg.deleteFile(audioInputName).catch(() => {});
await ffmpeg.deleteFile(outputName).catch(() => {});
}
});
}
export async function transcodeAudioToWav(audioBlob, filename = "source-audio.bin") {
return runFfmpegTask(async () => {
const [{ fetchFile }, ffmpeg] = await Promise.all([import("@ffmpeg/util"), getFfmpeg()]);
const id = makeId("compat-audio");
const extension = filename.split(".").pop()?.toLowerCase().replace(/[^a-z0-9]/g, "") || "bin";
const inputName = `${id}.${extension}`;
const outputName = `${id}.wav`;
await ffmpeg.writeFile(inputName, await fetchFile(audioBlob));
try {
await ffmpeg.exec(["-i", inputName, "-vn", "-ac", "2", "-ar", "48000", "-c:a", "pcm_s16le", outputName]);
const data = await ffmpeg.readFile(outputName);
return new Blob([data], { type: "audio/wav" });
} finally {
await ffmpeg.deleteFile(inputName).catch(() => {});
await ffmpeg.deleteFile(outputName).catch(() => {});
}
});
}
+125
View File
@@ -0,0 +1,125 @@
const COMPATIBLE_CONTAINER_EXTENSIONS = new Set(["mkv", "mka"]);
const KNOWN_IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp"]);
const KNOWN_VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "mkv"]);
const KNOWN_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "m4a", "aac", "ogg", "opus", "flac", "mka", "ac3"]);
export const MEDIA_BACKENDS = Object.freeze({
NATIVE: "native",
WEBCODECS: "webcodecs",
LIBAV: "libav",
FFMPEG: "ffmpeg",
});
export function getMediaFileExtension(fileOrName) {
const name = typeof fileOrName === "string" ? fileOrName : fileOrName?.name;
return (
String(name || "")
.split(".")
.pop()
?.toLowerCase()
.replace(/[^a-z0-9]/g, "") || ""
);
}
export function getMediaFileKind(file) {
const extension = getMediaFileExtension(file);
if (extension === "mka" || extension === "ac3") return "audio";
if (extension === "mkv") return "video";
const mime = String(file?.type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
if (KNOWN_IMAGE_EXTENSIONS.has(extension)) return "image";
if (KNOWN_VIDEO_EXTENSIONS.has(extension)) return "video";
if (KNOWN_AUDIO_EXTENSIONS.has(extension)) return "audio";
return "";
}
export function isSupportedMediaFile(file) {
return Boolean(getMediaFileKind(file));
}
export function isLibavCompatibilityEnabled(env = import.meta.env) {
return env?.VITE_MEDIA_COMPATIBILITY_FALLBACK !== "false";
}
export function shouldProbeWithLibav(file, options = {}) {
if (!isLibavCompatibilityEnabled(options.env)) return false;
if (options.nativeMetadataError)
return getMediaFileKind(file) === "video" || getMediaFileKind(file) === "audio";
return COMPATIBLE_CONTAINER_EXTENSIONS.has(getMediaFileExtension(file));
}
export function selectMediaBackends({
nativeReadable = false,
container = "",
videoCodec = "",
audioCodec = "",
webCodecsVideoSupported = false,
webCodecsAudioSupported = false,
libavAudioSupported = false,
libavEnabled = true,
} = {}) {
const normalizedContainer = String(container).toLowerCase();
const normalizedVideo = String(videoCodec).toLowerCase();
const normalizedAudio = String(audioCodec).toLowerCase();
const matroska = normalizedContainer.includes("matroska") || normalizedContainer === "mkv";
const audioNeedsFallback = Boolean(normalizedAudio) && !webCodecsAudioSupported;
const videoNeedsFallback = Boolean(normalizedVideo) && !webCodecsVideoSupported;
if (nativeReadable && !matroska) {
return {
probe: MEDIA_BACKENDS.NATIVE,
video: MEDIA_BACKENDS.NATIVE,
audio: MEDIA_BACKENDS.NATIVE,
needsNormalization: false,
};
}
return {
probe: libavEnabled ? MEDIA_BACKENDS.LIBAV : MEDIA_BACKENDS.FFMPEG,
video: videoNeedsFallback ? MEDIA_BACKENDS.FFMPEG : MEDIA_BACKENDS.WEBCODECS,
audio: audioNeedsFallback
? libavAudioSupported
? MEDIA_BACKENDS.LIBAV
: MEDIA_BACKENDS.FFMPEG
: MEDIA_BACKENDS.WEBCODECS,
needsNormalization: matroska || videoNeedsFallback || audioNeedsFallback,
};
}
export function emitMediaBackendDiagnostic(detail) {
if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") return;
window.dispatchEvent(new CustomEvent("timeline-studio:media-backend", { detail }));
}
export async function probeMediaCompatibility(file, options = {}) {
if (!shouldProbeWithLibav(file, options)) return null;
emitMediaBackendDiagnostic({
phase: "probing",
backend: MEDIA_BACKENDS.LIBAV,
name: file?.name || "",
});
try {
const { probeAndDecodeAudioWithLibavWorker, probeWithLibavWorker } =
await import("./libavCompatibilityClient.js");
const result = options.decodeAudio
? await probeAndDecodeAudioWithLibavWorker(file, options)
: await probeWithLibavWorker(file, options);
emitMediaBackendDiagnostic({
phase: "ready",
backend: MEDIA_BACKENDS.LIBAV,
name: file?.name || "",
result,
});
return result;
} catch (error) {
emitMediaBackendDiagnostic({
phase: "failed",
backend: MEDIA_BACKENDS.LIBAV,
name: file?.name || "",
message: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
getMediaFileExtension,
getMediaFileKind,
isLibavCompatibilityEnabled,
isSupportedMediaFile,
MEDIA_BACKENDS,
selectMediaBackends,
shouldProbeWithLibav,
} from "./mediaCompatibility.js";
describe("media compatibility routing", () => {
it("accepts Matroska files even when the browser supplies no MIME type", () => {
const file = { name: "camera-master.MKV", type: "" };
expect(getMediaFileExtension(file)).toBe("mkv");
expect(getMediaFileKind(file)).toBe("video");
expect(isSupportedMediaFile(file)).toBe(true);
expect(shouldProbeWithLibav(file)).toBe(true);
});
it("keeps ordinary browser-readable MP4 on the native fast path", () => {
expect(
selectMediaBackends({
nativeReadable: true,
container: "mp4",
videoCodec: "h264",
audioCodec: "aac",
webCodecsVideoSupported: true,
webCodecsAudioSupported: true,
}),
).toEqual({
probe: MEDIA_BACKENDS.NATIVE,
video: MEDIA_BACKENDS.NATIVE,
audio: MEDIA_BACKENDS.NATIVE,
needsNormalization: false,
});
});
it("routes MKV H.264 through WebCodecs and AC3 through the custom libav decoder", () => {
expect(
selectMediaBackends({
container: "matroska",
videoCodec: "h264",
audioCodec: "ac3",
webCodecsVideoSupported: true,
webCodecsAudioSupported: false,
libavAudioSupported: true,
}),
).toEqual({
probe: MEDIA_BACKENDS.LIBAV,
video: MEDIA_BACKENDS.WEBCODECS,
audio: MEDIA_BACKENDS.LIBAV,
needsNormalization: true,
});
});
it("supports disabling the optional compatibility layer", () => {
const file = { name: "archive.mkv", type: "video/x-matroska" };
expect(isLibavCompatibilityEnabled({ VITE_MEDIA_COMPATIBILITY_FALLBACK: "false" })).toBe(false);
expect(
shouldProbeWithLibav(file, {
env: { VITE_MEDIA_COMPATIBILITY_FALLBACK: "false" },
nativeMetadataError: true,
}),
).toBe(false);
});
it("does not require browser globals for pure routing", () => {
expect(getMediaFileKind({ name: "voice.flac", type: "" })).toBe("audio");
expect(getMediaFileKind({ name: "surround.ac3", type: "" })).toBe("audio");
expect(getMediaFileKind({ name: "audio-only.mka", type: "video/x-matroska" })).toBe("audio");
});
});
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { transcodeWebmToMp4 } from "./media.js";
describe("media export cancellation", () => {
it("does not load FFmpeg for an already canceled MP4 transcode", async () => {
const controller = new AbortController();
controller.abort();
await expect(transcodeWebmToMp4(new Blob(), { signal: controller.signal }))
.rejects.toMatchObject({ name: "AbortError" });
});
});
+81 -31
View File
@@ -6,12 +6,14 @@ import {
CanvasSink,
CanvasSource,
Input,
MovOutputFormat,
Mp4OutputFormat,
Output,
WebMOutputFormat,
} from "mediabunny";
import { registerAacEncoder } from "@mediabunny/aac-encoder";
import { throwIfExportAborted } from "./exportCancellation.js";
import {
createTemporalMaskCache,
drawPreviewFrame,
@@ -32,21 +34,23 @@ import { createPitchPreservedAudioBuffer } from "./pitchPreservingTimeStretch.js
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
let aacFallbackRegistered = false;
export function createOfflineFramePlan(duration, frameRate) {
export function createOfflineFramePlan(duration, frameRate, keyFrameInterval = 2) {
const fps = clamp(Math.round(Number(frameRate) || 30), 24, 60);
const safeDuration = Math.max(1 / fps, Number(duration) || 0);
const frameCount = Math.max(1, Math.ceil(safeDuration * fps));
const keyFrameEvery = Math.max(1, Math.round(fps * clamp(Number(keyFrameInterval) || 2, 0.25, 10)));
return Array.from({ length: frameCount }, (_, index) => ({
index,
timestamp: index / fps,
duration: 1 / fps,
keyFrame: index % (fps * 2) === 0,
keyFrame: index % keyFrameEvery === 0,
}));
}
export function getOfflineExportCodec(settings = {}) {
if (settings.codec === "vp8") return { video: "vp8", audio: "opus", extension: "webm", mimeType: "video/webm" };
if (settings.codec === "vp9") return { video: "vp9", audio: "opus", extension: "webm", mimeType: "video/webm" };
if (settings.codec === "h264-mov") return { video: "avc", audio: "aac", extension: "mov", mimeType: "video/quicktime", label: "MOV" };
return { video: "avc", audio: "aac", extension: "mp4", mimeType: "video/mp4" };
}
@@ -90,6 +94,7 @@ export async function mixOfflineAudio({
musicVolume = 0.35,
musicStart = 0,
musicSegments = [],
timelineOffset = 0,
}) {
const inputs = [
...voiceAudioSegments.filter((item) => item.blob).map((item) => ({
@@ -114,45 +119,66 @@ export async function mixOfflineAudio({
const OfflineContextClass = window.OfflineAudioContext || window.webkitOfflineAudioContext;
if (!OfflineContextClass) throw new Error("当前浏览器不支持离线音频混合。");
const context = new OfflineContextClass(2, Math.ceil(Math.max(0.01, duration) * sampleRate), sampleRate);
const rangeStart = Math.max(0, Number(timelineOffset) || 0);
const rangeEnd = rangeStart + Math.max(0.01, duration);
decoded.forEach((input) => {
const playbackRate = clamp(Number(input.playbackRate) || 1, 0.25, 4);
const originalOffset = Math.min(input.decoded.duration, input.sourceOffset);
const available = Math.max(0, input.decoded.duration - originalOffset);
const originalSourceDuration = Math.min(available, input.sourceDuration || available);
const originalOutputDuration = originalSourceDuration / playbackRate;
const visibleStart = Math.max(input.start, rangeStart);
const visibleEnd = Math.min(input.start + originalOutputDuration, rangeEnd);
if (visibleEnd <= visibleStart) return;
const trimOutput = visibleStart - input.start;
const outputDuration = visibleEnd - visibleStart;
const offset = originalOffset + trimOutput * playbackRate;
const sourceDuration = Math.min(input.decoded.duration - offset, outputDuration * playbackRate);
if (!(sourceDuration > 0)) return;
const source = context.createBufferSource();
const gain = context.createGain();
const offset = Math.min(input.decoded.duration, input.sourceOffset);
const available = Math.max(0, input.decoded.duration - offset);
const sourceDuration = Math.min(available, input.sourceDuration || available);
const outputDuration = sourceDuration / input.playbackRate;
const preservePitch = Math.abs(input.playbackRate - 1) > 0.0001;
const preservePitch = Math.abs(playbackRate - 1) > 0.0001;
source.buffer = preservePitch
? createPitchPreservedAudioBuffer(context, input.decoded, {
sourceOffset: offset,
sourceDuration,
playbackRate: input.playbackRate,
playbackRate,
})
: input.decoded;
source.playbackRate.value = 1;
gain.gain.setValueAtTime(input.volume, input.start);
if (input.fadeIn > 0) {
gain.gain.setValueAtTime(0, input.start);
gain.gain.linearRampToValueAtTime(input.volume, input.start + Math.min(input.fadeIn, outputDuration));
const outputStart = visibleStart - rangeStart;
const fadeInRemaining = Math.max(0, input.fadeIn - trimOutput);
const originalOutputEnd = input.start + originalOutputDuration;
const fadeOutStart = originalOutputEnd - input.fadeOut;
gain.gain.setValueAtTime(input.volume, outputStart);
if (fadeInRemaining > 0) {
gain.gain.setValueAtTime(input.volume * clamp(trimOutput / input.fadeIn, 0, 1), outputStart);
gain.gain.linearRampToValueAtTime(input.volume, outputStart + Math.min(fadeInRemaining, outputDuration));
}
if (input.fadeOut > 0) {
const fadeStart = input.start + Math.max(0, outputDuration - input.fadeOut);
gain.gain.setValueAtTime(input.volume, fadeStart);
gain.gain.linearRampToValueAtTime(0, input.start + outputDuration);
if (input.fadeOut > 0 && visibleEnd > fadeOutStart) {
const localFadeStart = Math.max(outputStart, fadeOutStart - rangeStart);
gain.gain.setValueAtTime(input.volume, localFadeStart);
gain.gain.linearRampToValueAtTime(
input.volume * clamp((originalOutputEnd - visibleEnd) / input.fadeOut, 0, 1),
outputStart + outputDuration,
);
}
source.connect(gain).connect(context.destination);
source.start(input.start, preservePitch ? 0 : offset, preservePitch ? outputDuration : sourceDuration);
source.start(outputStart, preservePitch ? 0 : offset, preservePitch ? outputDuration : sourceDuration);
});
return context.startRendering();
}
async function prepareComposition(options) {
throwIfExportAborted(options.signal);
const segments = options.visualSegments.some((segment) => segment.src)
? options.visualSegments.filter((segment) => segment.src)
: [{ id: "offline-visual", src: options.imageSrc, type: options.visualType, duration: options.duration }];
const timeline = getVisualSegmentTimeline(segments);
const items = await Promise.all(segments.map(async (segment, index) => {
throwIfExportAborted(options.signal);
const visual = segment.type === "video" ? await loadVideo(segment.src) : await loadImage(segment.src);
throwIfExportAborted(options.signal);
const cutoutVisual = segment.type === "image" && segment.vision?.options?.removeBackground && segment.vision?.cutoutUrl
? await loadImage(segment.vision.cutoutUrl).catch(() => null) : null;
const maskUrls = segment.type === "video" && segment.vision?.options?.removeBackground
@@ -160,7 +186,7 @@ async function prepareComposition(options) {
let sequentialFrames = null;
if (segment.type === "video" && options.framePlan?.length) {
try {
const blob = segment.blob instanceof Blob ? segment.blob : await fetch(segment.src).then((response) => {
const blob = segment.blob instanceof Blob ? segment.blob : await fetch(segment.src, { signal: options.signal }).then((response) => {
if (!response.ok) throw new Error(`Unable to read video source (${response.status})`);
return response.blob();
});
@@ -190,6 +216,7 @@ async function prepareComposition(options) {
decodeMode: segment.type === "video" ? (sequentialFrames ? "sequential-webcodecs" : "precise-seek") : "static-image",
};
}));
throwIfExportAborted(options.signal);
const stickerSources = [...new Set([
...options.stickerSegments.map((item) => item.src).filter(Boolean),
...(options.sticker?.src ? [options.sticker.src] : []),
@@ -199,6 +226,7 @@ async function prepareComposition(options) {
segment,
visual: segment.type === "video" ? await loadVideo(segment.src) : await loadImage(segment.src),
})));
throwIfExportAborted(options.signal);
return { segments, timeline, items, stickerImages, overlayItems };
}
@@ -231,7 +259,7 @@ async function renderCompositionAt(context, canvas, prepared, options, time) {
const next = transitionProgress > 0 ? prepared.items[index + 1] : null;
if (next?.segment.type === "video") await seekVideoFrame(next.visual, getVisualSourceTime(next.segment, transitionProgress * transitionDuration));
const captionSegments = options.captionSegments?.length ? options.captionSegments : createCaptionSegments(options.text);
const captionIndex = getSegmentIndexAtTime(captionSegments, time, 0);
const captionIndex = getSegmentIndexAtTime(captionSegments, time, options.captionTargetDuration || 0);
const caption = captionIndex >= 0 && !captionSegments[captionIndex]?.hidden ? captionSegments[captionIndex].text : "";
const stickers = getOfflineStickersAtTime(options.stickerSegments, options.sticker, time);
const activeOverlaySegments = getOfflineVisualOverlaysAtTime(prepared.overlayItems.map((item) => item.segment), time);
@@ -259,6 +287,7 @@ async function renderCompositionAt(context, canvas, prepared, options, time) {
}
export async function exportOfflineVideo(options) {
throwIfExportAborted(options.signal);
if (typeof VideoEncoder === "undefined") throw new Error("当前浏览器不支持 WebCodecs 离线编码。");
const settings = options.exportSettings || {};
const width = Math.max(2, Math.round(Number(settings.width) || options.ratio.width));
@@ -273,41 +302,60 @@ export async function exportOfflineVideo(options) {
const context = canvas.getContext("2d", { alpha: false, desynchronized: false });
if (!context) throw new Error("无法创建离线导出画布。");
options.onProgress?.({ progress: 4, phaseKey: "exportOfflinePreparing" });
const frames = createOfflineFramePlan(options.duration, settings.frameRate);
const preparedPromise = prepareComposition({ ...options, framePlan: frames });
const frames = createOfflineFramePlan(options.duration, settings.frameRate, settings.keyFrameInterval);
const timelineOffset = Math.max(0, Number(options.timelineOffset) || 0);
const compositionFrames = frames.map((frame) => ({ ...frame, timestamp: frame.timestamp + timelineOffset }));
const preparedPromise = prepareComposition({ ...options, framePlan: compositionFrames });
const audioPromise = mixOfflineAudio({ ...options, duration: frames.length * frames[0].duration });
const [prepared, audioBuffer] = await Promise.all([preparedPromise, audioPromise]);
throwIfExportAborted(options.signal);
const target = new BufferTarget();
const output = new Output({ format: codec.extension === "mp4" ? new Mp4OutputFormat({ fastStart: "in-memory" }) : new WebMOutputFormat(), target });
const outputFormat = codec.extension === "mp4"
? new Mp4OutputFormat({ fastStart: "in-memory" })
: codec.extension === "mov"
? new MovOutputFormat({ fastStart: "in-memory" })
: new WebMOutputFormat();
const output = new Output({ format: outputFormat, target });
let encoderConfig = null;
const videoSource = new CanvasSource(canvas, {
codec: codec.video,
bitrate: Math.max(2_000_000, Number(settings.videoBitsPerSecond) || 12_000_000),
keyFrameInterval: 2,
latencyMode: "quality",
bitrate: Math.max(1_000_000, Number(settings.videoBitsPerSecond) || 12_000_000),
keyFrameInterval: Math.max(0.25, Number(settings.keyFrameInterval) || 2),
latencyMode: "realtime",
onEncoderConfig: (config) => { encoderConfig = config; },
});
output.addVideoTrack(videoSource, { frameRate: frames.length / Math.max(options.duration, 1 / frames[0].duration) });
output.addVideoTrack(videoSource, { frameRate: frames.length / Math.max(options.duration, frames[0].duration) });
let audioSource = null;
const audioBitrate = Math.max(96_000, Number(settings.audioBitsPerSecond) || 192_000);
if (audioBuffer) {
audioSource = new AudioBufferSource({ codec: codec.audio, bitrate: codec.audio === "aac" ? 256_000 : 192_000 });
audioSource = new AudioBufferSource({
codec: codec.audio,
bitrate: audioBitrate,
});
output.addAudioTrack(audioSource);
}
await output.start();
if (audioSource) await audioSource.add(audioBuffer);
const abortOutput = () => { output.cancel().catch(() => {}); };
options.signal?.addEventListener("abort", abortOutput, { once: true });
try {
throwIfExportAborted(options.signal);
await output.start();
if (audioSource) await audioSource.add(audioBuffer);
for (const frame of frames) {
await renderCompositionAt(context, canvas, prepared, options, frame.timestamp);
throwIfExportAborted(options.signal);
await renderCompositionAt(context, canvas, prepared, options, frame.timestamp + timelineOffset);
throwIfExportAborted(options.signal);
await videoSource.add(frame.timestamp, frame.duration, { keyFrame: frame.keyFrame });
if (frame.index % Math.max(1, Math.round(frames.length / 100)) === 0) {
options.onProgress?.({ progress: 10 + Math.round((frame.index / frames.length) * 84), phaseKey: "exportOfflineRendering", phaseParams: { current: frame.index + 1, total: frames.length } });
}
}
throwIfExportAborted(options.signal);
await output.finalize();
} catch (error) {
await output.cancel().catch(() => {});
throw error;
} finally {
options.signal?.removeEventListener("abort", abortOutput);
prepared.items.forEach((item) => {
item.temporalMaskCache?.dispose();
if (item.segment.type === "video") { item.visual.removeAttribute("src"); item.visual.load(); }
@@ -316,12 +364,14 @@ export async function exportOfflineVideo(options) {
if (item.segment.type === "video") { item.visual.removeAttribute("src"); item.visual.load(); }
});
}
throwIfExportAborted(options.signal);
options.onProgress?.({ progress: 98, phaseKey: "exportVerifyFile" });
return {
blob: new Blob([target.buffer], { type: codec.mimeType }), extension: codec.extension,
label: codec.extension === "mp4" ? "MP4" : "WebM", mimeType: codec.mimeType,
label: codec.label || (codec.extension === "mp4" ? "MP4" : "WebM"), mimeType: codec.mimeType,
nativeMp4: codec.extension === "mp4", diagnostics: {
width, height, frameCount: frames.length, frameRate: settings.frameRate, encoderConfig,
audioBitrate: audioSource ? audioBitrate : null,
videoDecodeModes: prepared.items.map((item) => item.decodeMode),
},
};
+18
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
createOfflineFramePlan,
exportOfflineVideo,
getOfflineExportCodec,
getOfflineStickersAtTime,
getOfflineVisualOverlaysAtTime,
@@ -9,6 +10,12 @@ import {
import { getVisualDimensions } from "./media.js";
describe("offline video export", () => {
it("stops before allocating encoder resources when export is already canceled", async () => {
const controller = new AbortController();
controller.abort();
await expect(exportOfflineVideo({ signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" });
});
it("creates deterministic frame timestamps without wall-clock drift", () => {
const frames = createOfflineFramePlan(1, 30);
expect(frames).toHaveLength(30);
@@ -21,8 +28,19 @@ describe("offline video export", () => {
expect(frames.filter((frame) => frame.keyFrame).map((frame) => frame.index)).toEqual([0, 60, 120]);
});
it("uses the selected keyframe interval", () => {
const frames = createOfflineFramePlan(3.1, 30, 1);
expect(frames.filter((frame) => frame.keyFrame).map((frame) => frame.index)).toEqual([0, 30, 60, 90]);
});
it("maps codecs directly to their final container without a lossy intermediate", () => {
expect(getOfflineExportCodec({ codec: "h264" })).toMatchObject({ video: "avc", audio: "aac", extension: "mp4" });
expect(getOfflineExportCodec({ codec: "h264-mov" })).toMatchObject({
video: "avc",
audio: "aac",
extension: "mov",
mimeType: "video/quicktime",
});
expect(getOfflineExportCodec({ codec: "vp9" })).toMatchObject({ video: "vp9", audio: "opus", extension: "webm" });
});
+29 -1
View File
@@ -1,4 +1,4 @@
import { makeId } from "./timeline.js";
import { getCaptionTimeline, makeId } from "./timeline.js";
export const MAX_SRT_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_SRT_CAPTIONS = 5000;
@@ -51,3 +51,31 @@ export function appendImportedCaptions(existing, imported) {
return aStart - bStart;
});
}
function formatSrtTimestamp(seconds) {
const milliseconds = Math.max(0, Math.round((Number(seconds) || 0) * 1000));
const hours = Math.floor(milliseconds / 3_600_000);
const minutes = Math.floor((milliseconds % 3_600_000) / 60_000);
const secs = Math.floor((milliseconds % 60_000) / 1000);
const millis = milliseconds % 1000;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")},${String(millis).padStart(3, "0")}`;
}
export function serializeSrt(captions, targetDuration = 0, options = {}) {
const source = Array.isArray(captions) ? captions : [];
const timeline = getCaptionTimeline(source, targetDuration);
const rangeStart = Math.max(0, Number(options.start) || 0);
const requestedEnd = Number(options.end);
const rangeEnd = Number.isFinite(requestedEnd) ? Math.max(rangeStart, requestedEnd) : Number.POSITIVE_INFINITY;
const blocks = [];
source.forEach((caption, index) => {
const text = String(caption?.text ?? "").replace(/\r\n?/g, "\n").trim();
const range = timeline[index];
if (caption?.hidden || !text || !range) return;
const start = Math.max(range.start, rangeStart);
const end = Math.min(range.end, rangeEnd);
if (end <= start) return;
blocks.push(`${blocks.length + 1}\r\n${formatSrtTimestamp(start - rangeStart)} --> ${formatSrtTimestamp(end - rangeStart)}\r\n${text}`);
});
return blocks.length ? `${blocks.join("\r\n\r\n")}\r\n` : "";
}
+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { appendImportedCaptions, parseSrt } from "./subtitles.js";
import { appendImportedCaptions, parseSrt, serializeSrt } from "./subtitles.js";
describe("parseSrt", () => {
it("parses BOM, CRLF, multiline text, tags, and missing sequence numbers", () => {
@@ -23,3 +23,34 @@ describe("appendImportedCaptions", () => {
expect(result.map((caption) => caption.id)).toEqual(["first", "later"]);
});
});
describe("serializeSrt", () => {
it("writes explicit caption timing and excludes hidden captions", () => {
expect(serializeSrt([
{ text: "First\nline", start: 1.25, end: 3.5 },
{ text: "Hidden", start: 3.5, end: 4, hidden: true },
{ text: "Second", start: 62.005, end: 64.05 },
])).toBe(
"1\r\n00:00:01,250 --> 00:00:03,500\r\nFirst\nline\r\n\r\n"
+ "2\r\n00:01:02,005 --> 00:01:04,050\r\nSecond\r\n",
);
});
it("materializes captions without explicit timing across the target duration", () => {
const serialized = serializeSrt([{ text: "One" }, { text: "Two" }], 4);
expect(serialized).toContain("00:00:00,000 --> 00:00:02,000");
expect(serialized).toContain("00:00:02,000 --> 00:00:04,000");
expect(parseSrt(serialized).captions.map((caption) => caption.text)).toEqual(["One", "Two"]);
});
it("clips captions to a custom export range and rebases timestamps", () => {
expect(serializeSrt([
{ text: "Before and inside", start: 1, end: 3 },
{ text: "Inside", start: 4, end: 5 },
{ text: "After", start: 7, end: 8 },
], 10, { start: 2, end: 6 })).toBe(
"1\r\n00:00:00,000 --> 00:00:01,000\r\nBefore and inside\r\n\r\n"
+ "2\r\n00:00:02,000 --> 00:00:03,000\r\nInside\r\n",
);
});
});
+1
View File
@@ -35,6 +35,7 @@ export function getVisualAssetPayload(asset) {
name: asset.name ?? "",
meta: asset.meta ?? "",
blob: asset.blob ?? null,
compatibilityAudioBlob: asset.compatibilityAudioBlob ?? null,
width: asset.width ?? asset.naturalWidth ?? 0,
height: asset.height ?? asset.naturalHeight ?? 0,
sourceStart: Math.max(0, Number(asset.sourceStart) || 0),
+2 -2
View File
@@ -62,7 +62,7 @@ describe("timeline command CLI", () => {
});
expect(rendered.verification.duration).toBeGreaterThanOrEqual(0.2);
expect((await readFile(output)).length).toBeGreaterThan(1000);
}, 20_000);
}, 60_000);
it("dry-runs and writes a new archive while preserving media entries", async () => {
const directory = await mkdtemp(join(tmpdir(), "timeline-command-"));
@@ -160,5 +160,5 @@ describe("timeline command CLI", () => {
musicSegments: [{ id: "music-imported", start: 1, name: "music.wav" }],
commandState: { revision: 1, appliedOperationIds: ["edit-caption", "add-caption", "split-visual", "set-volume", "speed-visual", "mute-visual", "hide-audio", "set-ratio", "append-visual", "add-overlay", "set-transition", "import-image", "import-music"] },
});
}, 20_000);
}, 60_000);
});
+39 -8
View File
@@ -426,8 +426,9 @@ button:disabled {
.timeline-mobile-clip-actions { display: none; }
.mobile-fixed-playhead { display: none; }
.export-settings-popover { width: 360px; padding: 0; overflow: hidden; }
.export-settings-card { display: grid; gap: 14px; padding: 18px; }
.export-settings-popover { display: flex; flex-direction: column; width: 400px; max-height: calc(100dvh - 70px); padding: 0; overflow: hidden; }
.export-settings-card { display: grid; min-height: 0; gap: 12px; padding: 18px 18px 12px; overflow-y: auto; overscroll-behavior: contain; touch-action: pan-y; }
.export-settings-footer { flex: 0 0 auto; padding: 8px 18px 18px; background: linear-gradient(180deg, rgba(16,19,25,.88), #101319 35%); }
.export-settings-heading { display: flex; align-items: flex-start; justify-content: space-between; padding-right: 24px; }
.export-settings-heading div { display: grid; gap: 4px; }
.export-settings-heading strong { color: #f4fbfc; font-size: 16px; }
@@ -435,13 +436,13 @@ button:disabled {
.export-settings-heading > span { border: 1px solid rgba(69,245,228,.22); border-radius: 5px; padding: 3px 7px; color: #45e8d8; background: rgba(69,245,228,.08); font-size: 10px; font-weight: 750; }
.export-setting-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.export-setting-field { display: grid; gap: 7px; color: #aebbc3; font-size: 12px; }
.export-setting-field select { width: 100%; height: 36px; border: 1px solid rgba(255,255,255,.1); border-radius: 7px; padding: 0 10px; color: #eaf5f6; outline: 0; background: #20252d; }
.export-setting-field select:focus { border-color: rgba(69,245,228,.48); }
.export-quality-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.export-quality-options button { height: 34px; border: 1px solid rgba(255,255,255,.09); border-radius: 7px; color: #abb6bf; background: rgba(255,255,255,.04); cursor: pointer; }
.export-quality-options button.is-selected { border-color: rgba(69,245,228,.45); color: #eafffd; background: rgba(69,245,228,.12); }
.export-setting-field select, .export-setting-field input { width: 100%; height: 36px; border: 1px solid rgba(255,255,255,.1); border-radius: 7px; padding: 0 10px; color: #eaf5f6; outline: 0; background: #20252d; }
.export-setting-field select:focus, .export-setting-field input:focus { border-color: rgba(69,245,228,.48); }
.export-setting-field select:disabled { color: #657078; background: #171b21; cursor: not-allowed; }
.export-technical-summary { display: flex; flex-wrap: wrap; gap: 5px; }
.export-technical-summary span { padding: 4px 7px; border: 1px solid rgba(69,245,228,.12); border-radius: 5px; color: #9fb7b7; background: rgba(69,245,228,.045); font-size: 10px; font-variant-numeric: tabular-nums; }
.export-settings-note { padding: 9px 10px; border-radius: 7px; background: rgba(255,255,255,.035); }
.export-confirm-button { display: flex; align-items: center; justify-content: center; gap: 7px; height: 40px; border: 0; border-radius: 7px; color: #061313; font-size: 13px; font-weight: 800; background: linear-gradient(135deg, #45f5e4, #26cfc0); cursor: pointer; }
.export-confirm-button { display: flex; align-items: center; justify-content: center; gap: 7px; width: 100%; height: 40px; border: 0; border-radius: 7px; color: #061313; font-size: 13px; font-weight: 800; background: linear-gradient(135deg, #45f5e4, #26cfc0); cursor: pointer; }
.export-confirm-button:disabled { color: #758086; background: rgba(255,255,255,.07); cursor: not-allowed; }
.editor-grid {
@@ -1574,6 +1575,7 @@ button:disabled {
.auto-edit-status-card > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.auto-edit-status-card > div > span { color: #c1cdd2; font-size: 12px; }
.auto-edit-status-card p { margin: 0; }
.auto-edit-status-card progress { width: 100%; height: 5px; accent-color: #35ead9; }
.auto-edit-status-card .auto-edit-warning { color: #d8b96e; }
.auto-edit-availability { color: #82919b; font-size: 10px; }
.auto-edit-availability.is-available { color: #35ead9; }
@@ -5757,6 +5759,29 @@ button:disabled {
white-space: nowrap;
}
.export-progress-cancel {
width: 100%;
min-height: 38px;
margin-top: 16px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #dce5e9;
background: rgba(255, 255, 255, 0.06);
font-size: 13px;
font-weight: 700;
}
.export-progress-cancel:hover:not(:disabled) {
border-color: rgba(53, 234, 217, 0.45);
color: #fff;
background: rgba(53, 234, 217, 0.1);
}
.export-progress-cancel:disabled {
cursor: wait;
opacity: 0.55;
}
.remaster-progress-overlay {
position: fixed;
inset: 0;
@@ -5986,6 +6011,12 @@ button:disabled {
.topbar .project-file-popover { right: 10px; left: 10px; }
.file-menu-card,
.export-settings-popover { width: 100%; }
.topbar .export-settings-popover { overflow: hidden; }
.export-settings-card { gap: 11px; padding: 14px; }
.export-settings-footer { padding: 8px 14px 14px; }
.export-settings-card .export-setting-field { gap: 5px; font-size: 11px; }
.export-settings-card .export-setting-field select,
.export-settings-card .export-setting-field input { height: 34px; font-size: 11px; }
.file-menu-action { min-height: 60px; }
.editor-grid,
+37
View File
@@ -0,0 +1,37 @@
# Timeline compatibility libav.js build
This directory contains a lazy-loaded libav.js WebAssembly build used only when
native browser media handling fails or a Matroska file is imported.
- libav.js: 6.9.8.1
- upstream commit: `ff473905db2c496628a1fb9f0b5b3e7c234720c5`
- FFmpeg: 8.1
- Emscripten: 4.0.23
- target: `wasm.mjs`
- upstream source: https://github.com/Yahweasel/libav.js
- license: LGPL-2.1-or-later for the selected FFmpeg configuration; the
generated loader also includes the upstream libav.js ISC license header.
The custom libav.js configuration is:
```json
[
"avformat",
"avfcbridge",
"avcodec",
"demuxer-matroska",
"demuxer-ac3",
"parser-ac3",
"decoder-ac3",
"parser-aac",
"parser-h264",
"parser-hevc",
"parser-vp8",
"parser-vp9",
"parser-av1"
]
```
It intentionally decodes AC3 only. Browser-supported video remains on the
WebCodecs/native path, and unsupported video codecs continue to use the
FFmpeg.wasm normalization fallback.
File diff suppressed because one or more lines are too long
+206
View File
@@ -0,0 +1,206 @@
import LibAV from "@libav.js/variant-webcodecs";
import libavFactory from "../vendor/libav-timeline-compat/libav-6.9.8.1-timeline-compat.wasm.mjs";
import libavWasmUrl from "../vendor/libav-timeline-compat/libav-6.9.8.1-timeline-compat.wasm.wasm?url";
import { encodeLibavAudioFramesAsWav } from "../lib/libavAudio.js";
let runtimePromise = null;
const LIBAV_VARIANT = "timeline-compat";
const READ_CHUNK_BYTES = 4 * 1024 * 1024;
function getRuntime() {
if (!runtimePromise) {
runtimePromise = LibAV.LibAV({
factory: libavFactory,
wasmurl: libavWasmUrl,
variant: LIBAV_VARIANT,
noworker: true,
nothreads: true,
});
}
return runtimePromise;
}
function safeName(name) {
const cleaned = String(name || "input.mkv").replace(/[^a-zA-Z0-9._-]/g, "_");
return `probe-${crypto.randomUUID()}-${cleaned}`;
}
async function readCodecParameters(libav, stream) {
const codecpar = stream.codecpar;
const codec = await libav.avcodec_get_name(stream.codec_id);
const common = {
index: stream.index,
type:
stream.codec_type === libav.AVMEDIA_TYPE_VIDEO
? "video"
: stream.codec_type === libav.AVMEDIA_TYPE_AUDIO
? "audio"
: "other",
codec,
codecId: stream.codec_id,
duration: Number.isFinite(stream.duration) ? stream.duration : 0,
timeBase: [stream.time_base_num, stream.time_base_den],
};
if (common.type === "video") {
return {
...common,
width: await libav.AVCodecParameters_width(codecpar),
height: await libav.AVCodecParameters_height(codecpar),
};
}
if (common.type === "audio") {
return {
...common,
sampleRate: await libav.AVCodecParameters_sample_rate(codecpar),
channels: await libav.AVCodecParameters_ch_layout_nb_channels(codecpar),
};
}
return common;
}
async function readPacketSummary(libav, formatContext, streams) {
const packet = await libav.av_packet_alloc();
try {
const [, packetGroups] = await libav.ff_read_frame_multi(formatContext, packet, {
limit: 4 * 1024 * 1024,
unify: true,
});
const packets = packetGroups?.[0] || [];
const streamMap = new Map(streams.map((stream) => [stream.index, stream]));
const keyframes = {};
for (const item of packets) {
if (!(item.flags & libav.AV_PKT_FLAG_KEY)) continue;
const stream = streamMap.get(item.stream_index);
if (!stream) continue;
const pts = libav.i64tof64(item.pts || 0, item.ptshi || 0);
const time = (pts * stream.time_base_num) / stream.time_base_den;
if (!Number.isFinite(time) || time < 0) continue;
const values = keyframes[item.stream_index] || (keyframes[item.stream_index] = []);
if (values.length < 80) values.push(time);
}
return { packetCount: packets.length, keyframes };
} finally {
await libav.av_packet_free_js(packet).catch(() => {});
}
}
async function probe(file, originalName) {
const libav = await getRuntime();
const filename = safeName(originalName);
let formatContext = 0;
await libav.mkreadaheadfile(filename, file);
try {
const opened = await libav.ff_init_demuxer_file(filename);
formatContext = opened[0];
const rawStreams = opened[1];
const streams = await Promise.all(
rawStreams.map((stream) => readCodecParameters(libav, stream)),
);
const packetSummary = await readPacketSummary(libav, formatContext, rawStreams);
const durationLow = await libav.AVFormatContext_duration(formatContext);
const durationHigh = await libav.AVFormatContext_durationhi(formatContext);
const containerDuration = libav.i64tof64(durationLow, durationHigh) / libav.AV_TIME_BASE;
const duration =
Number.isFinite(containerDuration) && containerDuration > 0
? containerDuration
: Math.max(0, ...streams.map((stream) => stream.duration || 0));
const extension =
String(originalName || "")
.split(".")
.pop()
?.toLowerCase() || "";
return {
backend: "libav",
container: extension === "mkv" || extension === "mka" ? "matroska" : extension,
duration,
streams,
...packetSummary,
runtime: { mode: libav.libavjsMode, target: "wasm", variant: LIBAV_VARIANT },
};
} finally {
if (formatContext) await libav.avformat_close_input_js(formatContext).catch(() => {});
await libav.unlinkreadaheadfile(filename).catch(() => {});
}
}
async function decodeAudio(file, originalName) {
const libav = await getRuntime();
const filename = safeName(originalName);
let formatContext = 0;
let decoder = null;
await libav.mkreadaheadfile(filename, file);
try {
const opened = await libav.ff_init_demuxer_file(filename);
formatContext = opened[0];
const streams = opened[1];
const streamIndex = streams.findIndex(
(stream) => stream.codec_type === libav.AVMEDIA_TYPE_AUDIO,
);
if (streamIndex < 0) throw new Error("No audio stream found");
const stream = streams[streamIndex];
const codec = await libav.avcodec_get_name(stream.codec_id);
if (codec !== "ac3") {
throw new Error(`The ${LIBAV_VARIANT} build cannot decode ${codec || "this audio codec"}`);
}
await Promise.all(
streams.map((candidate, index) =>
index === streamIndex
? Promise.resolve()
: libav.AVStream_discard_s(candidate.ptr, libav.AVDISCARD_ALL),
),
);
decoder = await libav.ff_init_decoder(stream.codec_id, stream.codecpar);
const [, context, packet, frame] = decoder;
const frames = [];
while (true) {
const [result, packetGroups] = await libav.ff_read_frame_multi(formatContext, packet, {
limit: READ_CHUNK_BYTES,
});
const packets = packetGroups[streamIndex] || [];
if (packets.length) {
frames.push(...(await libav.ff_decode_multi(context, packet, frame, packets, false)));
}
if (result === libav.AVERROR_EOF) break;
if (result !== 0 && result !== -libav.EAGAIN) {
throw new Error(`libav.js demux failed: ${await libav.ff_error(result)}`);
}
}
frames.push(...(await libav.ff_decode_multi(context, packet, frame, [], true)));
return { backend: "libav", codec, ...encodeLibavAudioFramesAsWav(frames) };
} finally {
if (decoder) await libav.ff_free_decoder(decoder[1], decoder[2], decoder[3]).catch(() => {});
if (formatContext) await libav.avformat_close_input_js(formatContext).catch(() => {});
await libav.unlinkreadaheadfile(filename).catch(() => {});
}
}
self.onmessage = async (event) => {
if (!["probe", "probe-and-decode-audio", "decode-audio"].includes(event.data?.type)) return;
try {
const compatibility =
event.data.type === "decode-audio" ? null : await probe(event.data.file, event.data.name);
let decodedAudio = null;
let audioDecodeError = "";
if (event.data.type !== "probe") {
try {
decodedAudio = await decodeAudio(event.data.file, event.data.name);
} catch (error) {
if (event.data.type === "decode-audio") throw error;
audioDecodeError = error instanceof Error ? error.message : String(error);
}
}
const result =
event.data.type === "probe"
? compatibility
: event.data.type === "decode-audio"
? decodedAudio
: { ...compatibility, decodedAudio, audioDecodeError };
const transfers = decodedAudio?.buffer ? [decodedAudio.buffer] : [];
self.postMessage({ type: "result", result }, transfers);
} catch (error) {
self.postMessage({
type: "error",
message: error instanceof Error ? error.message : String(error),
});
}
};