feat: compose media attachments UI + video producer panel (t2+t5)

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.
This commit is contained in:
glass beadian 2026-10-14 20:14:20 +00:00
parent 71db0a08b2
commit 1183d69849
7 changed files with 903 additions and 17 deletions

View file

@ -0,0 +1,55 @@
/**
* Wire shapes + helpers for the media asset endpoints (POST /media, GET /media/:id).
*
* Kept local to the web app for the same reason as submissions-types.ts: the
* shared api-types.ts may be edited by other builders in parallel while the
* media backend lands. Hand-synced with the media controller contract.
*/
import { API_URL, ApiError, ApiUnreachableError, apiUploadFile } from "./api-client";
import { getSessionToken } from "./session-token";
import type { DraftPost } from "./api-types";
/** POST /media (multipart field "file") → the stored asset. */
export interface MediaAssetRecord {
id: string;
url: string;
}
/** Mastodon's attachment cap: up to 4 images, or a single video on its own. */
export const MEDIA_MAX_ATTACHMENTS = 4;
/** Shown next to the attach controls so the cap is discoverable before hitting it. */
export const MEDIA_ATTACH_HINT = "Mastodon allows up to 4 images or 1 video";
/**
* Bytes for GET /media/:id are auth-scoped, so a plain <img src> (which cannot
* send the Authorization header) does not work — mirror downloadGroupFile in
* components/files/files-client.ts: replay the client's auth rules, stream the
* body into a Blob and hand back an object URL the caller must revoke.
*/
export async function fetchMediaObjectUrl(assetId: string): Promise<string> {
const token = getSessionToken();
let response: Response;
try {
response = await fetch(`${API_URL}/media/${encodeURIComponent(assetId)}`, {
credentials: "include",
headers: token === null ? {} : { Authorization: `Bearer ${token}` },
});
} catch (err) {
throw new ApiUnreachableError(err);
}
if (!response.ok) {
if (response.status === 401) throw new ApiError(401, "Your session expired — sign in again.", null);
throw new ApiError(response.status, `Could not load media (HTTP ${response.status})`, null);
}
return URL.createObjectURL(await response.blob());
}
/** Multipart upload with browser progress; apiUploadFile owns auth + error shape. */
export function uploadMediaAsset(file: File, onProgress: (percent: number) => void): Promise<MediaAssetRecord> {
return apiUploadFile<MediaAssetRecord>("/media", file, onProgress);
}
/** drafts accept altTexts aligned by index with mediaAssetIds; null = no alt written yet. */
export type MediaDraftPost = DraftPost & { altTexts?: (string | null)[] };

View file

@ -55,3 +55,44 @@ export interface VideoStatusDto {
videoAsset: VideoAssetDto;
processingJob: VideoProcessingJobDto;
}
/**
* Producer (POST /video/:id/produce) request/response shapes — same
* hand-synced standing as the routes above.
*/
/** Where a lower-third overlay sits in the frame. */
export type VideoOverlayPosition = "bottom-left" | "bottom-center" | "bottom-right";
/** One overlay in a produce request; v1 renders lower-thirds only. */
export interface VideoOverlayDto {
kind: "lower-third";
text: string;
position: VideoOverlayPosition;
}
/** Voiceover mix settings; assetId is an asset already in the workspace. */
export interface VideoVoiceoverDto {
assetId: string;
gainDb: number;
duckOriginal: boolean;
}
/** Where the produced variant is rendered. */
export type VideoProduceLane = "local" | "remote";
/** Body of POST /video/:id/produce. */
export interface VideoProduceRequest {
overlays?: VideoOverlayDto[];
voiceover?: VideoVoiceoverDto;
outputLabel?: string;
lane?: VideoProduceLane;
}
/** Response of POST /video/:id/produce — the job that renders the derived asset. */
export interface VideoProduceResultDto {
job: VideoProcessingJobDto;
/** The derived asset the job writes; it appears in the library when the job completes. */
producedAssetId?: string;
outputKey?: string;
}