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
228 lines
8.4 KiB
TypeScript
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}`;
|
|
}
|