evOo-local/apps/mobile/lib/media-project.ts
Claude 4c30af9ea3
feat(media): mobile library index, resumable upload, and project variants
Index photos and videos from the device library into a client-pushed
ios_library source, upload originals in resumable chunks, and review,
approve, and share project variants with expo-video and the native share
sheet. Pure logic for chunking, resume, indexing, and variants is unit
tested.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:20:42 +00:00

228 lines
8.4 KiB
TypeScript

import type {
GateIssue,
MediaAsset,
MediaEditPlan,
MediaRender,
MediaRenderStatus,
PlatformProfileSummary,
} from "@rakazo/contracts";
/*
* Pure view logic for the mobile media screens: which plan and render make a
* variant, when to poll, what can be approved or shared, and where a media
* push notification leads. Screens stay thin; this stays testable.
*/
/** Clients poll while work runs; there is no realtime media subscription. */
export const MEDIA_POLL_MS = 2_000;
const ACTIVE_RENDER: ReadonlySet<MediaRenderStatus> = new Set(["queued", "rendering", "gating"]);
export function isRenderActive(render: Pick<MediaRender, "status"> | null | undefined): boolean {
return Boolean(render && ACTIVE_RENDER.has(render.status));
}
export type MediaVariant = {
profileId: string;
profileName: string;
plan: MediaEditPlan;
/** Newest render of the newest plan version, any quality. */
latest: MediaRender | null;
/** Newest final render of the newest plan version. */
final: MediaRender | null;
/** Newest render with a playable video: final when done, else the draft. */
preview: MediaRender | null;
};
function newer<T extends { createdAt: string; id: string }>(a: T, b: T): boolean {
return a.createdAt === b.createdAt ? a.id > b.id : a.createdAt > b.createdAt;
}
function newest<T extends { createdAt: string; id: string }>(items: T[]): T | null {
let best: T | null = null;
for (const item of items) if (!best || newer(item, best)) best = item;
return best;
}
export function videoOutput(render: Pick<MediaRender, "outputs"> | null) {
return render?.outputs.find((output) => output.kind === "video") ?? null;
}
/** One variant per profile: its newest plan version and that plan's renders. */
export function projectVariants(input: {
profileIds: string[];
plans: MediaEditPlan[];
renders: MediaRender[];
profiles: Pick<PlatformProfileSummary, "id" | "name">[];
}): MediaVariant[] {
const names = new Map(input.profiles.map((profile) => [profile.id, profile.name]));
const variants: MediaVariant[] = [];
for (const profileId of input.profileIds) {
const plans = input.plans.filter((plan) => plan.profileId === profileId);
let plan: MediaEditPlan | null = null;
for (const candidate of plans) {
if (
!plan ||
candidate.version > plan.version ||
(candidate.version === plan.version && newer(candidate, plan))
) {
plan = candidate;
}
}
if (!plan) continue;
const planId = plan.id;
const renders = input.renders.filter((render) => render.planId === planId);
const final = newest(renders.filter((render) => render.quality === "final"));
const playable = renders.filter((render) => render.status === "done" && videoOutput(render));
const finalDone = playable.filter((render) => render.quality === "final");
variants.push({
profileId,
profileName: names.get(profileId) ?? profileId,
plan,
latest: newest(renders),
final,
preview: newest(finalDone) ?? newest(playable),
});
}
return variants;
}
export type VariantState =
| "planned"
| "rendering"
| "draft_ready"
| "approved_rendering"
| "ready"
| "blocked"
| "failed";
export function variantState(variant: MediaVariant): VariantState {
const latest = variant.latest;
if (!latest) return "planned";
if (isRenderActive(latest))
return latest.quality === "final" ? "approved_rendering" : "rendering";
if (latest.status === "blocked") return "blocked";
if (latest.status === "failed" || latest.status === "canceled") return "failed";
return latest.quality === "final" ? "ready" : "draft_ready";
}
/** Approval starts the final render; it needs a plan and no render in flight. */
export function canApprove(variant: MediaVariant): boolean {
const state = variantState(variant);
return state === "draft_ready" || state === "planned" || state === "failed";
}
/** Only a final render that passed its gate leaves the app. */
export function canShare(variant: MediaVariant): boolean {
return Boolean(variant.final && variant.final.status === "done" && videoOutput(variant.final));
}
export function blockers(render: Pick<MediaRender, "gateReport"> | null): GateIssue[] {
return render?.gateReport?.issues.filter((issue) => issue.severity === "block") ?? [];
}
export function shouldPollProject(renders: MediaRender[]): boolean {
return renders.some(isRenderActive);
}
/** Device items that still have to upload before the server can work on them. */
export function needsUpload(asset: Pick<MediaAsset, "storage" | "ingestState">): boolean {
return asset.storage === "remote" || asset.ingestState === "awaiting_upload";
}
export type ProjectReadiness = "uploading" | "processing" | "ready" | "failed";
/** Items this device has to upload: only its own library can send them. */
export function uploadsFromDevice<
T extends Pick<MediaAsset, "storage" | "ingestState" | "sourceId">,
>(assets: T[], deviceSourceId: string | null): T[] {
return deviceSourceId
? assets.filter((asset) => asset.sourceId === deviceSourceId && needsUpload(asset))
: [];
}
/**
* Whether a project's material is ready for automatic plans: every item
* uploaded and analyzed (or at least proxied when analysis is unavailable).
* "uploading" means this device still holds originals; items waiting for
* another device count as processing.
*/
export function projectReadiness(
assets: Pick<MediaAsset, "storage" | "ingestState" | "sourceId">[],
analysisAvailable: boolean,
deviceSourceId: string | null,
): ProjectReadiness {
if (assets.length === 0) return "processing";
if (uploadsFromDevice(assets, deviceSourceId).length > 0) return "uploading";
if (assets.some(needsUpload)) return "processing";
const usable = assets.filter((asset) => asset.ingestState !== "failed");
if (usable.length === 0) return "failed";
const done = analysisAvailable ? ["analyzed"] : ["proxied", "analyzed"];
return usable.every((asset) => done.includes(asset.ingestState)) ? "ready" : "processing";
}
/** File name for a render shared from the device. */
export function shareFileName(renderId: string, fileName: string): string {
const extension = /\.[A-Za-z0-9]{1,5}$/.exec(fileName)?.[0] ?? ".mp4";
return `render-${renderId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 64)}${extension.toLowerCase()}`;
}
export function renderFileUrl(
apiBase: string,
renderId: string,
fileName: string,
download = false,
): string {
const base = apiBase.replace(/\/+$/, "");
const path = `/api/media/renders/${encodeURIComponent(renderId)}/files/${encodeURIComponent(fileName)}`;
return `${base}${path}${download ? "?download=1" : ""}`;
}
export function assetThumbUrl(apiBase: string, assetId: string): string {
return `${apiBase.replace(/\/+$/, "")}/api/media/assets/${encodeURIComponent(assetId)}/thumb`;
}
/** Push payload the server sends when a render finishes or is blocked. */
export type MediaRenderPushData = {
kind: "media.render";
renderId: string;
projectId: string;
status: MediaRenderStatus;
};
/** Route a tapped notification to its project, or null for any other push. */
export function mediaNotificationRoute(
data: unknown,
): { pathname: "/media-project"; params: { projectId: string } } | null {
if (!data || typeof data !== "object") return null;
const record = data as Record<string, unknown>;
if (record.kind !== "media.render") return null;
const projectId = record.projectId;
if (typeof projectId !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(projectId)) return null;
return { pathname: "/media-project", params: { projectId } };
}
export type LibraryAccess = "undetermined" | "denied" | "blocked" | "limited" | "all";
/** Collapse a media library permission response into what the screen needs. */
export function libraryAccess(permission: {
granted: boolean;
canAskAgain: boolean;
status: string;
accessPrivileges?: "all" | "limited" | "none";
}): LibraryAccess {
if (permission.granted) return permission.accessPrivileges === "limited" ? "limited" : "all";
if (permission.status === "undetermined") return "undetermined";
return permission.canAskAgain ? "denied" : "blocked";
}
export function formatDuration(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = String(total % 60).padStart(2, "0");
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
: `${minutes}:${seconds}`;
}