feat(downloads): every ready file is phone-safe — uploads and clips too

A share link is opened on whatever the friend holds. Measured on an iPhone and an iPad: AV1,
Matroska, HEVC tagged hev1 and MP3 inside an .mp4 fail there; Chrome and Firefox add HEVC and
10-bit / 4:4:4 H.264. The old check judged the codec NAME only and ran on downloads only, so a
10-bit H.264 passed, an upload went out exactly as it came (an iPhone's HEVC .mov, a webm, an mkv)
and a fast trim kept its source's codecs.

One rule now, `downloads/playable.py`: video is H.264 8-bit 4:2:0 + AAC in .mp4, audio-only is
.mp3 or AAC .m4a; `plan` says what must change, `build_cmd` re-encodes only the failing stream (or
remuxes). The worker applies it to a download, an editor clip and — new — an upload: media that
fails the rule is queued (asset pending, job queued) and the worker converts it, keeping only the
converted file (owner's choice). Until then the original sits at rel_path, so Retry starts from it
and a delete removes it; an upload that lost its last holder mid-conversion is reaped by the GC.
Every libx264 run (edit, concat, conversion) now pins `-pix_fmt yuv420p` — without it a 10-bit
source came out High 10. The download cache signature moves to v5 so a cached 10-bit asset
re-derives. The upload toast says when the file is still converting (HU/EN).

Tests: the plan for every measured class; real ffmpeg turning a 10-bit Matroska + Opus and an
mp3-in-mp4 into files the rule accepts (red without the pixel-format pin); the queued upload's
delete, cancel-then-GC, failed conversion and real worker conversion (the cleanup tests red
without their rules); the toast (red without the branch). Live on localdev: 9 fixture uploads
converted, every served file probed H.264 High yuv420p + AAC, or mp3 / m4a.
This commit is contained in:
2026-10-05 03:16:27 +02:00
parent 2768ab5bdd
commit 5561d9a379
21 changed files with 712 additions and 266 deletions
+10 -3
View File
@@ -19,7 +19,14 @@ import { Input } from "./ui/Input";
// Local-file ingest. A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
// lands in the Library as an already-`done` job, so we invalidate + point the user there.
// lands in the Library as an already-`done` job — or, for media no phone could play as it is (an
// iPhone's HEVC, a webm, an mkv), as a `queued` job the worker converts first — so we invalidate,
// say which, and point the user there.
// Out here, not inline in the upload handler: a `?:` inside its `try` makes the React Compiler
// skip the whole dialog.
const uploadedKey = (status: string) =>
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
export default function UploadDialog({ onClose }: { onClose: () => void }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -73,7 +80,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
setPct(0);
const displayName = name.trim() || undefined;
try {
await api.uploadDownload(file, {
const job = await api.uploadDownload(file, {
displayName,
onProgress: setPct,
signal: ac.signal,
@@ -81,7 +88,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
invalidateDownloads(qc);
notify({
level: "success",
message: t("downloads.upload.added"),
message: t(uploadedKey(job.status)),
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
});
onClose();