Probe real encoder output, order codecs by it, and add a quality slider
Your machine's H.264/HEVC encoders report support and then emit no video samples at all, which no configure-time check can see. The optimizer now test-encodes three frames per codec (cached for 30 days), shows the result as diagnostics in the modal (AVC x / VP9 (hardware) / ...), feeds the working codecs to the worker so Auto tries them first, and tells you to switch to WebM when a container has no usable encoder. Video quality is now a 10-100 slider with a predicted bitrate and size for this clip (mirroring Mediabunny's mapping), which makes the trade-off visible instead of guessing from presets.
This commit is contained in:
@@ -8,25 +8,17 @@ import { cn } from "@/lib/cn";
|
|||||||
import { formatBytes } from "@/lib/format";
|
import { formatBytes } from "@/lib/format";
|
||||||
import type { MediaItem } from "@/lib/types";
|
import type { MediaItem } from "@/lib/types";
|
||||||
|
|
||||||
import { detectVideoCodecs } from "./detect";
|
import { estimateVideoBitrate, VIDEO_CODEC_CANDIDATES } from "./bitrate";
|
||||||
|
import { probeVideoEncoders, workingCodecs } from "./detect";
|
||||||
import { deleteStoredFile } from "./opfs";
|
import { deleteStoredFile } from "./opfs";
|
||||||
import {
|
import {
|
||||||
DEFAULT_OPTIONS,
|
DEFAULT_OPTIONS,
|
||||||
kindForExtension,
|
kindForExtension,
|
||||||
type OptimizeKind,
|
type OptimizeKind,
|
||||||
type OptimizeOptions,
|
type OptimizeOptions,
|
||||||
type VideoQualityLevel,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { useOptimizer } from "./useOptimizer";
|
import { useOptimizer } from "./useOptimizer";
|
||||||
|
|
||||||
const QUALITY_LEVELS: { value: VideoQualityLevel; label: string }[] = [
|
|
||||||
{ value: "very-high", label: "Visually lossless" },
|
|
||||||
{ value: "high", label: "High (recommended)" },
|
|
||||||
{ value: "medium", label: "Balanced" },
|
|
||||||
{ value: "low", label: "Small" },
|
|
||||||
{ value: "very-low", label: "Smallest" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DIMENSION_CHOICES: { value: number | null; label: string }[] = [
|
const DIMENSION_CHOICES: { value: number | null; label: string }[] = [
|
||||||
{ value: null, label: "Keep original" },
|
{ value: null, label: "Keep original" },
|
||||||
{ value: 2560, label: "Max 2560 px" },
|
{ value: 2560, label: "Max 2560 px" },
|
||||||
@@ -190,12 +182,20 @@ export function OptimizeModal({
|
|||||||
const [resultInfo, setResultInfo] = useState<MediaInfo | null>(null);
|
const [resultInfo, setResultInfo] = useState<MediaInfo | null>(null);
|
||||||
|
|
||||||
const codecQuery = useQuery({
|
const codecQuery = useQuery({
|
||||||
queryKey: ["optimize-codecs"],
|
queryKey: ["optimize-encoders"],
|
||||||
queryFn: detectVideoCodecs,
|
queryFn: () => probeVideoEncoders(),
|
||||||
enabled: kind === "video",
|
enabled: kind === "video",
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
const probes = codecQuery.data ?? [];
|
||||||
|
const container: "mp4" | "webm" =
|
||||||
|
options.container === "keep"
|
||||||
|
? item.extension === "webm"
|
||||||
|
? "webm"
|
||||||
|
: "mp4"
|
||||||
|
: options.container;
|
||||||
|
const usableCodecs = workingCodecs(probes, container);
|
||||||
|
|
||||||
const applyMutation = useMutation({
|
const applyMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -240,15 +240,45 @@ export function OptimizeModal({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const isVideo = kind === "video";
|
const isVideo = kind === "video";
|
||||||
const hardwareAvailable =
|
|
||||||
codecQuery.data?.some((entry) => entry.supported && entry.hardware) ??
|
|
||||||
false;
|
|
||||||
const truncated =
|
const truncated =
|
||||||
isVideo &&
|
isVideo &&
|
||||||
originalInfo?.duration != null &&
|
originalInfo?.duration != null &&
|
||||||
resultInfo?.duration != null &&
|
resultInfo?.duration != null &&
|
||||||
resultInfo.duration + 1 < originalInfo.duration;
|
resultInfo.duration + 1 < originalInfo.duration;
|
||||||
|
|
||||||
|
// Predicted output size, mirroring Mediabunny's quality -> bitrate mapping.
|
||||||
|
const estimateCodec =
|
||||||
|
options.codec !== "auto"
|
||||||
|
? options.codec
|
||||||
|
: (usableCodecs[0] ?? VIDEO_CODEC_CANDIDATES[container][0]);
|
||||||
|
const estimate =
|
||||||
|
isVideo &&
|
||||||
|
originalInfo?.duration &&
|
||||||
|
originalInfo.width &&
|
||||||
|
originalInfo.height
|
||||||
|
? (() => {
|
||||||
|
const sourceHeight = originalInfo.height as number;
|
||||||
|
const sourceWidth = originalInfo.width as number;
|
||||||
|
const targetHeight =
|
||||||
|
options.height && options.height < sourceHeight
|
||||||
|
? options.height
|
||||||
|
: sourceHeight;
|
||||||
|
const targetWidth = Math.round(
|
||||||
|
sourceWidth * (targetHeight / sourceHeight),
|
||||||
|
);
|
||||||
|
const bitrate = estimateVideoBitrate(
|
||||||
|
estimateCodec,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
options.quality,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
bitrate,
|
||||||
|
size: ((bitrate + 128_000) / 8) * (originalInfo.duration as number),
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
function process() {
|
function process() {
|
||||||
setApplied(false);
|
setApplied(false);
|
||||||
setApplyError(null);
|
setApplyError(null);
|
||||||
@@ -259,6 +289,7 @@ export function OptimizeModal({
|
|||||||
filename: item.filename,
|
filename: item.filename,
|
||||||
kind,
|
kind,
|
||||||
options,
|
options,
|
||||||
|
preferredCodecs: usableCodecs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,24 +562,28 @@ export function OptimizeModal({
|
|||||||
|
|
||||||
{kind === "video" ? (
|
{kind === "video" ? (
|
||||||
<>
|
<>
|
||||||
<Field label="Quality">
|
<Field label={`Quality — ${options.quality}`}>
|
||||||
<select
|
<input
|
||||||
className={inputClass}
|
type="range"
|
||||||
value={options.qualityLevel}
|
min={10}
|
||||||
|
max={100}
|
||||||
|
value={options.quality}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setOptions((current) => ({
|
setOptions((current) => ({
|
||||||
...current,
|
...current,
|
||||||
qualityLevel: event.target.value as VideoQualityLevel,
|
quality: Number(event.target.value),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
className="w-full accent-ctp-mauve"
|
||||||
{QUALITY_LEVELS.map((level) => (
|
/>
|
||||||
<option key={level.value} value={level.value}>
|
|
||||||
{level.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
{estimate ? (
|
||||||
|
<p className="-mt-2 text-[11px] text-ctp-overlay0">
|
||||||
|
≈ {(estimate.bitrate / 1_000_000).toFixed(1)} Mbps · about{" "}
|
||||||
|
{formatBytes(estimate.size)} for{" "}
|
||||||
|
{originalInfo?.duration?.toFixed(1)} s
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<Field label="Resolution">
|
<Field label="Resolution">
|
||||||
<select
|
<select
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
@@ -584,14 +619,15 @@ export function OptimizeModal({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="auto">Auto (best available)</option>
|
<option value="auto">Auto (best available)</option>
|
||||||
{(codecQuery.data ?? [])
|
{usableCodecs.map((codec) => {
|
||||||
.filter((entry) => entry.supported)
|
const probe = probes.find((entry) => entry.codec === codec);
|
||||||
.map((entry) => (
|
return (
|
||||||
<option key={entry.codec} value={entry.codec}>
|
<option key={codec} value={codec}>
|
||||||
{entry.codec.toUpperCase()}
|
{codec.toUpperCase()}
|
||||||
{entry.hardware ? " (hardware)" : ""}
|
{probe?.hardware ? " (hardware)" : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Container">
|
<Field label="Container">
|
||||||
@@ -625,13 +661,34 @@ export function OptimizeModal({
|
|||||||
/>
|
/>
|
||||||
Prefer hardware encoding
|
Prefer hardware encoding
|
||||||
</label>
|
</label>
|
||||||
|
{codecQuery.isPending ? (
|
||||||
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
|
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
|
||||||
{codecQuery.isPending
|
Test-encoding a few frames to see which encoders really
|
||||||
? "Checking the encoders this browser offers…"
|
work here…
|
||||||
: hardwareAvailable
|
|
||||||
? "Hardware-accelerated encoders are available on this machine."
|
|
||||||
: "No hardware encoder detected — encoding will use the CPU."}
|
|
||||||
</p>
|
</p>
|
||||||
|
) : usableCodecs.length === 0 ? (
|
||||||
|
<p className="rounded-md border border-ctp-red/40 bg-ctp-red/10 px-2 py-1.5 text-[11px] leading-relaxed text-ctp-red">
|
||||||
|
This browser cannot produce {container.toUpperCase()} video
|
||||||
|
samples — switch the container to{" "}
|
||||||
|
{container === "mp4" ? "WebM" : "MP4"}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
|
||||||
|
Encoders:{" "}
|
||||||
|
{probes
|
||||||
|
.map(
|
||||||
|
(probe) =>
|
||||||
|
`${probe.codec.toUpperCase()}${
|
||||||
|
probe.supported && probe.works
|
||||||
|
? probe.hardware
|
||||||
|
? " (hardware)"
|
||||||
|
: " ✓"
|
||||||
|
: " ✗"
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
.join(" · ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { VideoCodec } from "mediabunny";
|
||||||
|
|
||||||
|
export const VIDEO_CODEC_CANDIDATES: Record<"mp4" | "webm", VideoCodec[]> = {
|
||||||
|
mp4: ["avc", "hevc"],
|
||||||
|
webm: ["vp9", "av1"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Codec efficiency factors, mirroring Mediabunny's bitrate mapping. */
|
||||||
|
const CODEC_EFFICIENCY: Record<string, number> = {
|
||||||
|
avc: 1.0,
|
||||||
|
hevc: 0.6,
|
||||||
|
vp9: 0.6,
|
||||||
|
av1: 0.4,
|
||||||
|
vp8: 1.2,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors Mediabunny's quality -> bitrate mapping so the UI can predict the
|
||||||
|
* size of the result before encoding.
|
||||||
|
*/
|
||||||
|
export function estimateVideoBitrate(
|
||||||
|
codec: VideoCodec,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
qualityPercent: number,
|
||||||
|
): number {
|
||||||
|
const quality = Math.min(Math.max(qualityPercent / 100, 0.05), 1);
|
||||||
|
const factor = 0.3 * Math.exp(2.5538 * quality);
|
||||||
|
const pixels = Math.max(width * height, 1);
|
||||||
|
const scale = Math.pow(pixels / (1920 * 1080), 0.95);
|
||||||
|
const base = 3_000_000 * scale * (CODEC_EFFICIENCY[codec] ?? 1);
|
||||||
|
return Math.ceil((base * factor) / 1000) * 1000;
|
||||||
|
}
|
||||||
@@ -1,32 +1,139 @@
|
|||||||
import { canEncodeVideo, getEncodableVideoCodecs, type VideoCodec } from "mediabunny";
|
import { canEncodeVideo, type VideoCodec } from "mediabunny";
|
||||||
|
|
||||||
export interface VideoCodecSupport {
|
import { VIDEO_CODEC_CANDIDATES } from "./bitrate";
|
||||||
|
|
||||||
|
export interface VideoEncoderProbe {
|
||||||
codec: VideoCodec;
|
codec: VideoCodec;
|
||||||
supported: boolean;
|
supported: boolean;
|
||||||
|
/** A hardware encoder responds for 1080p (a hint, never a guarantee). */
|
||||||
hardware: boolean;
|
hardware: boolean;
|
||||||
|
/** A tiny real encode produced samples — the only reliable check. */
|
||||||
|
works: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIDEO_CODEC_CANDIDATES: VideoCodec[] = ["avc", "hevc", "vp9", "av1"];
|
const CACHE_KEY = "j621.optimize.encoder-probe";
|
||||||
|
const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // re-probe monthly
|
||||||
|
const CODEC_STRINGS: Record<string, string> = {
|
||||||
|
avc: "avc1.42E01E",
|
||||||
|
hevc: "hvc1.1.6.L93.B0",
|
||||||
|
vp9: "vp09.00.10.08",
|
||||||
|
av1: "av01.0.04M.08",
|
||||||
|
};
|
||||||
|
|
||||||
/** Which codecs this browser can encode, and whether hardware is preferred. */
|
const ALL_CODECS: VideoCodec[] = ["avc", "hevc", "vp9", "av1"];
|
||||||
export async function detectVideoCodecs(): Promise<VideoCodecSupport[]> {
|
|
||||||
const supported = new Set(await getEncodableVideoCodecs());
|
interface CacheEntry {
|
||||||
const results: VideoCodecSupport[] = [];
|
at: number;
|
||||||
for (const codec of VIDEO_CODEC_CANDIDATES) {
|
probes: VideoEncoderProbe[];
|
||||||
const encodable = supported.has(codec);
|
}
|
||||||
let hardware = false;
|
|
||||||
if (encodable) {
|
function readCache(): VideoEncoderProbe[] | null {
|
||||||
try {
|
try {
|
||||||
|
const raw = window.localStorage.getItem(CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const entry = JSON.parse(raw) as CacheEntry;
|
||||||
|
if (!Array.isArray(entry.probes)) return null;
|
||||||
|
if (Date.now() - entry.at > CACHE_TTL_MS) return null;
|
||||||
|
return entry.probes;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(probes: VideoEncoderProbe[]): void {
|
||||||
|
try {
|
||||||
|
const entry: CacheEntry = { at: Date.now(), probes };
|
||||||
|
window.localStorage.setItem(CACHE_KEY, JSON.stringify(entry));
|
||||||
|
} catch {
|
||||||
|
// Storage disabled: probing just runs again next time.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encoders that this browser can actually produce video samples with. */
|
||||||
|
export async function probeVideoEncoders(
|
||||||
|
force = false,
|
||||||
|
): Promise<VideoEncoderProbe[]> {
|
||||||
|
if (!force) {
|
||||||
|
const cached = readCache();
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
const probes: VideoEncoderProbe[] = [];
|
||||||
|
for (const codec of ALL_CODECS) {
|
||||||
|
const supported = await canEncodeVideo(codec, {
|
||||||
|
width: 640,
|
||||||
|
height: 360,
|
||||||
|
}).catch(() => false);
|
||||||
|
let hardware = false;
|
||||||
|
let works = false;
|
||||||
|
if (supported) {
|
||||||
hardware = await canEncodeVideo(codec, {
|
hardware = await canEncodeVideo(codec, {
|
||||||
width: 1920,
|
width: 1920,
|
||||||
height: 1080,
|
height: 1080,
|
||||||
hardwareAcceleration: "prefer-hardware",
|
hardwareAcceleration: "prefer-hardware",
|
||||||
});
|
}).catch(() => false);
|
||||||
} catch {
|
works = await testEncode(codec);
|
||||||
hardware = false;
|
|
||||||
}
|
}
|
||||||
|
probes.push({ codec, supported, hardware, works });
|
||||||
}
|
}
|
||||||
results.push({ codec, supported: encodable, hardware });
|
writeCache(probes);
|
||||||
|
return probes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Some browsers claim support and then emit nothing; do a real micro-encode. */
|
||||||
|
async function testEncode(codec: VideoCodec): Promise<boolean> {
|
||||||
|
const codecString = CODEC_STRINGS[codec];
|
||||||
|
if (typeof VideoEncoder === "undefined" || !codecString) return false;
|
||||||
|
let chunks = 0;
|
||||||
|
let failed = false;
|
||||||
|
const encoder = new VideoEncoder({
|
||||||
|
output: () => {
|
||||||
|
chunks += 1;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
failed = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const config: VideoEncoderConfig = {
|
||||||
|
codec: codecString,
|
||||||
|
width: 640,
|
||||||
|
height: 360,
|
||||||
|
bitrate: 600_000,
|
||||||
|
framerate: 30,
|
||||||
|
};
|
||||||
|
const support = await VideoEncoder.isConfigSupported(config);
|
||||||
|
if (!support.supported) return false;
|
||||||
|
encoder.configure(config);
|
||||||
|
const canvas = new OffscreenCanvas(640, 360);
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return false;
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
context.fillStyle = index % 2 === 0 ? "#224466" : "#cc8844";
|
||||||
|
context.fillRect(0, 0, 640, 360);
|
||||||
|
const frame = new VideoFrame(canvas, { timestamp: index * 33_333 });
|
||||||
|
encoder.encode(frame, { keyFrame: index === 0 });
|
||||||
|
frame.close();
|
||||||
}
|
}
|
||||||
return results;
|
await encoder.flush();
|
||||||
|
return !failed && chunks > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
encoder.close();
|
||||||
|
} catch {
|
||||||
|
// Already closed by the error path.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workingCodecs(
|
||||||
|
probes: VideoEncoderProbe[],
|
||||||
|
container: "mp4" | "webm",
|
||||||
|
): VideoCodec[] {
|
||||||
|
const candidates = VIDEO_CODEC_CANDIDATES[container];
|
||||||
|
return probes
|
||||||
|
.filter((probe) => probe.supported && probe.works)
|
||||||
|
.map((probe) => probe.codec)
|
||||||
|
.filter((codec) => candidates.includes(codec));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ scope.onmessage = async (event: MessageEvent<OptimizeRequest>) => {
|
|||||||
if (kind === "video") {
|
if (kind === "video") {
|
||||||
// Mediabunny streams the source with range requests — never download a
|
// Mediabunny streams the source with range requests — never download a
|
||||||
// whole video into memory.
|
// whole video into memory.
|
||||||
result = await optimizeVideo(url, filename, options, report);
|
result = await optimizeVideo(
|
||||||
|
url,
|
||||||
|
filename,
|
||||||
|
options,
|
||||||
|
report,
|
||||||
|
event.data.preferredCodecs ?? [],
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
report(0.01, "Downloading the original…");
|
report(0.01, "Downloading the original…");
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
/** Shared types for the client-side optimization pipeline. */
|
/** Shared types for the client-side optimization pipeline. */
|
||||||
|
|
||||||
|
import type { VideoCodec } from "mediabunny";
|
||||||
|
|
||||||
export type OptimizeKind = "image" | "animation" | "video";
|
export type OptimizeKind = "image" | "animation" | "video";
|
||||||
|
|
||||||
export type VideoQualityLevel =
|
|
||||||
| "very-high"
|
|
||||||
| "high"
|
|
||||||
| "medium"
|
|
||||||
| "low"
|
|
||||||
| "very-low";
|
|
||||||
|
|
||||||
export interface OptimizeOptions {
|
export interface OptimizeOptions {
|
||||||
// Images (jpg / png / webp)
|
// Images (jpg / png / webp) and video quality (1-100)
|
||||||
quality: number; // 1-100 for lossy formats
|
quality: number;
|
||||||
pngLevel: number; // 0-6, higher = smaller/slower
|
pngLevel: number; // 0-6, higher = smaller/slower
|
||||||
maxDimension: number | null; // longest edge in px, null keeps the size
|
maxDimension: number | null; // longest edge in px, null keeps the size
|
||||||
// Animations (gif / apng)
|
// Animations (gif / apng)
|
||||||
@@ -19,8 +14,7 @@ export interface OptimizeOptions {
|
|||||||
fps: number | null; // frame-rate cap
|
fps: number | null; // frame-rate cap
|
||||||
colors: number; // palette size
|
colors: number; // palette size
|
||||||
// Video (mp4 / webm)
|
// Video (mp4 / webm)
|
||||||
codec: "auto" | "avc" | "hevc" | "vp9" | "av1";
|
codec: "auto" | VideoCodec;
|
||||||
qualityLevel: VideoQualityLevel;
|
|
||||||
container: "keep" | "mp4" | "webm";
|
container: "keep" | "mp4" | "webm";
|
||||||
height: number | null; // target height, null keeps the size
|
height: number | null; // target height, null keeps the size
|
||||||
preferHardware: boolean;
|
preferHardware: boolean;
|
||||||
@@ -34,7 +28,6 @@ export const DEFAULT_OPTIONS: OptimizeOptions = {
|
|||||||
fps: null,
|
fps: null,
|
||||||
colors: 192,
|
colors: 192,
|
||||||
codec: "auto",
|
codec: "auto",
|
||||||
qualityLevel: "high",
|
|
||||||
container: "keep",
|
container: "keep",
|
||||||
height: null,
|
height: null,
|
||||||
preferHardware: true,
|
preferHardware: true,
|
||||||
@@ -45,6 +38,8 @@ export interface OptimizeRequest {
|
|||||||
filename: string;
|
filename: string;
|
||||||
kind: OptimizeKind;
|
kind: OptimizeKind;
|
||||||
options: OptimizeOptions;
|
options: OptimizeOptions;
|
||||||
|
/** Codecs that actually produced samples on this machine, best first. */
|
||||||
|
preferredCodecs?: VideoCodec[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptimizeResult {
|
export interface OptimizeResult {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "mediabunny";
|
} from "mediabunny";
|
||||||
|
|
||||||
import { assertPlayable } from "./mp4";
|
import { assertPlayable } from "./mp4";
|
||||||
|
import { VIDEO_CODEC_CANDIDATES } from "./bitrate";
|
||||||
import { createStreamOutput, type StreamOutput } from "./stream";
|
import { createStreamOutput, type StreamOutput } from "./stream";
|
||||||
import {
|
import {
|
||||||
extensionOf,
|
extensionOf,
|
||||||
@@ -19,11 +20,6 @@ import {
|
|||||||
type ProgressReporter,
|
type ProgressReporter,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const CODEC_CANDIDATES: Record<"mp4" | "webm", VideoCodec[]> = {
|
|
||||||
mp4: ["avc", "hevc"],
|
|
||||||
webm: ["vp9", "av1"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const PREFERENCE_LABEL: Record<string, string> = {
|
const PREFERENCE_LABEL: Record<string, string> = {
|
||||||
"prefer-hardware": "hardware",
|
"prefer-hardware": "hardware",
|
||||||
"prefer-software": "software",
|
"prefer-software": "software",
|
||||||
@@ -45,6 +41,7 @@ export async function optimizeVideo(
|
|||||||
filename: string,
|
filename: string,
|
||||||
options: OptimizeOptions,
|
options: OptimizeOptions,
|
||||||
report: ProgressReporter,
|
report: ProgressReporter,
|
||||||
|
preferredCodecs: VideoCodec[] = [],
|
||||||
): Promise<OptimizeResult> {
|
): Promise<OptimizeResult> {
|
||||||
if (typeof VideoEncoder === "undefined") {
|
if (typeof VideoEncoder === "undefined") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -89,11 +86,21 @@ export async function optimizeVideo(
|
|||||||
// per-frame quantizer that qualitative presets would otherwise use, which
|
// per-frame quantizer that qualitative presets would otherwise use, which
|
||||||
// made results inconsistent (and sometimes tiny).
|
// made results inconsistent (and sometimes tiny).
|
||||||
const quality = new Quality({
|
const quality = new Quality({
|
||||||
quality: options.qualityLevel,
|
quality: Math.min(Math.max(options.quality / 100, 0.05), 1),
|
||||||
preferBitrate: true,
|
preferBitrate: true,
|
||||||
});
|
});
|
||||||
|
const containerCodecs = VIDEO_CODEC_CANDIDATES[container];
|
||||||
const codecs: VideoCodec[] =
|
const codecs: VideoCodec[] =
|
||||||
options.codec === "auto" ? CODEC_CANDIDATES[container] : [options.codec];
|
options.codec === "auto"
|
||||||
|
? [
|
||||||
|
...new Set([
|
||||||
|
...preferredCodecs.filter((codec) =>
|
||||||
|
containerCodecs.includes(codec),
|
||||||
|
),
|
||||||
|
...containerCodecs,
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: [options.codec];
|
||||||
const preferences: Attempt["preference"][] = options.preferHardware
|
const preferences: Attempt["preference"][] = options.preferHardware
|
||||||
? ["prefer-hardware", "prefer-software"]
|
? ["prefer-hardware", "prefer-software"]
|
||||||
: ["prefer-software"];
|
: ["prefer-software"];
|
||||||
@@ -178,9 +185,12 @@ export async function optimizeVideo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hint =
|
||||||
|
container === "mp4"
|
||||||
|
? "This browser's H.264/HEVC encoders produced no video samples — switch the Container to WebM (VP9) instead."
|
||||||
|
: "Try another codec or a lower resolution.";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Video encoding failed on every configuration (${issues.join("; ")}). ` +
|
`Video encoding failed on every configuration (${issues.join("; ")}). ${hint}`,
|
||||||
"Try WebM output or a lower resolution.",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user