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:
parent
71db0a08b2
commit
1183d69849
7 changed files with 903 additions and 17 deletions
55
apps/web/src/lib/media-types.ts
Normal file
55
apps/web/src/lib/media-types.ts
Normal 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)[] };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue