feat(export): offer server transcode to MP4 when WebCodecs is missing
The MediaRecorder export fallback introduced in #797 always emits a WebM container (most browsers don't expose an MP4 muxer through MediaRecorder). WebM plays in Chrome / Firefox / VLC but not natively in QuickTime or Safari, so users on browsers without WebCodecs who picked the MP4 format end up with a file they can't open in those players. This adds an opt-in escape hatch in the export popover: - When `isWebCodecsExportSupported()` is false AND the user picked MP4, the popover surfaces a "Browser compatibility" section that explains the situation and offers a "Transcode to H.264 MP4 on the server" checkbox (defaulted to on, since the user did ask for MP4). - When the checkbox is on, the post-export flow wraps the produced WebM buffer in a `File` and runs it through `convertVideoToH264`, which already routes to the existing `/api/convert-video` ffmpeg endpoint. The downloaded file is a real H.264 MP4. - If the user opts out, the popover keeps the WebM extension and mime type so the saved file matches its real container. - If the server transcode fails, the export is preserved as WebM and the user is notified via a toast. A small "Transcoding to MP4 on server…" section is shown in the popover between the export finishing and the download starting, since that step can take a noticeable amount of time on large clips. The WebCodecs-capable code path is unchanged: the warning section is only rendered when `!isWebCodecsExportSupported() && format === "mp4"`.
This commit is contained in:
parent
b1b3b1c9d1
commit
6f7dacb4d4
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
|
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -34,6 +34,9 @@ import {
|
||||||
} from "@/components/section";
|
} from "@/components/section";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { DEFAULT_EXPORT_OPTIONS } from "@/export/defaults";
|
import { DEFAULT_EXPORT_OPTIONS } from "@/export/defaults";
|
||||||
|
import { isWebCodecsExportSupported } from "@/services/renderer/media-recorder-support";
|
||||||
|
import { convertVideoToH264 } from "@/media/ffmpeg-convert";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
function isExportFormat(value: string): value is ExportFormat {
|
function isExportFormat(value: string): value is ExportFormat {
|
||||||
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
|
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
|
||||||
|
|
@ -111,6 +114,20 @@ function ExportPopover({
|
||||||
DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
|
DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// When the browser lacks WebCodecs encoders, the SceneExporter falls back to
|
||||||
|
// a MediaRecorder-based path that always produces WebM (VP8/VP9 + Opus).
|
||||||
|
// QuickTime and Safari can't play WebM natively, so we offer a one-click
|
||||||
|
// server-side transcode to H.264 MP4 when the user originally asked for MP4.
|
||||||
|
const webCodecsExportSupported = useMemo(
|
||||||
|
() => isWebCodecsExportSupported(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const willFallbackToWebM = !webCodecsExportSupported;
|
||||||
|
const shouldOfferServerTranscode = willFallbackToWebM && format === "mp4";
|
||||||
|
const [transcodeToMp4OnServer, setTranscodeToMp4OnServer] =
|
||||||
|
useState<boolean>(true);
|
||||||
|
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!activeProject) return;
|
if (!activeProject) return;
|
||||||
|
|
||||||
|
|
@ -129,10 +146,39 @@ function ExportPopover({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.success && result.buffer) {
|
if (result.success && result.buffer) {
|
||||||
|
let outBuffer = result.buffer;
|
||||||
|
let outFormat: ExportFormat = format;
|
||||||
|
|
||||||
|
if (shouldOfferServerTranscode) {
|
||||||
|
if (transcodeToMp4OnServer) {
|
||||||
|
setIsTranscoding(true);
|
||||||
|
try {
|
||||||
|
const webmFile = new File([outBuffer], "export.webm", {
|
||||||
|
type: "video/webm",
|
||||||
|
});
|
||||||
|
const mp4File = await convertVideoToH264({ file: webmFile });
|
||||||
|
outBuffer = await mp4File.arrayBuffer();
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
toast.error("Server transcode failed", {
|
||||||
|
description: `${message}. Saving WebM instead.`,
|
||||||
|
});
|
||||||
|
outFormat = "webm";
|
||||||
|
} finally {
|
||||||
|
setIsTranscoding(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User opted out of server transcode: keep WebM extension/mime so
|
||||||
|
// the file matches its actual container.
|
||||||
|
outFormat = "webm";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
downloadBuffer({
|
downloadBuffer({
|
||||||
buffer: result.buffer,
|
buffer: outBuffer,
|
||||||
filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
|
filename: `${activeProject.metadata.name}${getExportFileExtension({ format: outFormat })}`,
|
||||||
mimeType: getExportMimeType({ format }),
|
mimeType: getExportMimeType({ format: outFormat }),
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.project.clearExportState();
|
editor.project.clearExportState();
|
||||||
|
|
@ -250,6 +296,39 @@ function ExportPopover({
|
||||||
</div>
|
</div>
|
||||||
</SectionContent>
|
</SectionContent>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{shouldOfferServerTranscode && (
|
||||||
|
<Section collapsible defaultOpen>
|
||||||
|
<SectionHeader>
|
||||||
|
<SectionTitle>Browser compatibility</SectionTitle>
|
||||||
|
</SectionHeader>
|
||||||
|
<SectionContent>
|
||||||
|
<p className="mb-2 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
This browser can't encode H.264 directly, so the
|
||||||
|
export will produce a WebM file. WebM plays in Chrome,
|
||||||
|
Firefox and VLC, but not natively in QuickTime or
|
||||||
|
Safari.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="transcode-mp4"
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={transcodeToMp4OnServer}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setTranscodeToMp4OnServer(!!checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="transcode-mp4"
|
||||||
|
className="text-xs leading-snug"
|
||||||
|
>
|
||||||
|
Transcode to H.264 MP4 on the server after export
|
||||||
|
(uploads the WebM and re-encodes via ffmpeg)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</SectionContent>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 pt-0">
|
<div className="p-3 pt-0">
|
||||||
|
|
@ -282,6 +361,18 @@ function ExportPopover({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isTranscoding && !isExporting && (
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Transcoding to MP4 on server…
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Uploading the WebM file and re-encoding via ffmpeg. This
|
||||||
|
runs once per export and is independent of preview playback.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue