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 = new Set(["queued", "rendering", "gating"]); export function isRenderActive(render: Pick | 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(a: T, b: T): boolean { return a.createdAt === b.createdAt ? a.id > b.id : a.createdAt > b.createdAt; } function newest(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 | 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[]; }): 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 | 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): 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, >(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[], 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; 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}`; }