- MediaViewer branches by type: pdf (native iframe), text/json (viewer +
copy), html (sandboxed allow-scripts run + source toggle), image (zoom/
pan/rotate lightbox + Edit), video (brand player + Trim)
- ImageEditor: canvas crop (drag + aspect presets), rotate 90s, flip H/V,
brightness/contrast/saturation; Save as copy uploads a NEW library file
via the direct path (server fallback)
- VideoTrim: in/out over the duration; Cut in Video Studio registers the
same R2 object as a video_asset, creates a one-clip EDL at the trim, and
queues the assemble render — the cut lands in the Video library, the
original file is never modified
- invalid type syntax fixed across the new components (prop callbacks use
arrow types)
SOTA lightbox patterns adapted to the hub: images zoom (wheel/double-click)
pan rotate with a filmstrip + counter + focus trap; videos play in a
brand-skinned player (scrubber with buffered range, volume, speed, PiP,
fullscreen, keyboard: space/k, arrows ±5s, j/l ±10s, m, f, p, up/down).
Payloads are E2E-encrypted, so thumbnails and playback decrypt client-side
through the session cache (direct objects via presigned GET + CEK; others
via the streaming route) — the grid shows real video stills captured in the
browser. List view stays for documents; Media/Grid is the default for
image/video items. FileRow gains a View action for media rows.
P0-1 compose: inline 'Connect a channel to publish' beside the disabled
Publish (desktop) and above the mobile bar — the silent-disabled state is gone.
P0-2 first-run home reads the landing's tmbh.manual.lang preference instead
of hard-coding Spanish.
P1-3 sidebar: 16 flat items → 5 labeled sections (Publish/Content/Operations/
Admin); dead Onboarding item removed; Groups surfaces in Operations.
P1-4 More menu: Groups row added; Onboarding row removed.
P1-6 duplicate Security & sessions row removed.
P1-7 mobile Post → Publish (label drift); More menu labels unified
(Video library / Files / Connections / Audit trail).
P2-10 username pattern regex fixed ([a-z0-9._-] with the dash last).
Card and audio-less-clip anullsrc sources are infinite; concat never
terminates an infinite segment, so the encode hung forever at 30%. atrim
pins each bed to its item's span (card duration / clip trim).
The assemble lane's card pass resolves sharp (SHARP_MODULE_PATH or
require.resolve) like the api does; the worker image never had it, so every
assemble job failed 'sharp not found'.
Per the adopted qwen38 plan §2 (+ glm53 amendments): video_processing_jobs
kind 'assemble' (claim widened via 044), EdlPlan mirror validation
(lenient-fail), the filter_complex builder (interleaved concat pairs, fit
cover/contain chains, absolute-timeline PNG overlays with enable windows,
VO adelay/sidechain duck, hard-breaking word wrap for card PNGs), and the
runner's assemble path with pre-spawn trim gates, attempt backoff and 15-min
cap. Sources open by magic (SPENC1 kek / SPENC2 direct key) so direct-to-R2
uploads assemble without the api ever seeing bytes. Tests: builder 18,
edl-plan 12, overlay-png 3, runner fakes extended.
downloadSealed fetched raw bytes (getSealedObject) and handed ciphertext to
ffprobe — every direct upload failed 'Invalid data found'. The lane now uses
getObject, whose magic branch opens SPENC1 and SPENC2 alike.
POST /video/direct/init|complete: the browser seals (SPENC2) and PUTs
straight to object storage; the api HEAD-checks, registers the unprobed
asset and queues a probe job — video bytes never cross the api box.
Worker side: produce_job_claim widened to probe jobs (042), the runner
gains a probe lane (sealed fetch → SPENC1/SPENC2 open by magic → scratch →
ffprobe → metadata write-back → complete 100), worker storage reads SPENC2
frames via DIRECT_EC_PRIVATE_KEY (inherited through compose appenv), and a
ported ffprobe util. Probe rows carrying input_json fail instead of
stranding; the claim guard no longer strands probe rows as invalid.
getObject decrypts by magic, so feeding its plaintext into openDirectHeader
always failed. getSealedObject exposes raw bytes for the header read;
DirectStorageMembers and the test fakes carry the new member.
For scale: payload bytes go browser → R2 → browser; the api handles keys,
authz and metadata only, so a cheap box can serve thousands of users.
- SPENC2 frame (browser-sealed, P-256 ECDH → HKDF wrap of a per-file CEK,
10 MiG AES-GCM segments): storage.ts gains the reader (magic-branched
getObject), presignPut/headObject, openDirectHeader; sealForDirect is the
reference sealer for cross-implementation tests
- Files API: GET /files/direct/config, POST /files/direct/init (presigned
PUT), POST /files/direct/complete (HEAD-gated commit), POST
/files/:id/direct-download (presigned GET + CEK ticket)
- web: lib/direct-storage (WebCrypto seal/open), lib/files-direct (flows),
dropzone rewritten with a multi-file sequential queue (per-file status,
direct-first with server fallback), file download decrypts in-browser for
direct objects (falls back to streaming)
- video dropzone wired to the chunked ingest API (>10 MiB → init/chunk/
complete), fixing the Cloudflare ~100 MB body cap for large videos
- DIRECT_EC_PRIVATE_KEY (P-256, PKCS8 b64) wired through compose; cross-impl
roundtrip tests prove web-seal ↔ server-open byte compatibility
Intake stores alt texts positionally (aligned with mediaAssetIds) but the
Mastodon upload reads an asset-id-keyed providerMetadata.altText map, so
descriptions were silently dropped. postOf translates the array into the map.
Files payloads moved to R2 in 041; this removes the remaining oCIS coupling:
- delete the oCIS WebDAV client; verifyWhileStreaming moves to its own
storage-agnostic module (verify-stream.ts)
- group-files: storage_key is the only payload source; legacy rows answer
'retired storage' instead of touching oCIS
- collective share: the ocis-resource item kind is gone (peertube links stay);
share-into-library was R2-backed since the previous commit
- import-drive endpoint removed (no web caller); import-chat (homeserver) stays
- web: Drive tab + embedded oCIS page removed; Library is the Files view
Legacy e2e rows (2) deleted; every remaining archive row is R2-backed.
Cloudflare's proxy plan rejects bodies above ~100 MB, so whole-body video
uploads cannot exceed it no matter the origin limits. Clients now slice into
10 MB chunks: POST /video/upload/init, PUT /video/upload/:id/chunk/:index,
POST /video/upload/:id/complete reassembles to disk, seals, stores to R2 and
runs the normal probe + job pipeline. Max chunked upload: 100 GiB. Tests 17/17.
The Files tab was the only payload path not on object storage, violating the
2026-10-06 directive (object storage is the ONLY media I/O path) — and oCIS's
WebDAV surface is currently 500ing server-side, which made every upload fail.
Uploads now seal + dedupe into R2 via StorageService (SPENC1, content-
addressed); downloads read R2 when storage_key is set, falling back to oCIS
for legacy rows. Share-into-library copies oCIS Drive sources into R2 and
records the real sha256 instead of a placeholder. Migration 041 adds
chat_media_archive.storage_key.
Audio-less inputs no longer emit [0:a] maps that ffmpeg rejects; the worker
branches to an anullsrc bed configured at the output preset's rate/layout.
Producer tests 42/42.
The Files tab dropzone still enforced the old 20 MiB cap client- and
server-side while every other path had moved to 2 GiB. Also corrects the
stale '100 MiB' message on the video upload rejection.
Media posts are two mutations (media upload + status create). The dispatch
gate fences the second mutation unless the origin is deployment-verified via
MASTODON_PUBLISH_DEDUPE_ORIGINS, so without it every media post died as
PUBLISH_OUTCOME_UNKNOWN after a successful upload.
uploadAll/uploadAsset call the media hooks with (connection, assetId) so
implementations can resolve the workspace; the registry adapter threads
connection.workspaceId into the resolver.
The registry accepted media hooks but nothing supplied them, so Mastodon
media posts failed uploadAll (INVALID_POST attempts). PublishingMediaResolver
in the scheduler resolves workspace-scoped media_assets rows via RLS tenant
context and reads plaintext bytes from S3 with SPENC1 open (S3_KEK), matching
the api's S3Storage frame format.
The Mastodon connector's uploadAll resolves asset ids via loadAsset/openAsset.
The registry wires these hooks from a mediaResolver that the scheduler injects:
media_assets lookup (workspace-scoped via SET LOCAL) + R2 download + SPENC1
decrypt (extracted from the api's storage into connector-runtime/media.ts).
provider-fetch gains serializeBody for multipart media uploads through the
pinned transport channel.
The produce worker crashed its claim cycle at the scratch mkdtemp (ENOENT:
the parent dir never existed on a fresh volume) — bootstrap MEDIA_SCRATCH_DIR
recursively at startup. The api image lacked ffprobe entirely, so every
upload's probe job failed — install ffmpeg in the api runtime image.
The store's hand-built claim interpolated the lane predicate into the SQL
text while still passing a bind parameter — node-postgres sent 1 param for
a 0-param statement (08P01) on every poll, so the live produce worker
store-errored every ~2s and never claimed. Migration 040 replaces the
inline claim with produce_job_claim(p_lane, p_stale_after): SECURITY
DEFINER (the 035/oidc_refresh_lookup pattern — the poller is lane-global,
FORCE RLS hides every row without a tenant context), atomic FOR UPDATE
SKIP LOCKED with the queued→transcoding bump as the lease, and stale-
transcoding reclaim so a killed worker's rows return to the queue.
produce_job_workspace(p_job_id) discovers the owning tenant for the
follow-up writes, which stay RLS-bound as sp_app. pg-store rewired; store
errors now surface err.message instead of err.name; pg-store tests added.
38/38 producer tests green.
Deployment (t4g2): media-worker gains font-dejavu (drawtext lower-thirds)
and a dedicated produce-worker compose service running src/produce.ts —
claims video_processing_jobs compose|mix by lane, renders assembled/
overlaid/voiced cuts, registers derived library assets. Deliberately its
own process so a remote-lane box needs only Postgres + S3 + ffmpeg.
Web: compose media attachments UI (t2), Producer panel in the video
drawer (t5), collective creation + branding settings + shell branding
(brandweb), mobile CSS (mcss: touch targets, icon sizing, safe areas).
Compose: upload picked files to /media with progress, thumbnails via the
auth-scoped serve route, per-attachment alt text, remove, ≤4 cap with the
Mastodon norm hint, in-flight blocking. Video drawer: Producer section —
lower-third editor, logo key field, MediaRecorder voiceover (record/
stop/review/re-take/upload), produce submission with lane + label, job
progress, derived-asset link. Local types for both contracts.
validateBrandingPatch validated storage-key values but never assigned
them into the output — PUT /workspace/branding with a logo/background
silently did nothing, so admins could never set them. Also ACCENT_HEX
gains /i (uppercase hex accepted, canonicalized to lowercase on read).
Tests unpinned from the buggy behavior; 41/41 green.
Until now collectives only existed at registration; an account with a
personal workspace had no path to start one. The creator becomes Owner
and gets a session scoped to the new workspace.
POST /video/import-drive {resourceId} pulls through the oCIS WebDAV
service-account client; POST /video/import-chat {mxcUri} pulls from the
Matrix content repository (MATRIX_MEDIA_TOKEN, homeserver falls back to
ARCHIVE_PROVISIONER_HOMESERVER_URL). Both cap at 256 MiB (buffered v1,
streaming follow-up), store through the same encrypted content-
addressed path as uploads, probe, and audit video.imported with
provenance.