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:
@@ -105,6 +105,7 @@ PK: (spaceId, userId)
|
||||
| width | integer | | Image/video pixel width |
|
||||
| height | integer | | Image/video pixel height |
|
||||
| duration | real | | Audio/video seconds |
|
||||
| playable | integer (boolean) | NULL | Video web-playability tri-state: 1 = browser-decodable, 0 = known-undecodable (e.g. HEVC .mov), NULL = unknown/non-video. See uploads.md §3. |
|
||||
| sourceUrl | text | | Remote URL (federation) |
|
||||
| federationStatus | text | | local/remote/remote_partial |
|
||||
| federationMeta | text | | JSON rejection info |
|
||||
|
||||
@@ -941,12 +941,13 @@ When `queueDmRelay` constructs the relay payload, each attachment gets a `source
|
||||
```
|
||||
sourceUrl: `${getOurOrigin()}/api/uploads/${attachment.filename}`
|
||||
```
|
||||
The payload also carries `playable` (video web-playability, computed by the origin from the probed codec — see uploads.md §3) so the receiving instance need not re-probe the file's codec.
|
||||
|
||||
### Inbound (receiving instance -- `processCreateEvent`)
|
||||
|
||||
1. For each attachment in `event.message.attachments`:
|
||||
- SSRF check: `isUrlFromPeer(sourceUrl, peerOrigin)` -- hostname of sourceUrl must match peer origin hostname
|
||||
- Create `attachments` row with `filename = sourceUrl` (remote URL as interim filename)
|
||||
- Create `attachments` row with `filename = sourceUrl` (remote URL as interim filename), carrying through `playable` from the relay payload
|
||||
- Queue `federation_file_queue` entry with `status = 'pending'`, `expiresAt = now + 30 days`
|
||||
2. Initial WebSocket broadcast uses sourceUrl directly (frontend's `AttachmentRenderer` detects `http` prefix)
|
||||
|
||||
|
||||
+14
-3
@@ -3,7 +3,8 @@
|
||||
Source files:
|
||||
- `packages/server/src/routes/uploads.ts` -- File serving (cache, security, Range)
|
||||
- `packages/server/src/routes/files.ts` -- tus protocol endpoints (`/api/files/*`), PRE_CREATE / PRE_PATCH / POST_FINISH hooks, janitor helpers
|
||||
- `packages/server/src/utils/thumbnail.ts` -- Image thumbnail generation (sharp), video thumbnail extraction (ffmpeg), image dimension probing, profile image resizing, media metadata extraction
|
||||
- `packages/server/src/utils/thumbnail.ts` -- Image thumbnail generation (sharp), video thumbnail extraction (ffmpeg), image dimension probing, profile image resizing, media metadata extraction (incl. video codec)
|
||||
- `packages/server/src/utils/mediaPlayable.ts` -- `classifyVideoPlayable(mimetype, codec)` web-playability classifier (drives `attachments.playable`)
|
||||
- `packages/server/src/utils/fileCleanup.ts` -- File deletion helpers (disk + thumbnail + attachment record cleanup)
|
||||
- `packages/server/src/utils/storageJanitor.ts` -- Storage stats, orphan detection, cleanup routines (orphaned files, unlinked attachments, dangling references, old media), federation GC, soft-deleted DM channel purge
|
||||
- `packages/web/src/stores/transferStore.ts` -- Client transfer manager (uploads via tus-js-client, downloads via fetch + FS Access)
|
||||
@@ -149,10 +150,20 @@ Processing occurs inline during upload, before the response is sent. All process
|
||||
- Returns `{ thumbnailFilename, width, height }` (dimensions are from the original frame, not the thumbnail)
|
||||
|
||||
2. **Metadata probing** (`thumbnail.ts:probeMediaMeta`)
|
||||
- ffprobe for dimensions: `-select_streams v:0 -show_entries stream=width,height -of json`
|
||||
- ffprobe for dimensions + codec: `-select_streams v:0 -show_entries stream=width,height,codec_name -of json`
|
||||
- ffprobe for duration: `-show_entries format=duration -of json`
|
||||
- Duration rounded to 2 decimal places
|
||||
- If thumbnail extraction failed, dimensions fall back to ffprobe values
|
||||
- Returns `codec` (the primary video stream's `codec_name`) when available
|
||||
|
||||
3. **Web-playability classification** (`mediaPlayable.ts:classifyVideoPlayable`)
|
||||
- The finish hook calls `classifyVideoPlayable(mimetype, codec)` and stores the result in `attachments.playable` (tri-state, see below).
|
||||
- The browser `<video>` element can't decode every uploaded format. The dominant failure case is a macOS screen recording — a `video/quicktime` (.mov) container holding an **HEVC (H.265)** stream — which Chromium, Firefox and stock Electron can't decode. The file uploads fine and a server-side ffmpeg poster is generated, but inline playback silently fails (stuck at 0:00 with no error).
|
||||
- `attachments.playable` is a deliberate tri-state:
|
||||
- `0` / `false` — codec is confidently undecodable in mainstream browsers (HEVC, ProRes, WMV, MPEG-1/2, etc.). The client renders a download fallback card directly, no flash of a dead player.
|
||||
- `1` / `true` — web-standard codec (H.264/AVC, VP8/VP9, AV1, Theora) in a web container (`video/mp4`, `video/webm`, `video/ogg`). Plays inline.
|
||||
- `NULL` — unknown / optimistic. Codec couldn't be probed (ffmpeg absent or probe failed), or it's a web-safe codec in a container with inconsistent cross-browser support (H.264 in .mov). The client attempts inline playback and degrades via the `<video>` `onError` handler.
|
||||
- `false` is never widened beyond codecs known to fail everywhere, so an instance without ffmpeg keeps prior behaviour (attempt playback) rather than regressing every video to "unplayable".
|
||||
|
||||
### Audio Processing
|
||||
|
||||
@@ -521,7 +532,7 @@ URL resolution for attachment/thumbnail:
|
||||
| MIME category | Rendering |
|
||||
|---------------|-----------|
|
||||
| `image/*` | `<img>` with click-to-preview, lazy loading, aspect ratio from width/height, max 400x300px, uses thumbnail if available |
|
||||
| `video/*` | `<video>` with native controls, poster from thumbnail, preload `none` (with dimensions) or `metadata` (without), max 400px wide / 300px tall |
|
||||
| `video/*` | `VideoAttachment` sub-component. Playable (`playable !== false`): `<video src>` with native controls, poster from thumbnail, preload `none` (with dimensions) or `metadata` (without), max 400px wide / 300px tall, with an `onError` handler that falls back to the download card. Unplayable (`playable === false`, e.g. HEVC .mov): renders the download card directly — poster (if any) under a "Can't play here — download" overlay, plus filename, duration, size and a one-tap download. Never a silently broken player. |
|
||||
| `audio/*` | Audio card with icon, filename, size, `<audio>` with native controls, preload `metadata`, max 420px wide |
|
||||
| Other | Download link card with file icon, filename (link-styled), size |
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `attachments` ADD `playable` integer;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1782474063349,
|
||||
"tag": "0006_spicy_scourge",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1782832912087,
|
||||
"tag": "0007_nervous_orphan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -102,6 +102,10 @@ export const attachments = sqliteTable('attachments', {
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
duration: real('duration'),
|
||||
// Tri-state web-playability for video attachments: 1 = decodable in a
|
||||
// browser <video>, 0 = known-undecodable (e.g. HEVC .mov), NULL = unknown
|
||||
// (codec unprobed / non-video). Drives the client's download fallback.
|
||||
playable: integer('playable', { mode: 'boolean' }),
|
||||
sourceUrl: text('source_url'),
|
||||
federationStatus: text('federation_status'),
|
||||
federationMeta: text('federation_meta'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -446,6 +446,7 @@ export function queueDmRelay(
|
||||
width: a.width ?? undefined,
|
||||
height: a.height ?? undefined,
|
||||
duration: a.duration ?? undefined,
|
||||
playable: a.playable ?? null,
|
||||
thumbnailFilename: a.thumbnailFilename ?? undefined,
|
||||
sourceUrl: `${domainOrigin}/api/uploads/${a.filename}`,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyVideoPlayable } from './mediaPlayable';
|
||||
|
||||
describe('classifyVideoPlayable', () => {
|
||||
it('marks HEVC as not web-playable regardless of container', () => {
|
||||
// macOS screen recordings: video/quicktime + hevc — the reported bug.
|
||||
expect(classifyVideoPlayable('video/quicktime', 'hevc')).toBe(false);
|
||||
// HEVC inside an mp4 container is equally undecodable in Chromium/Firefox.
|
||||
expect(classifyVideoPlayable('video/mp4', 'hevc')).toBe(false);
|
||||
// ffprobe sometimes reports the tag rather than the codec name.
|
||||
expect(classifyVideoPlayable('video/quicktime', 'hvc1')).toBe(false);
|
||||
expect(classifyVideoPlayable('video/quicktime', 'hev1')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks other known-undecodable codecs as not web-playable', () => {
|
||||
expect(classifyVideoPlayable('video/quicktime', 'prores')).toBe(false);
|
||||
expect(classifyVideoPlayable('video/x-msvideo', 'wmv3')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks web-standard codec in a web container as playable', () => {
|
||||
expect(classifyVideoPlayable('video/mp4', 'h264')).toBe(true);
|
||||
expect(classifyVideoPlayable('video/webm', 'vp9')).toBe(true);
|
||||
expect(classifyVideoPlayable('video/webm', 'av1')).toBe(true);
|
||||
// ffprobe reports H.264 as 'h264'; the mp4 box tag is 'avc1'.
|
||||
expect(classifyVideoPlayable('video/mp4', 'avc1')).toBe(true);
|
||||
});
|
||||
|
||||
it('is optimistic (null) for web-safe codec in a non-web container', () => {
|
||||
// H.264 in a .mov plays in Chrome/Safari but not Firefox — let the client
|
||||
// attempt playback and fall back via onError rather than blocking it.
|
||||
expect(classifyVideoPlayable('video/quicktime', 'h264')).toBeNull();
|
||||
});
|
||||
|
||||
it('is optimistic (null) when the codec is unknown', () => {
|
||||
// ffmpeg unavailable / probe failed — must not regress to "unplayable".
|
||||
expect(classifyVideoPlayable('video/mp4', undefined)).toBeNull();
|
||||
expect(classifyVideoPlayable('video/mp4', null)).toBeNull();
|
||||
expect(classifyVideoPlayable('video/mp4', '')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-video mimetypes', () => {
|
||||
expect(classifyVideoPlayable('audio/mpeg', 'mp3')).toBeNull();
|
||||
expect(classifyVideoPlayable('image/png', undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('is case-insensitive on the codec name', () => {
|
||||
expect(classifyVideoPlayable('video/quicktime', 'HEVC')).toBe(false);
|
||||
expect(classifyVideoPlayable('video/mp4', 'H264')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Web-playability classification for video attachments.
|
||||
*
|
||||
* The browser `<video>` element can only decode a subset of the formats users
|
||||
* upload. The dominant failure case is a macOS screen recording — a
|
||||
* `video/quicktime` (.mov) container holding an HEVC (H.265) stream — which
|
||||
* Chromium, Firefox and stock Electron cannot decode. The file uploads fine,
|
||||
* a server-side ffmpeg poster is generated, but inline playback silently fails
|
||||
* (stuck at 0:00 with no error). We persist this classification per attachment
|
||||
* so the client can render a download fallback instead of a dead player.
|
||||
*
|
||||
* The result is a deliberate tri-state:
|
||||
* - `false` — confidently undecodable in mainstream browsers (e.g. HEVC).
|
||||
* The client renders the fallback card directly, no flash.
|
||||
* - `true` — confidently decodable (web codec in a web container).
|
||||
* - `null` — unknown / optimistic. The codec couldn't be probed (ffmpeg
|
||||
* absent or probe failed), or it's a web-safe codec in a
|
||||
* container with inconsistent cross-browser support (e.g. H.264
|
||||
* in .mov). The client attempts playback and degrades to the
|
||||
* fallback via the `<video>` `onError` handler.
|
||||
*
|
||||
* We never widen `false` beyond codecs we are certain fail everywhere, so an
|
||||
* instance without ffmpeg (codec always undefined) keeps today's behaviour
|
||||
* (attempt playback) rather than regressing every video to "unplayable".
|
||||
*/
|
||||
|
||||
/** ffprobe `codec_name` (or mp4/mov box tag) values that no mainstream browser decodes. */
|
||||
const UNPLAYABLE_VIDEO_CODECS = new Set([
|
||||
// HEVC / H.265 — codec_name is `hevc`; box tags are `hvc1` / `hev1`.
|
||||
'hevc', 'hvc1', 'hev1', 'h265',
|
||||
// Apple ProRes — editing/intermediate codec, never web-decodable.
|
||||
'prores',
|
||||
// Windows Media Video.
|
||||
'wmv1', 'wmv2', 'wmv3', 'vc1',
|
||||
// Legacy / capture codecs.
|
||||
'mpeg1video', 'mpeg2video', 'dnxhd', 'mjpeg', 'vp6', 'vp6f',
|
||||
]);
|
||||
|
||||
/** Codecs every modern browser can decode when in a web-standard container. */
|
||||
const PLAYABLE_VIDEO_CODECS = new Set([
|
||||
'h264', 'avc1', // H.264 / AVC
|
||||
'vp8', 'vp9',
|
||||
'av1', 'av01',
|
||||
'theora',
|
||||
]);
|
||||
|
||||
/** Containers with reliable cross-browser `<video>` support. */
|
||||
const PLAYABLE_VIDEO_CONTAINERS = new Set([
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/ogg',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify whether a video attachment can be played inline in a browser
|
||||
* `<video>` element, given its container mimetype and the probed video codec.
|
||||
*
|
||||
* @param mimetype Container mimetype (e.g. `video/quicktime`).
|
||||
* @param codec ffprobe `codec_name` of the primary video stream, if known.
|
||||
* @returns `false` (known-unplayable), `true` (known-playable) or `null` (unknown/optimistic).
|
||||
*/
|
||||
export function classifyVideoPlayable(
|
||||
mimetype: string,
|
||||
codec: string | null | undefined,
|
||||
): boolean | null {
|
||||
if (!mimetype.startsWith('video/')) return null;
|
||||
if (!codec) return null;
|
||||
|
||||
const c = codec.toLowerCase();
|
||||
if (UNPLAYABLE_VIDEO_CODECS.has(c)) return false;
|
||||
if (PLAYABLE_VIDEO_CONTAINERS.has(mimetype) && PLAYABLE_VIDEO_CODECS.has(c)) return true;
|
||||
|
||||
// Web-safe codec in a shaky container (H.264 in .mov), or an unrecognised
|
||||
// codec we can't vouch for: stay optimistic and let the client's onError
|
||||
// handler catch a genuine playback failure.
|
||||
return null;
|
||||
}
|
||||
@@ -138,19 +138,21 @@ export async function probeImageDimensions(filepath: string): Promise<{ width: n
|
||||
export async function probeMediaMeta(
|
||||
filepath: string,
|
||||
mimetype: string,
|
||||
): Promise<{ width?: number; height?: number; duration?: number } | null> {
|
||||
): Promise<{ width?: number; height?: number; duration?: number; codec?: string } | null> {
|
||||
if (!(await checkFfmpeg())) return null;
|
||||
|
||||
try {
|
||||
const result: { width?: number; height?: number; duration?: number } = {};
|
||||
const result: { width?: number; height?: number; duration?: number; codec?: string } = {};
|
||||
|
||||
// Video dimensions (stream-level)
|
||||
// Video dimensions + codec (stream-level). The codec drives web-playability
|
||||
// classification (see utils/mediaPlayable.ts) so the client can render a
|
||||
// download fallback for formats the browser can't decode (e.g. HEVC .mov).
|
||||
if (mimetype.startsWith('video/')) {
|
||||
try {
|
||||
const { stdout } = await execFile('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=width,height',
|
||||
'-show_entries', 'stream=width,height,codec_name',
|
||||
'-of', 'json',
|
||||
'-i', filepath,
|
||||
], { timeout: FFMPEG_TIMEOUT });
|
||||
@@ -161,7 +163,10 @@ export async function probeMediaMeta(
|
||||
result.width = stream.width;
|
||||
result.height = stream.height;
|
||||
}
|
||||
} catch { /* dimension probe failed — continue for duration */ }
|
||||
if (typeof stream?.codec_name === 'string' && stream.codec_name) {
|
||||
result.codec = stream.codec_name;
|
||||
}
|
||||
} catch { /* dimension/codec probe failed — continue for duration */ }
|
||||
}
|
||||
|
||||
// Duration (format-level — works for video and audio)
|
||||
|
||||
@@ -74,6 +74,7 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
|
||||
width: a.width ?? null,
|
||||
height: a.height ?? null,
|
||||
duration: a.duration ?? null,
|
||||
playable: a.playable ?? null,
|
||||
createdAt: a.createdAt,
|
||||
}));
|
||||
|
||||
|
||||
@@ -244,6 +244,13 @@ export interface Attachment {
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
duration?: number | null;
|
||||
/**
|
||||
* Web-playability for video attachments. `false` = the codec can't be
|
||||
* decoded in a browser <video> (e.g. HEVC .mov) so the client renders a
|
||||
* download fallback; `true`/`null` = attempt inline playback (null is the
|
||||
* optimistic unknown case, also covered by the client's onError fallback).
|
||||
*/
|
||||
playable?: boolean | null;
|
||||
federationStatus?: string | null;
|
||||
federationMeta?: string | null;
|
||||
createdAt: number;
|
||||
@@ -1088,6 +1095,9 @@ export interface FederationRelayAttachment {
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
// Web-playability computed by the origin instance (see Attachment.playable).
|
||||
// Propagated so the receiving instance need not re-probe the codec.
|
||||
playable?: boolean | null;
|
||||
thumbnailFilename?: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { Attachment } from '@backspace/shared';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useTransferStore } from '../../stores/transferStore';
|
||||
@@ -14,6 +14,16 @@ function formatFileSize(bytes: number): string {
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Format a duration in seconds as `m:ss` (or `h:mm:ss` for long clips). */
|
||||
function formatDuration(seconds: number): string {
|
||||
const total = Math.round(seconds);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the displayable URL for an attachment. Same logic used by inline
|
||||
* `<img>`/`<video>`/`<audio>` rendering and the file-card download button —
|
||||
@@ -24,6 +34,110 @@ export function attUrlOf(filename: string): string {
|
||||
return `/api/uploads/${filename}`;
|
||||
}
|
||||
|
||||
interface VideoAttachmentProps {
|
||||
attachment: Attachment;
|
||||
attUrl: string;
|
||||
thumbUrl: string | null;
|
||||
federationInlineBadge: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Video attachment with a graceful fallback for formats the browser can't
|
||||
* decode. The dominant case is a macOS screen recording (HEVC inside a .mov):
|
||||
* the upload succeeds and a server-side poster is generated, but inline
|
||||
* `<video>` playback silently fails (stuck at 0:00). We resolve this two ways:
|
||||
*
|
||||
* 1. Proactive — the server classifies web-playability from the probed codec
|
||||
* (`attachment.playable === false`), so we render the download card
|
||||
* directly with no flash of a dead player.
|
||||
* 2. Reactive — for the optimistic/unknown cases, the `<video>` `onError`
|
||||
* handler flips to the same card if playback actually fails at runtime.
|
||||
*
|
||||
* The fallback card surfaces the poster (still a useful preview), filename,
|
||||
* duration and size, and a one-tap download — never a silently broken player.
|
||||
*/
|
||||
function VideoAttachment({ attachment, attUrl, thumbUrl, federationInlineBadge }: VideoAttachmentProps) {
|
||||
const startDownload = useTransferStore((s) => s.startDownload);
|
||||
const [failed, setFailed] = useState(attachment.playable === false);
|
||||
|
||||
const { width, height, originalName, mimetype, size, duration } = attachment;
|
||||
const hasDimensions = !!(width && height);
|
||||
const sizing = hasDimensions
|
||||
? { aspectRatio: `${width}/${height}`, maxHeight: 300 }
|
||||
: undefined;
|
||||
|
||||
if (failed) {
|
||||
const meta = [duration ? formatDuration(duration) : null, formatFileSize(size)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return (
|
||||
<div className="mt-1 max-w-[400px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void startDownload(attUrl, { filename: originalName, size, mimetype, tray: true });
|
||||
}}
|
||||
className="block w-full text-left rounded-lg overflow-hidden border border-border-hard bg-surface-channel/50 hover:bg-interactive-hover transition-all group/vid"
|
||||
>
|
||||
{thumbUrl && (
|
||||
<div className="relative w-full" style={sizing}>
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={originalName}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/45 flex flex-col items-center justify-center gap-1.5 text-white">
|
||||
<svg className="w-9 h-9" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span className="text-[12px] font-medium">Can't play here — download</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
{!thumbUrl && (
|
||||
<div className="p-2 bg-surface-base rounded text-txt-tertiary flex-shrink-0">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-txt-link text-[14px] font-medium truncate group-hover/vid:underline">{originalName}</p>
|
||||
<p className="text-[12px] text-txt-tertiary">
|
||||
{meta ? `${meta} · ` : ''}Unsupported video format
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{federationInlineBadge && <div className="mt-1">{federationInlineBadge}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1 max-w-[400px]">
|
||||
<div
|
||||
className="relative max-h-[300px] rounded-lg overflow-hidden"
|
||||
style={sizing}
|
||||
>
|
||||
<video
|
||||
controls
|
||||
preload={hasDimensions ? 'none' : 'metadata'}
|
||||
poster={thumbUrl ?? undefined}
|
||||
src={attUrl}
|
||||
onError={() => setFailed(true)}
|
||||
className="w-full h-full rounded-lg"
|
||||
>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</div>
|
||||
{federationInlineBadge && <div className="mt-1">{federationInlineBadge}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttachmentRenderer({ attachment }: AttachmentRendererProps) {
|
||||
const openImagePreview = useUIStore((s) => s.openImagePreview);
|
||||
const startDownload = useTransferStore((s) => s.startDownload);
|
||||
@@ -109,26 +223,13 @@ export function AttachmentRenderer({ attachment }: AttachmentRendererProps) {
|
||||
}
|
||||
|
||||
if (mimetype.startsWith('video/')) {
|
||||
const { width, height } = attachment;
|
||||
const hasDimensions = width && height;
|
||||
return (
|
||||
<div className="mt-1 max-w-[400px]">
|
||||
<div
|
||||
className="relative max-h-[300px] rounded-lg overflow-hidden"
|
||||
style={hasDimensions ? { aspectRatio: `${width}/${height}`, maxHeight: 300 } : undefined}
|
||||
>
|
||||
<video
|
||||
controls
|
||||
preload={hasDimensions ? 'none' : 'metadata'}
|
||||
poster={thumbUrl ?? undefined}
|
||||
className="w-full h-full rounded-lg"
|
||||
>
|
||||
<source src={attUrl} type={mimetype} />
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
</div>
|
||||
{federationInlineBadge && <div className="mt-1">{federationInlineBadge}</div>}
|
||||
</div>
|
||||
<VideoAttachment
|
||||
attachment={attachment}
|
||||
attUrl={attUrl}
|
||||
thumbUrl={thumbUrl}
|
||||
federationInlineBadge={federationInlineBadge}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user