fix(uploads): graceful fallback for browser-unplayable video (HEVC .mov)

macOS screen recordings are HEVC inside a .mov container, which Chromium,
Firefox and stock Electron can't decode. The file uploaded fine and a
server-side ffmpeg poster was generated, but inline <video> playback failed
silently — stuck at 0:00 with no error, since AttachmentRenderer had no error
handling. Root cause: the system had no concept of web-playability.

Server detects, client degrades:
- mediaPlayable.ts: classifyVideoPlayable(mimetype, codec) — tri-state
  (false = known-undecodable e.g. HEVC/ProRes, true = web codec in web
  container, null = unknown/optimistic). Never widens `false` beyond codecs
  that fail everywhere, so ffmpeg-less instances keep prior behaviour.
- probeMediaMeta now captures the video codec_name; the upload finish hook
  stores the verdict in the new attachments.playable column (migration 0007).
- Flag propagated through every serializer: space messages, DMs, WS, and
  federation relay (outbound + inbound) — federation-compatible.
- VideoAttachment component: playable===false renders a download card (poster
  + "Can't play here — download" + name/duration/size) with no dead-player
  flash; otherwise plays inline with an onError fallback to the same card.

Specs updated: uploads.md, database.md, federation.md.
This commit is contained in:
Jannis Braun
2026-06-30 17:38:11 +02:00
parent e84daf57aa
commit 209aef7e9d
18 changed files with 3999 additions and 30 deletions
+1
View File
@@ -122,6 +122,7 @@ export function buildDmMessageWithUser(
width: a.width ?? null,
height: a.height ?? null,
duration: a.duration ?? null,
playable: a.playable ?? null,
federationStatus: a.federationStatus ?? null,
federationMeta: a.federationMeta ?? null,
createdAt: a.createdAt,
+3
View File
@@ -2911,6 +2911,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
width: a.width ?? undefined,
height: a.height ?? undefined,
duration: a.duration ?? undefined,
playable: a.playable ?? null,
thumbnailFilename: a.thumbnailFilename ?? undefined,
sourceUrl: `${localOrigin}/api/uploads/${a.filename}`,
}));
@@ -3656,6 +3657,7 @@ async function processCreateEvent(
width: attachment.width ?? null,
height: attachment.height ?? null,
duration: attachment.duration ?? null,
playable: attachment.playable ?? null,
thumbnailFilename: null, // Don't copy source thumbnail — it doesn't exist locally
sourceUrl: attachment.sourceUrl,
createdAt: now,
@@ -3830,6 +3832,7 @@ function processUpdateEvent(
width: a.width,
height: a.height,
duration: a.duration,
playable: a.playable ?? null,
createdAt: a.createdAt,
}));
+9 -1
View File
@@ -20,6 +20,7 @@ import {
generateVideoThumbnail,
thumbFilename,
} from '../utils/thumbnail.js';
import { classifyVideoPlayable } from '../utils/mediaPlayable.js';
import { eq } from 'drizzle-orm';
import type { Attachment } from '@backspace/shared';
import fs from 'node:fs';
@@ -251,6 +252,10 @@ export async function filesRoutes(app: FastifyInstance): Promise<void> {
let width: number | null = null;
let height: number | null = null;
let duration: number | null = null;
// Tri-state web-playability for video (null = unknown/optimistic). Lets
// the client render a download fallback for codecs the browser can't
// decode (e.g. HEVC .mov) instead of a dead <video> stuck at 0:00.
let playable: boolean | null = null;
try {
if (isResizableImage(mimetype)) {
stagedThumbName = await generateThumbnail(srcPath, mimetype, config.uploadDir);
@@ -271,6 +276,7 @@ export async function filesRoutes(app: FastifyInstance): Promise<void> {
duration = mediaMeta.duration ?? null;
if (width === null && mediaMeta.width) width = mediaMeta.width;
if (height === null && mediaMeta.height) height = mediaMeta.height;
playable = classifyVideoPlayable(mimetype, mediaMeta.codec);
}
} else if (mimetype.startsWith('audio/')) {
const mediaMeta = await probeMediaMeta(srcPath, mimetype);
@@ -286,7 +292,7 @@ export async function filesRoutes(app: FastifyInstance): Promise<void> {
} catch { /* ignore */ }
stagedThumbName = null;
}
width = null; height = null; duration = null;
width = null; height = null; duration = null; playable = null;
}
// ── Commit point: rename .tus/<id> → uploads/<snowflakeId><ext> ──────
@@ -337,6 +343,7 @@ export async function filesRoutes(app: FastifyInstance): Promise<void> {
width,
height,
duration,
playable,
createdAt: now,
}).run();
} catch (err) {
@@ -362,6 +369,7 @@ export async function filesRoutes(app: FastifyInstance): Promise<void> {
width: width ?? undefined,
height: height ?? undefined,
duration: duration ?? undefined,
playable,
createdAt: now,
};
+2
View File
@@ -118,6 +118,7 @@ export function fetchReplyToMessages(messages: (typeof schema.messages.$inferSel
width: a.width ?? null,
height: a.height ?? null,
duration: a.duration ?? null,
playable: a.playable ?? null,
createdAt: a.createdAt,
})),
embeds: [],
@@ -156,6 +157,7 @@ export function buildMessageWithUser(
width: a.width ?? null,
height: a.height ?? null,
duration: a.duration ?? null,
playable: a.playable ?? null,
federationStatus: a.federationStatus ?? null,
federationMeta: a.federationMeta ?? null,
createdAt: a.createdAt,