From 4c30af9ea31a2057dc4706d9d825d46b83159e8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:20:42 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w --- apps/mobile/app/media-project.tsx | 344 ++++++++++++++++++++++ apps/mobile/app/media-session.tsx | 189 ++++++++++++ apps/mobile/app/media.tsx | 241 ++++++++++++++++ apps/mobile/lib/media-api.ts | 374 ++++++++++++++++++++++++ apps/mobile/lib/media-index.test.ts | 215 ++++++++++++++ apps/mobile/lib/media-index.ts | 209 ++++++++++++++ apps/mobile/lib/media-project.test.ts | 299 +++++++++++++++++++ apps/mobile/lib/media-project.ts | 227 +++++++++++++++ apps/mobile/lib/media-sha256.test.ts | 44 +++ apps/mobile/lib/media-sha256.ts | 123 ++++++++ apps/mobile/lib/media-upload.test.ts | 396 ++++++++++++++++++++++++++ apps/mobile/lib/media-upload.ts | 249 ++++++++++++++++ 12 files changed, 2910 insertions(+) create mode 100644 apps/mobile/app/media-project.tsx create mode 100644 apps/mobile/app/media-session.tsx create mode 100644 apps/mobile/app/media.tsx create mode 100644 apps/mobile/lib/media-api.ts create mode 100644 apps/mobile/lib/media-index.test.ts create mode 100644 apps/mobile/lib/media-index.ts create mode 100644 apps/mobile/lib/media-project.test.ts create mode 100644 apps/mobile/lib/media-project.ts create mode 100644 apps/mobile/lib/media-sha256.test.ts create mode 100644 apps/mobile/lib/media-sha256.ts create mode 100644 apps/mobile/lib/media-upload.test.ts create mode 100644 apps/mobile/lib/media-upload.ts diff --git a/apps/mobile/app/media-project.tsx b/apps/mobile/app/media-project.tsx new file mode 100644 index 0000000..6c3f6de --- /dev/null +++ b/apps/mobile/app/media-project.tsx @@ -0,0 +1,344 @@ +import type { + MediaAsset, + MediaCapabilities, + MediaCollection, + MediaEditPlan, + MediaProject, + MediaRender, + PlatformProfileSummary, +} from "@rakazo/contracts"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { mobileTokens } from "../lib/appearance"; +import { useI18n } from "../lib/i18n"; +import { + mediaRpc, + registerDeviceSource, + renderVideoSource, + shareRender, + uploadAssets, +} from "../lib/media-api"; +import type { MediaVariant } from "../lib/media-project"; +import { + blockers, + canApprove, + canShare, + MEDIA_POLL_MS, + projectReadiness, + projectVariants, + shouldPollProject, + variantState, + videoOutput, +} from "../lib/media-project"; +import { native, useThemedStyles } from "../lib/native"; + +type ProjectData = { project: MediaProject; plans: MediaEditPlan[]; renders: MediaRender[] }; + +function message(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason); +} + +export default function MediaProjectScreen() { + const styles = useThemedStyles(createStyles); + const { t } = useI18n(); + const { projectId, upload } = useLocalSearchParams<{ projectId: string; upload?: string }>(); + const [data, setData] = useState(null); + const [assets, setAssets] = useState([]); + const [profiles, setProfiles] = useState([]); + const [analysis, setAnalysis] = useState(true); + const [deviceSourceId, setDeviceSourceId] = useState(null); + const [uploadProgress, setUploadProgress] = useState(null); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const uploadStarted = useRef(false); + const planned = useRef(false); + const uploadAbort = useRef(null); + + const load = useCallback(async () => { + if (!projectId) return; + const next = await mediaRpc("projects/get", { projectId }); + const collections = await Promise.all( + next.project.collectionIds.map((collectionId) => + mediaRpc<{ collection: MediaCollection; assets: MediaAsset[] }>("collections/get", { + collectionId, + }), + ), + ); + const byId = new Map(); + for (const entry of collections) for (const asset of entry.assets) byId.set(asset.id, asset); + setAssets([...byId.values()]); + setData(next); + }, [projectId]); + + useEffect(() => { + void load().catch((reason) => setError(message(reason))); + void mediaRpc("profiles/list") + .then(setProfiles) + .catch(() => undefined); + void registerDeviceSource() + .then((source) => setDeviceSourceId(source.id)) + .catch(() => undefined); + void mediaRpc("capabilities") + .then((capabilities) => setAnalysis(capabilities.configured)) + .catch(() => undefined); + return () => uploadAbort.current?.abort(); + }, [load]); + + const readiness = projectReadiness(assets, analysis, deviceSourceId); + const uploading = uploadProgress !== null; + + const startUpload = useCallback(async () => { + if (!deviceSourceId) return; + uploadStarted.current = true; + const controller = new AbortController(); + uploadAbort.current = controller; + setUploadProgress(0); + try { + const failed = await uploadAssets( + assets, + deviceSourceId, + setUploadProgress, + controller.signal, + ); + if (failed.length > 0) { + Alert.alert(t("{count} items could not be uploaded.", { count: failed.length })); + } + } catch (reason) { + if (!controller.signal.aborted) setError(message(reason)); + } finally { + if (!controller.signal.aborted) { + setUploadProgress(null); + await load().catch(() => undefined); + } + } + }, [assets, deviceSourceId, load, t]); + + useEffect(() => { + if (upload === "1" && readiness === "uploading" && !uploadStarted.current) { + void startUpload(); + } + }, [upload, readiness, startUpload]); + + useEffect(() => { + if (!data || readiness !== "ready" || data.plans.length > 0 || planned.current) return; + planned.current = true; + void (async () => { + try { + const plans = await mediaRpc( + "projects/autoPlan", + { projectId: data.project.id }, + { timeoutMs: 120_000 }, + ); + for (const plan of plans) { + await mediaRpc("renders/start", { planId: plan.id, quality: "draft" }); + } + await load(); + } catch (reason) { + setError(message(reason)); + } + })(); + }, [data, readiness, load]); + + useEffect(() => { + if (!data || uploading) return; + const waiting = data.plans.length === 0 && readiness === "processing"; + if (!waiting && !shouldPollProject(data.renders)) return; + const timer = setTimeout(() => void load().catch(() => undefined), MEDIA_POLL_MS); + return () => clearTimeout(timer); + }, [data, readiness, uploading, load]); + + async function act(variant: MediaVariant, action: () => Promise) { + setBusy(variant.profileId); + try { + await action(); + await load(); + } catch (reason) { + Alert.alert(message(reason)); + } finally { + setBusy(null); + } + } + + const variants = data + ? projectVariants({ + profileIds: data.project.profileIds, + plans: data.plans, + renders: data.renders, + profiles, + }) + : []; + + return ( + + + {error ? {error} : null} + {!data ? : null} + {data && readiness === "uploading" ? ( + uploading ? ( + + + + {t("Uploading {percent}%", { percent: Math.round((uploadProgress ?? 0) * 100) })} + + + ) : ( + void startUpload()} + > + {t("Upload")} + + ) + ) : null} + {data && variants.length === 0 && readiness === "processing" ? ( + + ) : null} + {variants.map((variant) => ( + + void act(variant, () => + mediaRpc("renders/start", { planId: variant.plan.id, quality: "final" }), + ) + } + onShare={() => + void act(variant, async () => { + if (variant.final) await shareRender(variant.final); + }) + } + /> + ))} + {variants.length > 0 ? {t("Edit on web or desktop.")} : null} + + ); +} + +function VariantCard({ + variant, + busy, + onApprove, + onShare, +}: { + variant: MediaVariant; + busy: boolean; + onApprove: () => void; + onShare: () => void; +}) { + const styles = useThemedStyles(createStyles); + const { t } = useI18n(); + const state = variantState(variant); + const progress = Math.round((variant.latest?.progress ?? 0) * 100); + const blocker = blockers(variant.latest)[0]; + const status = + state === "rendering" || state === "approved_rendering" + ? t("Rendering {percent}%", { percent: progress }) + : state === "blocked" + ? (blocker?.message ?? t("Blocked")) + : state === "failed" + ? (variant.latest?.error ?? t("Failed")) + : null; + + return ( + + {variant.profileName} + {variant.preview ? : null} + {status ? ( + + {status} + + ) : null} + {busy ? ( + + ) : canApprove(variant) || canShare(variant) ? ( + + {canApprove(variant) ? ( + + {t("Approve")} + + ) : null} + {canShare(variant) ? ( + + {t("Share")} + + ) : null} + + ) : null} + + ); +} + +function VariantPlayer({ render }: { render: MediaRender }) { + const renderId = render.id; + const fileName = videoOutput(render)?.fileName ?? null; + const [source, setSource] = useState<{ uri: string; headers: Record } | null>( + null, + ); + useEffect(() => { + if (!fileName) return; + let active = true; + void renderVideoSource(renderId, fileName).then((next) => { + if (active) setSource(next); + }); + return () => { + active = false; + }; + }, [renderId, fileName]); + return source ? : null; +} + +function Player({ source }: { source: { uri: string; headers: Record } }) { + const styles = useThemedStyles(createStyles); + const player = useVideoPlayer(source); + return ; +} + +function createStyles() { + const tokens = mobileTokens(); + return StyleSheet.create({ + screen: { flex: 1, backgroundColor: native.page }, + content: { padding: 16, gap: 12 }, + card: { padding: 14, borderRadius: 16, backgroundColor: native.fill, gap: 10 }, + cardTitle: { color: native.label, fontSize: 16, fontWeight: "600" }, + video: { width: "100%", aspectRatio: 9 / 16, maxHeight: 460, borderRadius: 12 }, + status: { color: native.secondaryLabel, fontSize: 14 }, + blocked: { color: tokens.destructive, fontSize: 14 }, + progressRow: { flexDirection: "row", alignItems: "center", gap: 10 }, + actions: { flexDirection: "row", gap: 8 }, + button: { + minHeight: 42, + paddingHorizontal: 16, + borderRadius: 12, + backgroundColor: native.fillPressed, + alignItems: "center", + justifyContent: "center", + }, + buttonLabel: { color: native.label, fontSize: 15, fontWeight: "600" }, + primaryButton: { + minHeight: 48, + borderRadius: 12, + backgroundColor: tokens.primary, + alignItems: "center", + justifyContent: "center", + }, + primaryLabel: { color: tokens.primaryForeground, fontSize: 16, fontWeight: "600" }, + error: { color: tokens.destructive, fontSize: 14 }, + note: { color: native.tertiaryLabel, fontSize: 13, textAlign: "center", marginTop: 4 }, + }); +} diff --git a/apps/mobile/app/media-session.tsx b/apps/mobile/app/media-session.tsx new file mode 100644 index 0000000..44901a9 --- /dev/null +++ b/apps/mobile/app/media-session.tsx @@ -0,0 +1,189 @@ +import type { + MediaAsset, + MediaCollection, + MediaProject, + PlatformProfileSummary, +} from "@rakazo/contracts"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useEffect, useState } from "react"; +import { + ActivityIndicator, + Alert, + FlatList, + Image, + Pressable, + StyleSheet, + Text, + useWindowDimensions, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { NativeSymbol } from "../components/native-symbol"; +import { authHeaders, currentApiBase } from "../lib/api"; +import { mobileTokens } from "../lib/appearance"; +import { useI18n } from "../lib/i18n"; +import { mediaRpc } from "../lib/media-api"; +import { assetThumbUrl, formatDuration } from "../lib/media-project"; +import { presentMessageActionSheet } from "../lib/message-action-sheet"; +import { native, useResolvedAppearance, useThemedStyles } from "../lib/native"; + +const COLUMNS = 3; +const GAP = 2; + +export default function MediaSession() { + const styles = useThemedStyles(createStyles); + const { t } = useI18n(); + const router = useRouter(); + const appearance = useResolvedAppearance(); + const { width } = useWindowDimensions(); + const { collectionId } = useLocalSearchParams<{ collectionId: string }>(); + const [collection, setCollection] = useState(null); + const [assets, setAssets] = useState([]); + const [headers, setHeaders] = useState>({}); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const tile = (width - GAP * (COLUMNS - 1)) / COLUMNS; + + useEffect(() => { + if (!collectionId) return; + void authHeaders().then(setHeaders); + void mediaRpc<{ collection: MediaCollection; assets: MediaAsset[] }>("collections/get", { + collectionId, + }) + .then((result) => { + setCollection(result.collection); + setAssets(result.assets); + }) + .catch((reason) => setError(reason instanceof Error ? reason.message : String(reason))); + }, [collectionId]); + + async function create(profileId: string) { + if (!collection) return; + setCreating(true); + try { + const project = await mediaRpc("projects/create", { + title: collection.title.slice(0, 120) || t("Media"), + goal: "teaser", + collectionIds: [collection.id], + profileIds: [profileId], + }); + router.replace({ + pathname: "/media-project", + params: { projectId: project.id, upload: "1" }, + }); + } catch (reason) { + Alert.alert(reason instanceof Error ? reason.message : String(reason)); + } finally { + setCreating(false); + } + } + + async function chooseProfile() { + try { + const profiles = await mediaRpc("profiles/list"); + if (profiles.length === 1 && profiles[0]) { + await create(profiles[0].id); + return; + } + presentMessageActionSheet({ + actions: profiles.map((profile) => ({ + text: profile.name, + onPress: () => void create(profile.id), + })), + cancel: t("Cancel"), + more: t("More"), + colorScheme: appearance, + }); + } catch (reason) { + Alert.alert(reason instanceof Error ? reason.message : String(reason)); + } + } + + return ( + + + asset.id} + numColumns={COLUMNS} + columnWrapperStyle={{ gap: GAP }} + contentContainerStyle={{ gap: GAP }} + contentInsetAdjustmentBehavior="automatic" + ListHeaderComponent={error ? {error} : null} + renderItem={({ item }) => ( + + {item.hasThumbnail ? ( + + ) : ( + + )} + {item.kind === "video" && item.durationMs ? ( + {formatDuration(item.durationMs)} + ) : null} + + )} + /> + {collection ? ( + + [styles.primaryButton, pressed && styles.pressed]} + onPress={() => void chooseProfile()} + > + {creating ? ( + + ) : ( + {t("Upload and create project")} + )} + + + ) : null} + + ); +} + +function createStyles() { + const tokens = mobileTokens(); + return StyleSheet.create({ + screen: { flex: 1, backgroundColor: native.page }, + tile: { + backgroundColor: native.fill, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + }, + duration: { + position: "absolute", + right: 6, + bottom: 4, + color: tokens.foreground, + fontSize: 12, + fontWeight: "600", + fontVariant: ["tabular-nums"], + textShadowColor: tokens.background, + textShadowRadius: 3, + }, + footer: { paddingHorizontal: 16, paddingTop: 8, backgroundColor: native.page }, + primaryButton: { + minHeight: 50, + borderRadius: 14, + backgroundColor: tokens.primary, + alignItems: "center", + justifyContent: "center", + marginBottom: 8, + }, + pressed: { opacity: 0.8 }, + primaryLabel: { color: tokens.primaryForeground, fontSize: 16, fontWeight: "600" }, + error: { color: tokens.destructive, fontSize: 14, padding: 16 }, + }); +} diff --git a/apps/mobile/app/media.tsx b/apps/mobile/app/media.tsx new file mode 100644 index 0000000..15244dd --- /dev/null +++ b/apps/mobile/app/media.tsx @@ -0,0 +1,241 @@ +import type { MediaCollection, MediaProject } from "@rakazo/contracts"; +import { Stack, useRouter } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, + Linking, + Pressable, + RefreshControl, + SectionList, + StyleSheet, + Text, + View, +} from "react-native"; +import { NativeSymbol } from "../components/native-symbol"; +import { mobileTokens } from "../lib/appearance"; +import { useI18n } from "../lib/i18n"; +import { + changeLimitedSelection, + mediaRpc, + readLibraryAccess, + requestLibraryAccess, + syncDeviceLibrary, +} from "../lib/media-api"; +import type { LibraryAccess } from "../lib/media-project"; +import { formatDuration } from "../lib/media-project"; +import { native, useThemedStyles } from "../lib/native"; + +type Row = + | { kind: "project"; project: MediaProject } + | { kind: "session"; collection: MediaCollection }; + +export default function MediaLibrary() { + const styles = useThemedStyles(createStyles); + const { t } = useI18n(); + const router = useRouter(); + const [access, setAccess] = useState(null); + const [sessions, setSessions] = useState([]); + const [projects, setProjects] = useState([]); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + + const loadLists = useCallback(async () => { + const [sessionPage, projectPage] = await Promise.all([ + mediaRpc<{ items: MediaCollection[] }>("collections/list", { kind: "session", limit: 200 }), + mediaRpc<{ items: MediaProject[] }>("projects/list", { limit: 50 }), + ]); + setSessions(sessionPage.items); + setProjects(projectPage.items.filter((project) => project.status !== "archived")); + }, []); + + const refresh = useCallback( + async (current: LibraryAccess | null) => { + setError(null); + try { + if (current === "all" || current === "limited") { + setSyncing(true); + await syncDeviceLibrary(); + } + await loadLists(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setSyncing(false); + } + }, + [loadLists], + ); + + useEffect(() => { + void readLibraryAccess() + .catch(() => "denied" as const) + .then((current) => { + setAccess(current); + return refresh(current); + }); + }, [refresh]); + + async function allow() { + if (access === "blocked") { + await Linking.openSettings(); + return; + } + const next = await requestLibraryAccess(); + setAccess(next); + await refresh(next); + } + + async function chooseItems() { + await changeLimitedSelection().catch(() => undefined); + await refresh(access); + } + + const sections = [ + ...(projects.length + ? [ + { + title: t("Projects"), + data: projects.map((project): Row => ({ kind: "project", project })), + }, + ] + : []), + { + title: t("Sessions"), + data: sessions.map((collection): Row => ({ kind: "session", collection })), + }, + ]; + const needsAccess = access !== null && access !== "all" && access !== "limited"; + + return ( + + + syncing ? ( + + ) : access === "limited" ? ( + void chooseItems()} + > + + + ) : null, + }} + /> + (row.kind === "project" ? row.project.id : row.collection.id)} + contentContainerStyle={styles.content} + contentInsetAdjustmentBehavior="automatic" + stickySectionHeadersEnabled={false} + refreshControl={ + void refresh(access)} /> + } + ListHeaderComponent={ + needsAccess ? ( + void allow()} + > + + {access === "blocked" ? t("Open Settings") : t("Allow photo access")} + + + ) : error ? ( + {error} + ) : null + } + renderSectionHeader={({ section }) => + // A lone sessions list needs no heading. + sections.length > 1 ? {section.title} : null + } + renderItem={({ item }) => + item.kind === "project" ? ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => + router.push({ pathname: "/media-project", params: { projectId: item.project.id } }) + } + > + + {item.project.title} + + + ) : ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => + router.push({ + pathname: "/media-session", + params: { collectionId: item.collection.id }, + }) + } + > + + {item.collection.title} + + + {item.collection.videoCount > 0 + ? `${t("{count} items", { count: item.collection.assetCount })} ยท ${formatDuration( + item.collection.totalDurationMs, + )}` + : t("{count} items", { count: item.collection.assetCount })} + + + ) + } + renderSectionFooter={({ section }) => + section.data.length === 0 && !syncing && !needsAccess ? ( + {t("No sessions yet")} + ) : null + } + /> + + ); +} + +function createStyles() { + const tokens = mobileTokens(); + return StyleSheet.create({ + screen: { flex: 1, backgroundColor: native.page }, + content: { padding: 16, gap: 8 }, + sectionTitle: { + color: native.secondaryLabel, + fontSize: 13, + fontWeight: "600", + marginTop: 12, + marginBottom: 6, + }, + row: { + minHeight: 56, + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 12, + backgroundColor: native.fill, + justifyContent: "center", + gap: 2, + marginBottom: 8, + }, + rowPressed: { backgroundColor: native.fillPressed }, + rowTitle: { color: native.label, fontSize: 16, fontWeight: "500" }, + rowDetail: { color: native.secondaryLabel, fontSize: 13 }, + primaryButton: { + minHeight: 48, + borderRadius: 12, + backgroundColor: tokens.primary, + alignItems: "center", + justifyContent: "center", + marginBottom: 8, + }, + primaryLabel: { color: tokens.primaryForeground, fontSize: 16, fontWeight: "600" }, + error: { color: tokens.destructive, fontSize: 14, marginBottom: 8 }, + empty: { color: native.tertiaryLabel, fontSize: 14, paddingVertical: 8 }, + }); +} diff --git a/apps/mobile/lib/media-api.ts b/apps/mobile/lib/media-api.ts new file mode 100644 index 0000000..7b15ceb --- /dev/null +++ b/apps/mobile/lib/media-api.ts @@ -0,0 +1,374 @@ +import type { + MediaAsset, + MediaCapabilities, + MediaRender, + MediaSource, + MediaUpload, +} from "@rakazo/contracts"; +import { fetch as expoFetch } from "expo/fetch"; +import * as Application from "expo-application"; +import { File, Paths } from "expo-file-system"; +import { + AssetField, + getPermissionsAsync, + Asset as LibraryAsset, + MediaType, + presentPermissionsPicker, + Query, + requestPermissionsAsync, +} from "expo-media-library"; +import * as Notifications from "expo-notifications"; +import { useRouter } from "expo-router"; +import * as SecureStore from "expo-secure-store"; +import * as Sharing from "expo-sharing"; +import { useEffect, useState } from "react"; +import { Platform } from "react-native"; +import { authHeaders, currentApiBase, rpc, selectedSpaceId } from "./api"; +import { t } from "./i18n"; +import type { DeviceAsset, PushInput, PushResult } from "./media-index"; +import { syncDeviceIndex } from "./media-index"; +import type { LibraryAccess } from "./media-project"; +import { + libraryAccess, + mediaNotificationRoute, + renderFileUrl, + shareFileName, + uploadsFromDevice, + videoOutput, +} from "./media-project"; +import type { ChunkBytes, UploadTransport } from "./media-upload"; +import { + combinedProgress, + hashOriginal, + MediaUploadHttpError, + uploadOriginal, +} from "./media-upload"; + +/* + * Native glue for Media Studio on mobile: RPC calls, the media library, the + * chunk route, sharing, and push routing. Decisions live in the pure + * media-index, media-upload, and media-project modules. + */ + +export function mediaRpc( + proc: string, + body: unknown = {}, + options?: { timeoutMs?: number | null; signal?: AbortSignal }, +): Promise { + return rpc(`media/${proc}`, body, options); +} + +/** Media appears only when the server reports Media Studio configured. */ +export function useMediaAvailable(): boolean { + const [available, setAvailable] = useState(false); + useEffect(() => { + let active = true; + void mediaRpc("capabilities") + .then((capabilities) => { + if (active) setAvailable(capabilities.configured); + }) + .catch(() => { + if (active) setAvailable(false); + }); + return () => { + active = false; + }; + }, []); + return available; +} + +// --------------------------------------------------------------------------- +// Library permission +// --------------------------------------------------------------------------- + +const LIBRARY_PERMISSIONS = ["photo", "video"] as const; + +export async function readLibraryAccess(): Promise { + return libraryAccess(await getPermissionsAsync(false, [...LIBRARY_PERMISSIONS])); +} + +export async function requestLibraryAccess(): Promise { + return libraryAccess(await requestPermissionsAsync(false, [...LIBRARY_PERMISSIONS])); +} + +/** With limited access, let the user change which items the app may see. */ +export async function changeLimitedSelection(): Promise { + await presentPermissionsPicker(["photo", "video"]); +} + +// --------------------------------------------------------------------------- +// Device index +// --------------------------------------------------------------------------- + +async function deviceId(): Promise { + if (Platform.OS === "ios") { + const id = await Application.getIosIdForVendorAsync(); + if (id) return `ios:${id}`; + } else if (Platform.OS === "android") { + return `android:${Application.getAndroidId()}`; + } + throw new Error(t("Failed")); +} + +function storeKey(sourceId: string): string { + const scope = `${selectedSpaceId() ?? "default"}.${sourceId}`.replace(/[^A-Za-z0-9._-]/g, "_"); + return `rakazo.media.sync.${scope}`; +} + +async function readSyncToken(sourceId: string): Promise { + try { + return (await SecureStore.getItemAsync(storeKey(sourceId))) || null; + } catch { + return null; + } +} + +async function writeSyncToken(sourceId: string, token: string | null): Promise { + try { + if (token) await SecureStore.setItemAsync(storeKey(sourceId), token); + } catch { + // The next sync then pushes everything again; the server reports it unchanged. + } +} + +export async function registerDeviceSource(): Promise { + return mediaRpc("sources/registerClient", { + kind: "ios_library", + label: Platform.OS === "ios" ? "iPhone" : "Android", + deviceId: await deviceId(), + }); +} + +async function listDevice(offset: number, limit: number): Promise { + const rows = await new Query() + .within(AssetField.MEDIA_TYPE, [MediaType.IMAGE, MediaType.VIDEO]) + .orderBy({ key: AssetField.CREATION_TIME, ascending: true }) + .offset(offset) + .limit(limit) + .exeForMetadata(); + return rows.map((row) => ({ + id: row.id, + filename: row.filename, + mediaType: row.mediaType, + width: row.width, + height: row.height, + duration: row.duration, + creationTime: row.creationTime, + modificationTime: row.modificationTime, + isFavorite: row.isFavorite, + })); +} + +async function serverExternalIds(sourceId: string): Promise { + const ids: string[] = []; + let cursor: string | null = null; + do { + const page: { items: MediaAsset[]; nextCursor: string | null } = await mediaRpc( + "assets/search", + { sourceId, cursor, limit: 500 }, + ); + for (const asset of page.items) ids.push(asset.externalId); + cursor = page.nextCursor; + } while (cursor); + return ids; +} + +let syncing: Promise | null = null; + +/** Index the device library into its source. One sync runs at a time. */ +export function syncDeviceLibrary(): Promise { + syncing ??= (async () => { + const source = await registerDeviceSource(); + const result = await syncDeviceIndex({ + sourceId: source.id, + syncToken: await readSyncToken(source.id), + io: { + listDevice, + locate: (id) => new LibraryAsset(id).getLocation(), + push: (input: PushInput) => + mediaRpc("sources/pushItems", input, { timeoutMs: 60_000 }), + serverAssetCount: async () => { + const sources = await mediaRpc("sources/list"); + return sources.find((entry) => entry.id === source.id)?.assetCount ?? 0; + }, + serverExternalIds: () => serverExternalIds(source.id), + }, + }); + await writeSyncToken(source.id, result.syncToken); + return source; + })().finally(() => { + syncing = null; + }); + return syncing; +} + +// --------------------------------------------------------------------------- +// Uploads +// --------------------------------------------------------------------------- + +async function rpcUploadCall(proc: string, body: unknown, timeoutMs?: number): Promise { + try { + return await mediaRpc(proc, body, timeoutMs ? { timeoutMs } : undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/expired/i.test(message)) throw new MediaUploadHttpError(409, message); + throw error; + } +} + +async function putChunk( + uploadId: string, + index: number, + bytes: ChunkBytes, + signal?: AbortSignal, +): Promise { + const url = `${currentApiBase()}/api/media/uploads/${encodeURIComponent(uploadId)}/chunks/${index}`; + const response = await expoFetch(url, { + method: "PUT", + headers: { + ...(await authHeaders()), + origin: "rakazo://", + "content-type": "application/octet-stream", + }, + body: bytes, + signal, + }); + const body = (await response.json().catch(() => null)) as + | (MediaUpload & { error?: undefined }) + | { error?: string } + | null; + if (!response.ok) { + const message = (body && "error" in body && body.error) || `Upload failed (${response.status})`; + throw new MediaUploadHttpError(response.status, message); + } + return body as MediaUpload; +} + +const transport: UploadTransport = { + begin: (input) => rpcUploadCall("uploads/begin", input), + status: (uploadId) => rpcUploadCall("uploads/status", { uploadId }), + putChunk, + complete: (uploadId) => rpcUploadCall("uploads/complete", { uploadId }, 10 * 60_000), +}; + +/** Hashes are slow on large videos; keep them for retries within this session. */ +const hashes = new Map(); + +async function uploadOne( + asset: MediaAsset, + onProgress: (fraction: number) => void, + signal?: AbortSignal, +): Promise { + const original = new LibraryAsset(asset.externalId); + const file = new File(await original.getUri()); + const sizeBytes = file.size; + if (!sizeBytes) throw new Error("Original not available"); + const fileName = (await original.getFilename()) || asset.externalId; + const handle = file.open(); + try { + const read = (start: number, length: number) => { + handle.offset = start; + return handle.readBytes(length); + }; + const key = `${asset.id}:${sizeBytes}`; + let sha256 = hashes.get(key); + if (!sha256) { + sha256 = await hashOriginal(sizeBytes, read, { + signal, + onProgress: (fraction) => onProgress(fraction * 0.1), + }); + hashes.set(key, sha256); + } + await uploadOriginal({ + assetId: asset.id, + fileName, + sizeBytes, + sha256, + read, + transport, + signal, + onProgress: (fraction) => onProgress(0.1 + fraction * 0.9), + }); + } finally { + handle.close(); + } +} + +/** Upload every listed item that still waits for its original. Returns the ids that failed. */ +export async function uploadAssets( + assets: MediaAsset[], + deviceSourceId: string, + onProgress: (fraction: number) => void, + signal?: AbortSignal, +): Promise { + const pending = uploadsFromDevice(assets, deviceSourceId); + const progress = pending.map(() => ({ sizeBytes: 1, fraction: 0 })); + const failed: string[] = []; + for (const [index, asset] of pending.entries()) { + signal?.throwIfAborted(); + try { + await uploadOne( + asset, + (fraction) => { + const entry = progress[index]; + if (entry) entry.fraction = fraction; + onProgress(combinedProgress(progress)); + }, + signal, + ); + } catch (error) { + if (signal?.aborted) throw error; + failed.push(asset.id); + } + const entry = progress[index]; + if (entry) entry.fraction = 1; + onProgress(combinedProgress(progress)); + } + return failed; +} + +// --------------------------------------------------------------------------- +// Playback and sharing +// --------------------------------------------------------------------------- + +/** Authenticated source for expo-video. */ +export async function renderVideoSource( + renderId: string, + fileName: string, +): Promise<{ uri: string; headers: Record }> { + return { + uri: renderFileUrl(currentApiBase(), renderId, fileName), + headers: { ...(await authHeaders()), origin: "rakazo://" }, + }; +} + +/** Download a final render and open the native share sheet. */ +export async function shareRender(render: MediaRender): Promise { + const video = videoOutput(render); + if (!video || render.status !== "done") return; + const target = new File(Paths.cache, shareFileName(render.id, video.fileName)); + const file = await File.downloadFileAsync( + renderFileUrl(currentApiBase(), render.id, video.fileName, true), + target, + { headers: { ...(await authHeaders()), origin: "rakazo://" }, idempotent: true }, + ); + await Sharing.shareAsync(file.uri, { mimeType: "video/mp4" }); +} + +// --------------------------------------------------------------------------- +// Push +// --------------------------------------------------------------------------- + +/** Open the project when the user taps a render notification. Mount once in the root layout. */ +export function useMediaNotificationRouting(): void { + const router = useRouter(); + useEffect(() => { + const open = (response: Notifications.NotificationResponse | null) => { + const route = mediaNotificationRoute(response?.notification.request.content.data); + if (route) router.push(route); + }; + open(Notifications.getLastNotificationResponse()); + const subscription = Notifications.addNotificationResponseReceivedListener(open); + return () => subscription.remove(); + }, [router]); +} diff --git a/apps/mobile/lib/media-index.test.ts b/apps/mobile/lib/media-index.test.ts new file mode 100644 index 0000000..1fe47b9 --- /dev/null +++ b/apps/mobile/lib/media-index.test.ts @@ -0,0 +1,215 @@ +import { mediaRpcShapes } from "@rakazo/contracts"; +import { describe, expect, it, vi } from "vitest"; +import type { DeviceAsset, DeviceIndexIo, PushInput } from "./media-index"; +import { + chunked, + parseSyncToken, + roundCoordinate, + syncDeviceIndex, + toClientMediaItem, +} from "./media-index"; + +function asset(id: string, overrides: Partial = {}): DeviceAsset { + return { + id, + filename: `${id}.mov`, + mediaType: "video", + width: 1920, + height: 1080, + duration: 12_345, + creationTime: 1_700_000_000_000, + modificationTime: 1_700_000_000_000, + isFavorite: false, + ...overrides, + }; +} + +/** Device library plus a server that keeps what was pushed, like `sources.pushItems`. */ +function harness(device: DeviceAsset[], serverIds: string[] = []) { + const server = new Set(serverIds); + const pushes: PushInput[] = []; + const io = { + listDevice: vi.fn(async (offset: number, limit: number) => + device.slice(offset, offset + limit), + ), + locate: vi.fn(async () => ({ latitude: 52.520_08, longitude: 13.404_95 })), + push: vi.fn(async (input: PushInput) => { + // Every push must satisfy the RPC contract. + mediaRpcShapes.sources.pushItems.input.parse(input); + pushes.push(input); + for (const item of input.items) server.add(item.externalId); + for (const id of input.deletedExternalIds) server.delete(id); + return { created: input.items.length, updated: 0, unchanged: 0, deleted: 0 }; + }), + serverAssetCount: vi.fn(async () => server.size), + serverExternalIds: vi.fn(async () => [...server]), + } satisfies DeviceIndexIo; + return { io, server, pushes }; +} + +describe("toClientMediaItem", () => { + it("maps videos with duration and rounded location", () => { + const item = toClientMediaItem(asset("v1"), { latitude: 52.520_08, longitude: 13.404_95 }); + expect(item).toEqual({ + externalId: "v1", + kind: "video", + capturedAt: "2023-11-14T22:13:20.000Z", + durationMs: 12_345, + width: 1920, + height: 1080, + fps: null, + gpsLat: 52.52, + gpsLon: 13.4, + deviceModel: null, + albumNames: [], + isFavorite: false, + fileName: "v1.mov", + }); + }); + + it("maps photos without a duration", () => { + const item = toClientMediaItem( + asset("p1", { mediaType: "image", duration: 0, filename: "p1.heic", isFavorite: true }), + null, + ); + expect(item).toMatchObject({ kind: "photo", durationMs: null, gpsLat: null, isFavorite: true }); + }); + + it("skips audio and unknown entries", () => { + expect(toClientMediaItem(asset("a1", { mediaType: "audio" }), null)).toBeNull(); + expect(toClientMediaItem(asset("u1", { mediaType: "unknown" }), null)).toBeNull(); + }); + + it("drops missing or invalid metadata instead of sending it", () => { + const item = toClientMediaItem( + asset("v2", { width: null, height: 0, creationTime: null, filename: null }), + { latitude: 0, longitude: 0 }, + ); + expect(item).toMatchObject({ + width: null, + height: null, + capturedAt: null, + fileName: null, + gpsLat: null, + gpsLon: null, + }); + expect(toClientMediaItem(asset("v3"), { latitude: 120, longitude: 10 })?.gpsLat).toBeNull(); + }); + + it("rounds coordinates to about a kilometer", () => { + expect(roundCoordinate(48.137_15)).toBe(48.14); + expect(roundCoordinate(-33.868_82)).toBe(-33.87); + }); +}); + +describe("helpers", () => { + it("parses only numeric sync tokens", () => { + expect(parseSyncToken(null)).toBeNull(); + expect(parseSyncToken("abc")).toBeNull(); + expect(parseSyncToken("1700000000000")).toBe(1_700_000_000_000); + }); + + it("chunks lists", () => { + expect(chunked([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + expect(chunked([], 2)).toEqual([]); + }); +}); + +describe("syncDeviceIndex", () => { + it("indexes photos and videos on the first sync and completes", async () => { + const device = [ + asset("v1"), + asset("p1", { mediaType: "image", modificationTime: 1_700_000_500_000 }), + asset("a1", { mediaType: "audio" }), + ]; + const { io, server, pushes } = harness(device); + const result = await syncDeviceIndex({ sourceId: "src1", syncToken: null, io }); + expect([...server].sort()).toEqual(["p1", "v1"]); + expect(result).toEqual({ scanned: 2, pushed: 2, deleted: 0, syncToken: "1700000500000" }); + const last = pushes.at(-1); + expect(last).toMatchObject({ complete: true, syncToken: "1700000500000", items: [] }); + expect(pushes.slice(0, -1).every((push) => !push.complete && push.syncToken === null)).toBe( + true, + ); + }); + + it("pages through large libraries within the push limit", async () => { + const device = Array.from({ length: 1_203 }, (_, index) => + asset(`v${index}`, { modificationTime: 1_700_000_000_000 + index }), + ); + const { io, server, pushes } = harness(device); + await syncDeviceIndex({ sourceId: "src1", syncToken: null, io }); + expect(server.size).toBe(1_203); + expect(io.listDevice).toHaveBeenCalledTimes(3); + expect(pushes.every((push) => push.items.length <= 500)).toBe(true); + }); + + it("pushes only items changed since the token when nothing else differs", async () => { + const device = [ + asset("old", { modificationTime: 1_000, creationTime: 1_000 }), + asset("new", { modificationTime: 3_000, creationTime: 3_000 }), + ]; + const { io, pushes } = harness(device, ["old"]); + const result = await syncDeviceIndex({ sourceId: "src1", syncToken: "2000", io }); + expect(pushes.flatMap((push) => push.items.map((item) => item.externalId))).toEqual(["new"]); + expect(io.serverExternalIds).not.toHaveBeenCalled(); + expect(result.syncToken).toBe("3000"); + }); + + it("sends no items when nothing changed and keeps the token", async () => { + const device = [asset("v1", { modificationTime: 1_000, creationTime: 1_000 })]; + const { io, pushes } = harness(device, ["v1"]); + const result = await syncDeviceIndex({ sourceId: "src1", syncToken: "1000", io }); + expect(pushes).toHaveLength(1); + expect(pushes[0]).toMatchObject({ items: [], deletedExternalIds: [], complete: true }); + expect(result.syncToken).toBe("1000"); + expect(io.locate).not.toHaveBeenCalled(); + }); + + it("reports items deleted on the device", async () => { + const device = [asset("keep", { modificationTime: 1_000, creationTime: 1_000 })]; + const { io, server } = harness(device, ["keep", "gone1", "gone2"]); + const result = await syncDeviceIndex({ sourceId: "src1", syncToken: "1000", io }); + expect([...server]).toEqual(["keep"]); + expect(result.deleted).toBe(2); + }); + + it("adds old items newly shared through limited access", async () => { + const device = [ + asset("known", { modificationTime: 1_000, creationTime: 1_000 }), + asset("shared", { modificationTime: 500, creationTime: 500 }), + ]; + const { io, server } = harness(device, ["known"]); + await syncDeviceIndex({ sourceId: "src1", syncToken: "1000", io }); + expect([...server].sort()).toEqual(["known", "shared"]); + }); + + it("splits large deletions and completes only on the last push", async () => { + const serverIds = Array.from({ length: 6_000 }, (_, index) => `gone${index}`); + const { io, pushes, server } = harness([], serverIds); + const result = await syncDeviceIndex({ sourceId: "src1", syncToken: null, io }); + expect(server.size).toBe(0); + expect(result.deleted).toBe(6_000); + expect(pushes.map((push) => [push.deletedExternalIds.length, push.complete])).toEqual([ + [5_000, false], + [1_000, true], + ]); + }); + + it("indexes without location when it is not permitted", async () => { + const { io, pushes } = harness([asset("v1")]); + io.locate.mockRejectedValue(new Error("ACCESS_MEDIA_LOCATION")); + await syncDeviceIndex({ sourceId: "src1", syncToken: null, io }); + expect(pushes[0]?.items[0]).toMatchObject({ gpsLat: null, gpsLon: null }); + }); + + it("stops when aborted", async () => { + const { io } = harness([asset("v1")]); + const controller = new AbortController(); + controller.abort(new Error("stopped")); + await expect( + syncDeviceIndex({ sourceId: "src1", syncToken: null, io, signal: controller.signal }), + ).rejects.toThrow("stopped"); + expect(io.push).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/lib/media-index.ts b/apps/mobile/lib/media-index.ts new file mode 100644 index 0000000..629f647 --- /dev/null +++ b/apps/mobile/lib/media-index.ts @@ -0,0 +1,209 @@ +import type { ClientMediaItem } from "@rakazo/contracts"; + +/* + * Index of the device media library (photos and videos) pushed to the + * `ios_library` source through `sources.pushItems`. Only metadata leaves the + * device here; originals upload later, per session, when the user asks. + */ + +/** Lightweight library metadata as expo-media-library reports it. */ +export type DeviceAsset = { + id: string; + filename: string | null; + mediaType: string; + width: number | null; + height: number | null; + /** Milliseconds. */ + duration: number | null; + /** Unix milliseconds. */ + creationTime: number | null; + modificationTime: number | null; + isFavorite: boolean; +}; + +export type DeviceLocation = { latitude: number; longitude: number }; + +export type PushInput = { + sourceId: string; + items: ClientMediaItem[]; + deletedExternalIds: string[]; + syncToken: string | null; + complete: boolean; +}; + +export type PushResult = { created: number; updated: number; unchanged: number; deleted: number }; + +export const PUSH_ITEMS_MAX = 500; +export const PUSH_DELETIONS_MAX = 5000; + +/** Coordinates leave the device rounded to two decimals (about 1 km). */ +export function roundCoordinate(value: number): number { + return Math.round(value * 100) / 100; +} + +function validCoordinate(value: number, limit: number): boolean { + return Number.isFinite(value) && Math.abs(value) <= limit; +} + +function positiveInt(value: number | null): number | null { + return value !== null && Number.isFinite(value) && value > 0 ? Math.round(value) : null; +} + +function isoOrNull(ms: number | null): string | null { + if (ms === null || !Number.isFinite(ms) || ms <= 0) return null; + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +/** Photos and videos only; audio and unknown entries are not media for this index. */ +export function isIndexable(asset: Pick): boolean { + return asset.mediaType === "image" || asset.mediaType === "video"; +} + +export function toClientMediaItem( + asset: DeviceAsset, + location: DeviceLocation | null, +): ClientMediaItem | null { + if (!isIndexable(asset) || !asset.id || asset.id.length > 200) return null; + const hasLocation = + location !== null && + validCoordinate(location.latitude, 90) && + validCoordinate(location.longitude, 180) && + !(location.latitude === 0 && location.longitude === 0); + const video = asset.mediaType === "video"; + return { + externalId: asset.id, + kind: video ? "video" : "photo", + capturedAt: isoOrNull(asset.creationTime), + durationMs: video ? positiveInt(asset.duration) : null, + width: positiveInt(asset.width), + height: positiveInt(asset.height), + fps: null, + gpsLat: hasLocation ? roundCoordinate(location.latitude) : null, + gpsLon: hasLocation ? roundCoordinate(location.longitude) : null, + deviceModel: null, + albumNames: [], + isFavorite: asset.isFavorite, + fileName: asset.filename ? asset.filename.slice(0, 255) : null, + }; +} + +/** The sync token is the newest change time seen; later syncs push only newer changes. */ +export function changeTime(asset: DeviceAsset): number { + return Math.max(asset.modificationTime ?? 0, asset.creationTime ?? 0); +} + +export function parseSyncToken(token: string | null): number | null { + if (!token || !/^[0-9]{1,16}$/.test(token)) return null; + return Number(token); +} + +export function chunked(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let index = 0; index < items.length; index += size) + out.push(items.slice(index, index + size)); + return out; +} + +export type DeviceIndexIo = { + /** One page of library metadata in a stable order; fewer than `limit` ends the scan. */ + listDevice(offset: number, limit: number): Promise; + /** Location of one asset, or null when unknown or not permitted. */ + locate(id: string): Promise; + push(input: PushInput): Promise; + /** How many assets the server holds for this source. */ + serverAssetCount(): Promise; + /** Every externalId the server holds for this source. */ + serverExternalIds(): Promise; +}; + +export type DeviceIndexResult = { + scanned: number; + pushed: number; + deleted: number; + syncToken: string | null; +}; + +/** + * Bring the server index in line with the device library. Pushes items changed + * since `syncToken`; when the server count then differs from the device count + * (deletions, or items newly shared through limited access), reconciles by id. + */ +export async function syncDeviceIndex(input: { + sourceId: string; + syncToken: string | null; + io: DeviceIndexIo; + pageSize?: number; + signal?: AbortSignal; + onProgress?: (scanned: number) => void; +}): Promise { + const { io, signal } = input; + const pageSize = Math.min(input.pageSize ?? PUSH_ITEMS_MAX, PUSH_ITEMS_MAX); + const since = parseSyncToken(input.syncToken); + + const device = new Map(); + for (let offset = 0; ; offset += pageSize) { + signal?.throwIfAborted(); + const page = await io.listDevice(offset, pageSize); + for (const asset of page) if (isIndexable(asset)) device.set(asset.id, asset); + input.onProgress?.(device.size); + if (page.length < pageSize) break; + } + + let newest = since ?? 0; + for (const asset of device.values()) newest = Math.max(newest, changeTime(asset)); + const syncToken = newest > 0 ? String(newest) : null; + + let pushed = 0; + const sent = new Set(); + const pushAssets = async (assets: DeviceAsset[]) => { + for (const batch of chunked(assets, pageSize)) { + signal?.throwIfAborted(); + const items: ClientMediaItem[] = []; + for (const asset of batch) { + const location = await io.locate(asset.id).catch(() => null); + const item = toClientMediaItem(asset, location); + if (item) items.push(item); + sent.add(asset.id); + } + if (items.length === 0) continue; + await io.push({ + sourceId: input.sourceId, + items, + deletedExternalIds: [], + syncToken: null, + complete: false, + }); + pushed += items.length; + } + }; + + const changed = [...device.values()].filter( + (asset) => since === null || changeTime(asset) > since, + ); + await pushAssets(changed); + + let deletions: string[] = []; + if ((await io.serverAssetCount()) !== device.size) { + const server = new Set(await io.serverExternalIds()); + await pushAssets( + [...device.values()].filter((asset) => !server.has(asset.id) && !sent.has(asset.id)), + ); + deletions = [...server].filter((id) => !device.has(id)); + } + + const deletionBatches = chunked(deletions, PUSH_DELETIONS_MAX); + if (deletionBatches.length === 0) deletionBatches.push([]); + for (const [index, batch] of deletionBatches.entries()) { + signal?.throwIfAborted(); + const last = index === deletionBatches.length - 1; + await io.push({ + sourceId: input.sourceId, + items: [], + deletedExternalIds: batch, + syncToken: last ? syncToken : null, + complete: last, + }); + } + return { scanned: device.size, pushed, deleted: deletions.length, syncToken }; +} diff --git a/apps/mobile/lib/media-project.test.ts b/apps/mobile/lib/media-project.test.ts new file mode 100644 index 0000000..5ed7ce2 --- /dev/null +++ b/apps/mobile/lib/media-project.test.ts @@ -0,0 +1,299 @@ +import type { MediaEditPlan, MediaRender } from "@rakazo/contracts"; +import { describe, expect, it } from "vitest"; +import { + assetThumbUrl, + blockers, + canApprove, + canShare, + formatDuration, + isRenderActive, + libraryAccess, + mediaNotificationRoute, + projectReadiness, + projectVariants, + renderFileUrl, + shareFileName, + shouldPollProject, + uploadsFromDevice, + variantState, +} from "./media-project"; + +function plan( + id: string, + profileId: string, + version: number, + createdAt = "2026-01-01T00:00:00.000Z", +) { + return { id, profileId, version, createdAt } as MediaEditPlan; +} + +function render(id: string, planId: string, overrides: Partial = {}): MediaRender { + return { + id, + planId, + projectId: "p1", + profileId: "example-short-vertical", + quality: "draft", + status: "done", + progress: 1, + outputs: [{ kind: "video", artifactId: null, fileName: "video.mp4", sizeBytes: 10 }], + gateReport: null, + ffprobe: null, + error: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +const profiles = [{ id: "short", name: "Short" }]; + +function variantFor(renders: MediaRender[]) { + const [variant] = projectVariants({ + profileIds: ["short"], + plans: [plan("plan1", "short", 1)], + renders, + profiles, + }); + if (!variant) throw new Error("no variant"); + return variant; +} + +describe("projectVariants", () => { + it("uses the newest plan version per profile and its renders only", () => { + const variants = projectVariants({ + profileIds: ["short", "wide", "missing"], + plans: [plan("a1", "short", 1), plan("a2", "short", 2), plan("b1", "wide", 1)], + renders: [ + render("r-old", "a1", { createdAt: "2026-01-02T00:00:00.000Z" }), + render("r-new", "a2"), + ], + profiles, + }); + expect( + variants.map((variant) => [variant.profileId, variant.profileName, variant.plan.id]), + ).toEqual([ + ["short", "Short", "a2"], + ["wide", "wide", "b1"], + ]); + expect(variants[0]?.latest?.id).toBe("r-new"); + expect(variants[1]?.latest).toBeNull(); + }); + + it("previews the final render once it is done, else the draft", () => { + const draft = render("d", "plan1", { createdAt: "2026-01-01T00:00:00.000Z" }); + const finalRunning = render("f", "plan1", { + quality: "final", + status: "rendering", + outputs: [], + createdAt: "2026-01-02T00:00:00.000Z", + }); + expect(variantFor([draft, finalRunning]).preview?.id).toBe("d"); + const finalDone = { ...finalRunning, status: "done" as const, outputs: draft.outputs }; + expect(variantFor([draft, finalDone]).preview?.id).toBe("f"); + }); +}); + +describe("variant state and actions", () => { + it("is planned without renders and can be approved", () => { + const variant = variantFor([]); + expect(variantState(variant)).toBe("planned"); + expect(canApprove(variant)).toBe(true); + expect(canShare(variant)).toBe(false); + }); + + it("is rendering while a draft runs and cannot be approved", () => { + const variant = variantFor([render("d", "plan1", { status: "gating", progress: 0.9 })]); + expect(variantState(variant)).toBe("rendering"); + expect(canApprove(variant)).toBe(false); + }); + + it("approves a finished draft", () => { + const variant = variantFor([render("d", "plan1")]); + expect(variantState(variant)).toBe("draft_ready"); + expect(canApprove(variant)).toBe(true); + expect(canShare(variant)).toBe(false); + }); + + it("shares only a finished final render", () => { + const running = variantFor([render("f", "plan1", { quality: "final", status: "queued" })]); + expect(variantState(running)).toBe("approved_rendering"); + expect(canShare(running)).toBe(false); + const done = variantFor([render("f", "plan1", { quality: "final" })]); + expect(variantState(done)).toBe("ready"); + expect(canShare(done)).toBe(true); + expect(canApprove(done)).toBe(false); + }); + + it("never shares a blocked render and lists its blockers", () => { + const blocked = render("f", "plan1", { + quality: "final", + status: "blocked", + gateReport: { + profileId: "short", + passed: false, + checkedAt: "2026-01-01T00:00:00.000Z", + sampledFrames: 10, + autoFixRound: 3, + issues: [ + { + check: "consent", + severity: "block", + code: "consent_missing", + message: "Consent missing", + }, + { check: "text", severity: "warn", code: "long", message: "Long caption" }, + ], + }, + }); + const variant = variantFor([blocked]); + expect(variantState(variant)).toBe("blocked"); + expect(canShare(variant)).toBe(false); + expect(canApprove(variant)).toBe(false); + expect(blockers(variant.latest).map((issue) => issue.code)).toEqual(["consent_missing"]); + }); + + it("allows another try after a failure", () => { + const variant = variantFor([render("d", "plan1", { status: "failed", outputs: [] })]); + expect(variantState(variant)).toBe("failed"); + expect(canApprove(variant)).toBe(true); + }); +}); + +describe("polling", () => { + it("polls only while a render is queued, rendering, or gating", () => { + expect(isRenderActive(null)).toBe(false); + for (const status of ["queued", "rendering", "gating"] as const) { + expect(shouldPollProject([render("r", "p", { status })])).toBe(true); + } + for (const status of ["done", "blocked", "failed", "canceled"] as const) { + expect(shouldPollProject([render("r", "p", { status })])).toBe(false); + } + }); +}); + +describe("projectReadiness", () => { + const device = "src-phone"; + + it("waits for this device's uploads first", () => { + expect( + projectReadiness( + [ + { sourceId: device, storage: "remote", ingestState: "awaiting_upload" }, + { sourceId: device, storage: "uploaded", ingestState: "analyzed" }, + ], + true, + device, + ), + ).toBe("uploading"); + }); + + it("treats items another device must upload as processing", () => { + const assets = [ + { sourceId: "src-mac", storage: "remote" as const, ingestState: "awaiting_upload" as const }, + ]; + expect(projectReadiness(assets, true, device)).toBe("processing"); + expect(uploadsFromDevice(assets, device)).toEqual([]); + expect(uploadsFromDevice(assets, null)).toEqual([]); + }); + + it("waits for analysis when it is available", () => { + const assets = [ + { sourceId: device, storage: "uploaded" as const, ingestState: "proxied" as const }, + ]; + expect(projectReadiness(assets, true, device)).toBe("processing"); + expect(projectReadiness(assets, false, device)).toBe("ready"); + }); + + it("ignores failed items unless all failed", () => { + expect( + projectReadiness( + [ + { sourceId: device, storage: "uploaded", ingestState: "failed" }, + { sourceId: device, storage: "uploaded", ingestState: "analyzed" }, + ], + true, + device, + ), + ).toBe("ready"); + expect( + projectReadiness( + [{ sourceId: device, storage: "uploaded", ingestState: "failed" }], + true, + device, + ), + ).toBe("failed"); + expect(projectReadiness([], true, device)).toBe("processing"); + }); +}); + +describe("urls and names", () => { + it("builds render and thumbnail routes", () => { + expect(renderFileUrl("https://api.example.test/", "r1", "video.mp4")).toBe( + "https://api.example.test/api/media/renders/r1/files/video.mp4", + ); + expect(renderFileUrl("https://api.example.test", "r1", "video.mp4", true)).toBe( + "https://api.example.test/api/media/renders/r1/files/video.mp4?download=1", + ); + expect(assetThumbUrl("https://api.example.test", "a 1")).toBe( + "https://api.example.test/api/media/assets/a%201/thumb", + ); + }); + + it("names shared files from the render id", () => { + expect(shareFileName("r1", "video.MP4")).toBe("render-r1.mp4"); + expect(shareFileName("../r1", "video")).toBe("render-r1.mp4"); + }); + + it("formats durations", () => { + expect(formatDuration(0)).toBe("0:00"); + expect(formatDuration(65_400)).toBe("1:05"); + expect(formatDuration(3_725_000)).toBe("1:02:05"); + }); +}); + +describe("mediaNotificationRoute", () => { + it("opens the project of a render push", () => { + expect( + mediaNotificationRoute({ + kind: "media.render", + projectId: "p1", + renderId: "r1", + status: "done", + }), + ).toEqual({ pathname: "/media-project", params: { projectId: "p1" } }); + }); + + it("ignores other pushes and malformed ids", () => { + expect(mediaNotificationRoute(null)).toBeNull(); + expect(mediaNotificationRoute({ kind: "completion", threadId: "t1" })).toBeNull(); + expect(mediaNotificationRoute({ kind: "media.render", projectId: "../x" })).toBeNull(); + expect(mediaNotificationRoute({ kind: "media.render" })).toBeNull(); + }); +}); + +describe("libraryAccess", () => { + it("distinguishes full, limited, undecided, denied, and blocked access", () => { + expect( + libraryAccess({ + granted: true, + canAskAgain: true, + status: "granted", + accessPrivileges: "all", + }), + ).toBe("all"); + expect( + libraryAccess({ + granted: true, + canAskAgain: true, + status: "granted", + accessPrivileges: "limited", + }), + ).toBe("limited"); + expect(libraryAccess({ granted: false, canAskAgain: true, status: "undetermined" })).toBe( + "undetermined", + ); + expect(libraryAccess({ granted: false, canAskAgain: true, status: "denied" })).toBe("denied"); + expect(libraryAccess({ granted: false, canAskAgain: false, status: "denied" })).toBe("blocked"); + }); +}); diff --git a/apps/mobile/lib/media-project.ts b/apps/mobile/lib/media-project.ts new file mode 100644 index 0000000..54b71e3 --- /dev/null +++ b/apps/mobile/lib/media-project.ts @@ -0,0 +1,227 @@ +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}`; +} diff --git a/apps/mobile/lib/media-sha256.test.ts b/apps/mobile/lib/media-sha256.test.ts new file mode 100644 index 0000000..bde3b89 --- /dev/null +++ b/apps/mobile/lib/media-sha256.test.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { Sha256 } from "./media-sha256"; + +const node = (data: Uint8Array) => createHash("sha256").update(data).digest("hex"); +const text = (value: string) => new TextEncoder().encode(value); + +describe("Sha256", () => { + it("matches known vectors", () => { + expect(new Sha256().hex()).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + expect(new Sha256().update(text("abc")).hex()).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + }); + + it("matches node:crypto across block boundaries", () => { + for (const size of [1, 55, 56, 63, 64, 65, 119, 120, 128, 1000, 4097]) { + const data = new Uint8Array(size); + for (let index = 0; index < size; index++) data[index] = (index * 13 + size) & 0xff; + expect(new Sha256().update(data).hex()).toBe(node(data)); + } + }); + + it("gives the same digest however the input is split", () => { + const data = new Uint8Array(5000); + for (let index = 0; index < data.length; index++) data[index] = (index * 7) & 0xff; + for (const step of [1, 3, 63, 64, 100, 4096]) { + const hash = new Sha256(); + for (let offset = 0; offset < data.length; offset += step) { + hash.update(data.subarray(offset, offset + step)); + } + expect(hash.hex()).toBe(node(data)); + } + }); + + it("refuses updates after finishing", () => { + const hash = new Sha256(); + hash.hex(); + expect(() => hash.update(text("x"))).toThrow(); + expect(() => hash.hex()).toThrow(); + }); +}); diff --git a/apps/mobile/lib/media-sha256.ts b/apps/mobile/lib/media-sha256.ts new file mode 100644 index 0000000..73131be --- /dev/null +++ b/apps/mobile/lib/media-sha256.ts @@ -0,0 +1,123 @@ +/* + * Incremental SHA-256 for media uploads. `uploads.begin` needs the hash of the + * whole original before the first chunk, and originals can be gigabytes, so the + * file is hashed chunk by chunk instead of being read into memory. + */ + +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +export class Sha256 { + private readonly state = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]); + private readonly block = new Uint8Array(64); + private readonly words = new Uint32Array(64); + private blockLength = 0; + private totalBytes = 0; + private finished = false; + + update(data: Uint8Array): this { + if (this.finished) throw new Error("Hash already finished"); + let offset = 0; + this.totalBytes += data.byteLength; + if (this.blockLength > 0) { + const take = Math.min(64 - this.blockLength, data.byteLength); + this.block.set(data.subarray(0, take), this.blockLength); + this.blockLength += take; + offset = take; + if (this.blockLength < 64) return this; + this.compress(this.block, 0); + this.blockLength = 0; + } + while (offset + 64 <= data.byteLength) { + this.compress(data, offset); + offset += 64; + } + if (offset < data.byteLength) { + this.block.set(data.subarray(offset), 0); + this.blockLength = data.byteLength - offset; + } + return this; + } + + /** Lowercase hex digest, the form `uploads.begin` expects. */ + hex(): string { + if (this.finished) throw new Error("Hash already finished"); + const bitsHigh = Math.floor(this.totalBytes / 0x20000000); + const bitsLow = (this.totalBytes * 8) >>> 0; + const padLength = this.blockLength < 56 ? 56 - this.blockLength : 120 - this.blockLength; + const tail = new Uint8Array(padLength + 8); + tail[0] = 0x80; + const view = new DataView(tail.buffer); + view.setUint32(padLength, bitsHigh); + view.setUint32(padLength + 4, bitsLow); + const total = this.totalBytes; + this.update(tail); + this.totalBytes = total; + this.finished = true; + let out = ""; + for (const word of this.state) out += word.toString(16).padStart(8, "0"); + return out; + } + + private compress(data: Uint8Array, offset: number): void { + const w = this.words; + for (let i = 0; i < 16; i++) { + const j = offset + i * 4; + w[i] = + ((data[j] ?? 0) << 24) | + ((data[j + 1] ?? 0) << 16) | + ((data[j + 2] ?? 0) << 8) | + (data[j + 3] ?? 0); + } + for (let i = 16; i < 64; i++) { + const a = w[i - 15] ?? 0; + const b = w[i - 2] ?? 0; + const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3); + const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10); + w[i] = ((w[i - 16] ?? 0) + s0 + (w[i - 7] ?? 0) + s1) | 0; + } + const s = this.state; + let a = s[0] ?? 0; + let b = s[1] ?? 0; + let c = s[2] ?? 0; + let d = s[3] ?? 0; + let e = s[4] ?? 0; + let f = s[5] ?? 0; + let g = s[6] ?? 0; + let h = s[7] ?? 0; + for (let i = 0; i < 64; i++) { + const s1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); + const ch = (e & f) ^ (~e & g); + const t1 = (h + s1 + ch + (K[i] ?? 0) + (w[i] ?? 0)) | 0; + const s0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (s0 + maj) | 0; + h = g; + g = f; + f = e; + e = (d + t1) | 0; + d = c; + c = b; + b = a; + a = (t1 + t2) | 0; + } + s[0] = (s[0] ?? 0) + a; + s[1] = (s[1] ?? 0) + b; + s[2] = (s[2] ?? 0) + c; + s[3] = (s[3] ?? 0) + d; + s[4] = (s[4] ?? 0) + e; + s[5] = (s[5] ?? 0) + f; + s[6] = (s[6] ?? 0) + g; + s[7] = (s[7] ?? 0) + h; + } +} diff --git a/apps/mobile/lib/media-upload.test.ts b/apps/mobile/lib/media-upload.test.ts new file mode 100644 index 0000000..a60b423 --- /dev/null +++ b/apps/mobile/lib/media-upload.test.ts @@ -0,0 +1,396 @@ +import { createHash } from "node:crypto"; +import type { MediaUpload } from "@rakazo/contracts"; +import { MEDIA_UPLOAD_CHUNK_BYTES } from "@rakazo/contracts"; +import { describe, expect, it, vi } from "vitest"; +import type { ChunkBytes, UploadTransport } from "./media-upload"; +import { + chunkCount, + chunkRange, + combinedProgress, + hashOriginal, + isRetryableUploadError, + isUploadExpired, + MediaUploadHttpError, + missingChunks, + retryDelayMs, + uploadOriginal, +} from "./media-upload"; + +function bytes(size: number): ChunkBytes { + const out = new Uint8Array(size); + for (let index = 0; index < size; index++) out[index] = (index * 31 + 7) & 0xff; + return out; +} + +const sha = (data: Uint8Array) => createHash("sha256").update(data).digest("hex"); + +/** In-memory server with the same chunk rules as the API route. */ +function fakeServer(file: Uint8Array, chunkBytes: number) { + let nextId = 0; + const uploads = new Map }>(); + const failures: Array<(index: number) => Error | null> = []; + const view = (id: string): MediaUpload => { + const entry = uploads.get(id); + if (!entry) throw new MediaUploadHttpError(404, "Not found"); + return { ...entry.upload, receivedChunks: [...entry.chunks.keys()].sort((a, b) => a - b) }; + }; + const transport = { + begin: vi.fn(async (input: { assetId: string; sizeBytes: number; sha256: string }) => { + const open = [...uploads.values()].find( + (entry) => entry.upload.assetId === input.assetId && entry.upload.status === "open", + ); + if (open) return view(open.upload.id); + nextId += 1; + const id = `up${nextId}`; + uploads.set(id, { + upload: { + id, + assetId: input.assetId, + sizeBytes: input.sizeBytes, + chunkBytes, + receivedChunks: [], + status: "open", + }, + chunks: new Map(), + }); + return view(id); + }), + status: vi.fn(async (uploadId: string) => view(uploadId)), + putChunk: vi.fn(async (uploadId: string, index: number, chunk: Uint8Array) => { + const failure = failures.shift()?.(index); + if (failure) throw failure; + const entry = uploads.get(uploadId); + if (!entry) throw new MediaUploadHttpError(404, "Not found"); + if (entry.upload.status !== "open") throw new MediaUploadHttpError(409, "Upload expired"); + const { length } = chunkRange(index, entry.upload.sizeBytes, chunkBytes); + if (chunk.byteLength !== length) throw new MediaUploadHttpError(400, "Chunk is incomplete"); + entry.chunks.set(index, chunk.slice()); + return view(uploadId); + }), + complete: vi.fn(async (uploadId: string) => { + const entry = uploads.get(uploadId); + if (!entry) throw new MediaUploadHttpError(404, "Not found"); + if (entry.upload.status !== "open") throw new MediaUploadHttpError(409, "Upload expired"); + const count = chunkCount(entry.upload.sizeBytes, chunkBytes); + const parts: Uint8Array[] = []; + for (let index = 0; index < count; index++) { + const part = entry.chunks.get(index); + if (!part) throw new MediaUploadHttpError(409, "Upload is missing chunks"); + parts.push(part); + } + const assembled = new Uint8Array(entry.upload.sizeBytes); + let offset = 0; + for (const part of parts) { + assembled.set(part, offset); + offset += part.byteLength; + } + expect(sha(assembled)).toBe(sha(file)); + entry.upload.status = "complete"; + return view(uploadId); + }), + } satisfies UploadTransport; + return { + transport, + uploads, + failNext: (fail: (index: number) => Error | null) => failures.push(fail), + expire: (id: string) => { + const entry = uploads.get(id); + if (entry) entry.upload.status = "failed"; + }, + }; +} + +const read = (file: ChunkBytes) => (start: number, length: number) => + file.slice(start, start + length); +const noWait = async () => undefined; + +describe("chunk planning", () => { + it("splits a file into full chunks and a short last chunk", () => { + expect(chunkCount(10, 4)).toBe(3); + expect(chunkRange(0, 10, 4)).toEqual({ start: 0, length: 4 }); + expect(chunkRange(1, 10, 4)).toEqual({ start: 4, length: 4 }); + expect(chunkRange(2, 10, 4)).toEqual({ start: 8, length: 2 }); + }); + + it("uses exact chunks when the size divides evenly", () => { + expect(chunkCount(8, 4)).toBe(2); + expect(chunkRange(1, 8, 4)).toEqual({ start: 4, length: 4 }); + }); + + it("matches the contract chunk size for large originals", () => { + const size = 3 * MEDIA_UPLOAD_CHUNK_BYTES + 5; + expect(chunkCount(size, MEDIA_UPLOAD_CHUNK_BYTES)).toBe(4); + expect(chunkRange(3, size, MEDIA_UPLOAD_CHUNK_BYTES)).toEqual({ + start: 3 * MEDIA_UPLOAD_CHUNK_BYTES, + length: 5, + }); + }); + + it("rejects invalid sizes and indexes", () => { + expect(() => chunkCount(0, 4)).toThrow(); + expect(() => chunkCount(10, 0)).toThrow(); + expect(() => chunkRange(3, 10, 4)).toThrow(); + expect(() => chunkRange(-1, 10, 4)).toThrow(); + }); + + it("lists the chunks the server has not confirmed", () => { + expect(missingChunks({ sizeBytes: 10, chunkBytes: 4, receivedChunks: [] })).toEqual([0, 1, 2]); + expect(missingChunks({ sizeBytes: 10, chunkBytes: 4, receivedChunks: [2, 0] })).toEqual([1]); + expect(missingChunks({ sizeBytes: 10, chunkBytes: 4, receivedChunks: [0, 1, 2] })).toEqual([]); + }); +}); + +describe("error classification", () => { + it("recognizes an expired upload", () => { + expect(isUploadExpired(new MediaUploadHttpError(409, "Upload expired"))).toBe(true); + expect(isUploadExpired(new MediaUploadHttpError(409, "Upload is missing chunks"))).toBe(false); + expect(isUploadExpired(new Error("Upload expired"))).toBe(false); + }); + + it("retries network and server failures, not client errors", () => { + expect(isRetryableUploadError(new TypeError("Network request failed"))).toBe(true); + expect(isRetryableUploadError(new MediaUploadHttpError(503, "busy"))).toBe(true); + expect(isRetryableUploadError(new MediaUploadHttpError(408, "slow"))).toBe(true); + expect(isRetryableUploadError(new MediaUploadHttpError(400, "Chunk is incomplete"))).toBe( + false, + ); + expect(isRetryableUploadError(new MediaUploadHttpError(413, "Chunk is too large"))).toBe(false); + }); + + it("backs off exponentially up to a cap", () => { + expect(retryDelayMs(1)).toBe(1_000); + expect(retryDelayMs(3)).toBe(4_000); + expect(retryDelayMs(20)).toBe(30_000); + }); +}); + +describe("hashOriginal", () => { + it("hashes the file in pieces", async () => { + const file = bytes(1_000); + const progress: number[] = []; + const hash = await hashOriginal(file.byteLength, read(file), { + readBytes: 64, + onProgress: (fraction) => progress.push(fraction), + }); + expect(hash).toBe(sha(file)); + expect(progress.at(-1)).toBe(1); + }); + + it("fails when the file is shorter than reported", async () => { + const file = bytes(100); + await expect(hashOriginal(200, read(file), { readBytes: 64 })).rejects.toThrow(/changed/); + }); +}); + +describe("uploadOriginal", () => { + const file = bytes(10_000); + const chunkBytes = 4_096; + const base = { + assetId: "asset1", + fileName: "clip.mov", + sizeBytes: file.byteLength, + sha256: sha(file), + }; + + it("uploads every chunk in order and completes", async () => { + const server = fakeServer(file, chunkBytes); + const progress: number[] = []; + const upload = await uploadOriginal({ + ...base, + read: read(file), + transport: server.transport, + onProgress: (fraction) => progress.push(fraction), + wait: noWait, + }); + expect(upload.status).toBe("complete"); + expect(server.transport.putChunk.mock.calls.map(([, index]) => index)).toEqual([0, 1, 2]); + expect(server.transport.complete).toHaveBeenCalledTimes(1); + expect(progress.at(-1)).toBe(1); + }); + + it("resumes from receivedChunks of an open upload", async () => { + const server = fakeServer(file, chunkBytes); + const first = await server.transport.begin(base); + await server.transport.putChunk(first.id, 0, read(file)(0, chunkBytes)); + await server.transport.putChunk( + first.id, + 2, + read(file)(2 * chunkBytes, 10_000 - 2 * chunkBytes), + ); + server.transport.putChunk.mockClear(); + + const upload = await uploadOriginal({ + ...base, + read: read(file), + transport: server.transport, + wait: noWait, + }); + expect(upload.id).toBe(first.id); + expect(server.transport.putChunk.mock.calls.map(([, index]) => index)).toEqual([1]); + }); + + it("asks the server what arrived after a network failure and continues", async () => { + const server = fakeServer(file, chunkBytes); + server.failNext(() => null); + server.failNext(() => new TypeError("Network request failed")); + const wait = vi.fn(noWait); + const upload = await uploadOriginal({ + ...base, + read: read(file), + transport: server.transport, + wait, + }); + expect(upload.status).toBe("complete"); + expect(server.transport.status).toHaveBeenCalledTimes(1); + expect(wait).toHaveBeenCalledWith(1_000, undefined); + // Chunk 0 landed before the failure; only 1 is sent again. + expect(server.transport.putChunk.mock.calls.map(([, index]) => index)).toEqual([0, 1, 1, 2]); + }); + + it("begins again when the upload expired", async () => { + const server = fakeServer(file, chunkBytes); + server.failNext(() => null); + server.failNext(() => { + server.expire("up1"); + return new MediaUploadHttpError(409, "Upload expired"); + }); + const upload = await uploadOriginal({ + ...base, + read: read(file), + transport: server.transport, + wait: noWait, + }); + expect(upload.status).toBe("complete"); + expect(upload.id).toBe("up2"); + expect(server.transport.begin).toHaveBeenCalledTimes(2); + // The new upload starts from chunk 0. + expect(server.transport.putChunk.mock.calls.map(([id, index]) => `${id}:${index}`)).toEqual([ + "up1:0", + "up1:1", + "up2:0", + "up2:1", + "up2:2", + ]); + }); + + it("begins again when completion reports the upload expired", async () => { + const server = fakeServer(file, chunkBytes); + const complete = server.transport.complete.getMockImplementation(); + server.transport.complete.mockImplementationOnce(async (uploadId: string) => { + server.expire(uploadId); + throw new MediaUploadHttpError(409, "Upload expired"); + }); + if (complete) server.transport.complete.mockImplementation(complete); + const upload = await uploadOriginal({ + ...base, + read: read(file), + transport: server.transport, + wait: noWait, + }); + expect(upload.status).toBe("complete"); + expect(server.transport.begin).toHaveBeenCalledTimes(2); + }); + + it("stops restarting after the restart budget", async () => { + const server = fakeServer(file, chunkBytes); + for (let attempt = 0; attempt < 5; attempt++) { + server.failNext(() => { + for (const id of server.uploads.keys()) server.expire(id); + return new MediaUploadHttpError(409, "Upload expired"); + }); + } + await expect( + uploadOriginal({ + ...base, + read: read(file), + transport: server.transport, + wait: noWait, + maxRestarts: 2, + }), + ).rejects.toThrow("Upload expired"); + expect(server.transport.begin).toHaveBeenCalledTimes(3); + }); + + it("gives up after repeated failures", async () => { + const server = fakeServer(file, chunkBytes); + for (let attempt = 0; attempt < 10; attempt++) { + server.failNext(() => new MediaUploadHttpError(503, "busy")); + } + await expect( + uploadOriginal({ + ...base, + read: read(file), + transport: server.transport, + wait: noWait, + maxAttempts: 3, + }), + ).rejects.toThrow("busy"); + expect(server.transport.putChunk).toHaveBeenCalledTimes(3); + }); + + it("does not retry a rejected chunk", async () => { + const server = fakeServer(file, chunkBytes); + server.failNext(() => new MediaUploadHttpError(413, "Chunk is too large")); + await expect( + uploadOriginal({ ...base, read: read(file), transport: server.transport, wait: noWait }), + ).rejects.toThrow("Chunk is too large"); + expect(server.transport.putChunk).toHaveBeenCalledTimes(1); + }); + + it("returns at once when the upload is already complete", async () => { + const server = fakeServer(file, chunkBytes); + await uploadOriginal({ ...base, read: read(file), transport: server.transport, wait: noWait }); + server.transport.putChunk.mockClear(); + server.transport.begin.mockImplementationOnce(async () => ({ + ...(await server.transport.status("up1")), + })); + const again = await uploadOriginal({ + ...base, + read: read(file), + transport: server.transport, + wait: noWait, + }); + expect(again.status).toBe("complete"); + expect(server.transport.putChunk).not.toHaveBeenCalled(); + }); + + it("stops when aborted", async () => { + const server = fakeServer(file, chunkBytes); + const controller = new AbortController(); + server.failNext(() => { + controller.abort(new Error("stopped")); + return new TypeError("Network request failed"); + }); + await expect( + uploadOriginal({ + ...base, + read: read(file), + transport: server.transport, + wait: noWait, + signal: controller.signal, + }), + ).rejects.toThrow("stopped"); + expect(server.transport.status).not.toHaveBeenCalled(); + }); + + it("fails when the file changed size while uploading", async () => { + const server = fakeServer(file, chunkBytes); + const short = (start: number, length: number) => file.slice(start, start + length - 1); + await expect( + uploadOriginal({ ...base, read: short, transport: server.transport, wait: noWait }), + ).rejects.toThrow(/changed/); + expect(server.transport.status).not.toHaveBeenCalled(); + }); +}); + +describe("combinedProgress", () => { + it("weights uploads by size", () => { + expect(combinedProgress([])).toBe(0); + expect( + combinedProgress([ + { sizeBytes: 100, fraction: 1 }, + { sizeBytes: 300, fraction: 0 }, + ]), + ).toBe(0.25); + }); +}); diff --git a/apps/mobile/lib/media-upload.ts b/apps/mobile/lib/media-upload.ts new file mode 100644 index 0000000..46335dd --- /dev/null +++ b/apps/mobile/lib/media-upload.ts @@ -0,0 +1,249 @@ +import type { MediaUpload } from "@rakazo/contracts"; +import { Sha256 } from "./media-sha256"; + +/* + * Resumable chunked upload of one device original (spec 2.7). The server owns + * the protocol: `uploads.begin` opens or resumes an upload for the same asset + * and content, chunks go to `PUT /api/media/uploads/:id/chunks/:index` with + * their exact length, `uploads.status.receivedChunks` says what already + * arrived, and an upload idle for too long answers 409 "Upload expired" and + * must begin again. This module is the driver; the native glue injects I/O. + */ + +/** A failed byte route or RPC with the HTTP status the server answered. */ +export class MediaUploadHttpError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "MediaUploadHttpError"; + } +} + +/** The local original no longer matches what the upload was opened for. */ +export class MediaFileChangedError extends Error { + constructor() { + super("The file changed while reading it"); + this.name = "MediaFileChangedError"; + } +} + +export function chunkCount(sizeBytes: number, chunkBytes: number): number { + if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) throw new Error("Invalid upload size"); + if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0) throw new Error("Invalid chunk size"); + return Math.ceil(sizeBytes / chunkBytes); +} + +/** Byte range of chunk `index`: every chunk is full except the last. */ +export function chunkRange( + index: number, + sizeBytes: number, + chunkBytes: number, +): { start: number; length: number } { + const count = chunkCount(sizeBytes, chunkBytes); + if (!Number.isInteger(index) || index < 0 || index >= count) { + throw new Error("Chunk index out of range"); + } + const start = index * chunkBytes; + return { start, length: Math.min(chunkBytes, sizeBytes - start) }; +} + +/** Chunk indexes the server has not confirmed yet, in upload order. */ +export function missingChunks( + upload: Pick, +) { + const received = new Set(upload.receivedChunks); + const missing: number[] = []; + for (let index = 0; index < chunkCount(upload.sizeBytes, upload.chunkBytes); index++) { + if (!received.has(index)) missing.push(index); + } + return missing; +} + +export function isUploadExpired(error: unknown): boolean { + return ( + error instanceof MediaUploadHttpError && error.status === 409 && /expired/i.test(error.message) + ); +} + +/** Network failures and server hiccups are worth another try; client errors are not. */ +export function isRetryableUploadError(error: unknown): boolean { + if (error instanceof MediaUploadHttpError) { + return error.status === 408 || error.status === 429 || error.status >= 500; + } + if (error instanceof MediaFileChangedError) return false; + return !(error instanceof Error && error.name === "AbortError"); +} + +/** Bytes of one chunk, backed by a plain ArrayBuffer so fetch can send them. */ +export type ChunkBytes = Uint8Array; + +/** Reads `length` bytes at `start` from the original. */ +export type ReadChunk = (start: number, length: number) => Promise | ChunkBytes; + +export async function hashOriginal( + sizeBytes: number, + read: ReadChunk, + options: { + readBytes?: number; + signal?: AbortSignal; + onProgress?: (fraction: number) => void; + } = {}, +): Promise { + const step = options.readBytes ?? 4 * 1024 * 1024; + const hash = new Sha256(); + for (let start = 0; start < sizeBytes; start += step) { + options.signal?.throwIfAborted(); + const length = Math.min(step, sizeBytes - start); + const bytes = await read(start, length); + if (bytes.byteLength !== length) throw new MediaFileChangedError(); + hash.update(bytes); + options.onProgress?.(Math.min(1, (start + length) / sizeBytes)); + } + return hash.hex(); +} + +export type UploadTransport = { + begin(input: { + assetId: string; + sizeBytes: number; + sha256: string; + fileName: string; + }): Promise; + status(uploadId: string): Promise; + putChunk( + uploadId: string, + index: number, + bytes: ChunkBytes, + signal?: AbortSignal, + ): Promise; + complete(uploadId: string): Promise; +}; + +export type UploadOriginalInput = { + assetId: string; + fileName: string; + sizeBytes: number; + sha256: string; + read: ReadChunk; + transport: UploadTransport; + signal?: AbortSignal; + /** Fraction of bytes the server confirmed, 0..1. */ + onProgress?: (fraction: number) => void; + /** Consecutive failed attempts before giving up. */ + maxAttempts?: number; + /** How often an expired upload may start over. */ + maxRestarts?: number; + wait?: (ms: number, signal?: AbortSignal) => Promise; +}; + +const defaultWait = (ms: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true }, + ); + }); + +export function retryDelayMs(attempt: number): number { + return Math.min(30_000, 1_000 * 2 ** Math.max(0, attempt - 1)); +} + +function confirmedFraction(upload: MediaUpload): number { + const count = chunkCount(upload.sizeBytes, upload.chunkBytes); + let bytes = 0; + for (const index of new Set(upload.receivedChunks)) { + if (index >= 0 && index < count) { + bytes += chunkRange(index, upload.sizeBytes, upload.chunkBytes).length; + } + } + return bytes / upload.sizeBytes; +} + +/** + * Upload one original and complete it. Resumes from the server's + * `receivedChunks` after any failure, begins again when the upload expired, + * and backs off between retries. Returns the completed upload. + */ +export async function uploadOriginal(input: UploadOriginalInput): Promise { + const { transport, signal } = input; + const maxAttempts = input.maxAttempts ?? 5; + const maxRestarts = input.maxRestarts ?? 2; + const wait = input.wait ?? defaultWait; + const begin = () => + transport.begin({ + assetId: input.assetId, + sizeBytes: input.sizeBytes, + sha256: input.sha256, + fileName: input.fileName, + }); + + let restarts = 0; + let failures = 0; + let upload = await begin(); + for (;;) { + signal?.throwIfAborted(); + try { + if (upload.status === "complete") { + input.onProgress?.(1); + return upload; + } + if (upload.status === "failed") { + throw new MediaUploadHttpError(409, "Upload expired"); + } + if (upload.sizeBytes !== input.sizeBytes) throw new MediaFileChangedError(); + input.onProgress?.(confirmedFraction(upload)); + for (const index of missingChunks(upload)) { + signal?.throwIfAborted(); + const range = chunkRange(index, upload.sizeBytes, upload.chunkBytes); + const bytes = await input.read(range.start, range.length); + if (bytes.byteLength !== range.length) throw new MediaFileChangedError(); + upload = await transport.putChunk(upload.id, index, bytes, signal); + failures = 0; + input.onProgress?.(confirmedFraction(upload)); + } + upload = await transport.complete(upload.id); + if (upload.status !== "complete") throw new Error("Upload did not complete"); + input.onProgress?.(1); + return upload; + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + if (isUploadExpired(error)) { + if (restarts >= maxRestarts) throw error; + restarts += 1; + failures = 0; + upload = await begin(); + continue; + } + if (!isRetryableUploadError(error)) throw error; + failures += 1; + if (failures >= maxAttempts) throw error; + await wait(retryDelayMs(failures), signal); + // Ask the server what arrived; a chunk may have landed before the error. + try { + upload = await transport.status(upload.id); + } catch (statusError) { + if (isUploadExpired(statusError)) { + if (restarts >= maxRestarts) throw statusError; + restarts += 1; + upload = await begin(); + } else if (!isRetryableUploadError(statusError)) { + throw statusError; + } + } + } + } +} + +/** Overall progress of several uploads, weighted by size. */ +export function combinedProgress(items: Array<{ sizeBytes: number; fraction: number }>): number { + const total = items.reduce((sum, item) => sum + item.sizeBytes, 0); + if (total === 0) return 0; + return items.reduce((sum, item) => sum + item.sizeBytes * item.fraction, 0) / total; +}