Release v0.7.0: expand media compatibility and export
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
{
|
||||
|
||||
Generated
+9
-2
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 d’export", exportPipeline: "Stratégie d’export", exportPipelineAuto: "Automatique (recommandé)",
|
||||
exportPipelineAutoHint: "Utilise d’abord le rendu image par image, puis passe à l’export 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 d’encodage", 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 l’export", exportCanceling: "Annulation…", exportCanceled: "Export annulé",
|
||||
exportRange: "Plage d’export", 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 d’images 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 l’export", exportDeterministicFailed: "Ces réglages ne permettent pas l’export 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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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` : "";
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Binary file not shown.
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user