feat(media): mobile library index, resumable upload, and project variants

Index photos and videos from the device library into a client-pushed
ios_library source, upload originals in resumable chunks, and review,
approve, and share project variants with expo-video and the native share
sheet. Pure logic for chunking, resume, indexing, and variants is unit
tested.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
This commit is contained in:
Claude 2026-10-03 19:20:42 +00:00
parent 884d8903ca
commit 4c30af9ea3
No known key found for this signature in database
12 changed files with 2910 additions and 0 deletions

View File

@ -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<ProjectData | null>(null);
const [assets, setAssets] = useState<MediaAsset[]>([]);
const [profiles, setProfiles] = useState<PlatformProfileSummary[]>([]);
const [analysis, setAnalysis] = useState(true);
const [deviceSourceId, setDeviceSourceId] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const uploadStarted = useRef(false);
const planned = useRef(false);
const uploadAbort = useRef<AbortController | null>(null);
const load = useCallback(async () => {
if (!projectId) return;
const next = await mediaRpc<ProjectData>("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<string, MediaAsset>();
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<PlatformProfileSummary[]>("profiles/list")
.then(setProfiles)
.catch(() => undefined);
void registerDeviceSource()
.then((source) => setDeviceSourceId(source.id))
.catch(() => undefined);
void mediaRpc<MediaCapabilities>("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<MediaEditPlan[]>(
"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<unknown>) {
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 (
<ScrollView
style={styles.screen}
contentContainerStyle={styles.content}
contentInsetAdjustmentBehavior="automatic"
>
<Stack.Screen options={{ title: data?.project.title ?? "" }} />
{error ? <Text style={styles.error}>{error}</Text> : null}
{!data ? <ActivityIndicator color={native.secondaryLabel} /> : null}
{data && readiness === "uploading" ? (
uploading ? (
<View style={styles.progressRow}>
<ActivityIndicator color={native.secondaryLabel} />
<Text style={styles.status}>
{t("Uploading {percent}%", { percent: Math.round((uploadProgress ?? 0) * 100) })}
</Text>
</View>
) : (
<Pressable
accessibilityRole="button"
style={styles.primaryButton}
onPress={() => void startUpload()}
>
<Text style={styles.primaryLabel}>{t("Upload")}</Text>
</Pressable>
)
) : null}
{data && variants.length === 0 && readiness === "processing" ? (
<ActivityIndicator color={native.secondaryLabel} />
) : null}
{variants.map((variant) => (
<VariantCard
key={variant.profileId}
variant={variant}
busy={busy === variant.profileId}
onApprove={() =>
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 ? <Text style={styles.note}>{t("Edit on web or desktop.")}</Text> : null}
</ScrollView>
);
}
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 (
<View style={styles.card}>
<Text style={styles.cardTitle}>{variant.profileName}</Text>
{variant.preview ? <VariantPlayer render={variant.preview} /> : null}
{status ? (
<Text style={state === "blocked" || state === "failed" ? styles.blocked : styles.status}>
{status}
</Text>
) : null}
{busy ? (
<ActivityIndicator color={native.secondaryLabel} />
) : canApprove(variant) || canShare(variant) ? (
<View style={styles.actions}>
{canApprove(variant) ? (
<Pressable accessibilityRole="button" style={styles.button} onPress={onApprove}>
<Text style={styles.buttonLabel}>{t("Approve")}</Text>
</Pressable>
) : null}
{canShare(variant) ? (
<Pressable accessibilityRole="button" style={styles.button} onPress={onShare}>
<Text style={styles.buttonLabel}>{t("Share")}</Text>
</Pressable>
) : null}
</View>
) : null}
</View>
);
}
function VariantPlayer({ render }: { render: MediaRender }) {
const renderId = render.id;
const fileName = videoOutput(render)?.fileName ?? null;
const [source, setSource] = useState<{ uri: string; headers: Record<string, string> } | 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 ? <Player key={source.uri} source={source} /> : null;
}
function Player({ source }: { source: { uri: string; headers: Record<string, string> } }) {
const styles = useThemedStyles(createStyles);
const player = useVideoPlayer(source);
return <VideoView player={player} nativeControls contentFit="contain" style={styles.video} />;
}
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 },
});
}

View File

@ -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<MediaCollection | null>(null);
const [assets, setAssets] = useState<MediaAsset[]>([]);
const [headers, setHeaders] = useState<Record<string, string>>({});
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(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<MediaProject>("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<PlatformProfileSummary[]>("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 (
<View style={styles.screen}>
<Stack.Screen options={{ title: collection?.title ?? "" }} />
<FlatList
data={assets}
keyExtractor={(asset) => asset.id}
numColumns={COLUMNS}
columnWrapperStyle={{ gap: GAP }}
contentContainerStyle={{ gap: GAP }}
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={error ? <Text style={styles.error}>{error}</Text> : null}
renderItem={({ item }) => (
<View style={[styles.tile, { width: tile, height: tile }]}>
{item.hasThumbnail ? (
<Image
accessibilityIgnoresInvertColors
source={{ uri: assetThumbUrl(currentApiBase(), item.id), headers }}
style={StyleSheet.absoluteFill}
/>
) : (
<NativeSymbol
ios={item.kind === "video" ? "video" : "photo"}
android={item.kind === "video" ? "videocam-outline" : "image-outline"}
color={native.tertiaryLabel}
size={22}
/>
)}
{item.kind === "video" && item.durationMs ? (
<Text style={styles.duration}>{formatDuration(item.durationMs)}</Text>
) : null}
</View>
)}
/>
{collection ? (
<SafeAreaView edges={["bottom"]} style={styles.footer}>
<Pressable
accessibilityRole="button"
disabled={creating}
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
onPress={() => void chooseProfile()}
>
{creating ? (
<ActivityIndicator color={mobileTokens().primaryForeground} />
) : (
<Text style={styles.primaryLabel}>{t("Upload and create project")}</Text>
)}
</Pressable>
</SafeAreaView>
) : null}
</View>
);
}
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 },
});
}

241
apps/mobile/app/media.tsx Normal file
View File

@ -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<LibraryAccess | null>(null);
const [sessions, setSessions] = useState<MediaCollection[]>([]);
const [projects, setProjects] = useState<MediaProject[]>([]);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<View style={styles.screen}>
<Stack.Screen
options={{
title: t("Media"),
headerRight: () =>
syncing ? (
<ActivityIndicator color={native.secondaryLabel} />
) : access === "limited" ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Choose photos and videos")}
hitSlop={10}
onPress={() => void chooseItems()}
>
<NativeSymbol ios="photo.badge.plus" android="images-outline" />
</Pressable>
) : null,
}}
/>
<SectionList
sections={sections}
keyExtractor={(row) => (row.kind === "project" ? row.project.id : row.collection.id)}
contentContainerStyle={styles.content}
contentInsetAdjustmentBehavior="automatic"
stickySectionHeadersEnabled={false}
refreshControl={
<RefreshControl refreshing={false} onRefresh={() => void refresh(access)} />
}
ListHeaderComponent={
needsAccess ? (
<Pressable
accessibilityRole="button"
style={styles.primaryButton}
onPress={() => void allow()}
>
<Text style={styles.primaryLabel}>
{access === "blocked" ? t("Open Settings") : t("Allow photo access")}
</Text>
</Pressable>
) : error ? (
<Text style={styles.error}>{error}</Text>
) : null
}
renderSectionHeader={({ section }) =>
// A lone sessions list needs no heading.
sections.length > 1 ? <Text style={styles.sectionTitle}>{section.title}</Text> : null
}
renderItem={({ item }) =>
item.kind === "project" ? (
<Pressable
accessibilityRole="button"
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() =>
router.push({ pathname: "/media-project", params: { projectId: item.project.id } })
}
>
<Text style={styles.rowTitle} numberOfLines={1}>
{item.project.title}
</Text>
</Pressable>
) : (
<Pressable
accessibilityRole="button"
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() =>
router.push({
pathname: "/media-session",
params: { collectionId: item.collection.id },
})
}
>
<Text style={styles.rowTitle} numberOfLines={1}>
{item.collection.title}
</Text>
<Text style={styles.rowDetail}>
{item.collection.videoCount > 0
? `${t("{count} items", { count: item.collection.assetCount })} · ${formatDuration(
item.collection.totalDurationMs,
)}`
: t("{count} items", { count: item.collection.assetCount })}
</Text>
</Pressable>
)
}
renderSectionFooter={({ section }) =>
section.data.length === 0 && !syncing && !needsAccess ? (
<Text style={styles.empty}>{t("No sessions yet")}</Text>
) : null
}
/>
</View>
);
}
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 },
});
}

View File

@ -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<T>(
proc: string,
body: unknown = {},
options?: { timeoutMs?: number | null; signal?: AbortSignal },
): Promise<T> {
return rpc<T>(`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<MediaCapabilities>("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<LibraryAccess> {
return libraryAccess(await getPermissionsAsync(false, [...LIBRARY_PERMISSIONS]));
}
export async function requestLibraryAccess(): Promise<LibraryAccess> {
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<void> {
await presentPermissionsPicker(["photo", "video"]);
}
// ---------------------------------------------------------------------------
// Device index
// ---------------------------------------------------------------------------
async function deviceId(): Promise<string> {
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<string | null> {
try {
return (await SecureStore.getItemAsync(storeKey(sourceId))) || null;
} catch {
return null;
}
}
async function writeSyncToken(sourceId: string, token: string | null): Promise<void> {
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<MediaSource> {
return mediaRpc<MediaSource>("sources/registerClient", {
kind: "ios_library",
label: Platform.OS === "ios" ? "iPhone" : "Android",
deviceId: await deviceId(),
});
}
async function listDevice(offset: number, limit: number): Promise<DeviceAsset[]> {
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<string[]> {
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<MediaSource> | null = null;
/** Index the device library into its source. One sync runs at a time. */
export function syncDeviceLibrary(): Promise<MediaSource> {
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<PushResult>("sources/pushItems", input, { timeoutMs: 60_000 }),
serverAssetCount: async () => {
const sources = await mediaRpc<MediaSource[]>("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<T>(proc: string, body: unknown, timeoutMs?: number): Promise<T> {
try {
return await mediaRpc<T>(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<MediaUpload> {
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<MediaUpload>("uploads/begin", input),
status: (uploadId) => rpcUploadCall<MediaUpload>("uploads/status", { uploadId }),
putChunk,
complete: (uploadId) => rpcUploadCall<MediaUpload>("uploads/complete", { uploadId }, 10 * 60_000),
};
/** Hashes are slow on large videos; keep them for retries within this session. */
const hashes = new Map<string, string>();
async function uploadOne(
asset: MediaAsset,
onProgress: (fraction: number) => void,
signal?: AbortSignal,
): Promise<void> {
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<string[]> {
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<string, string> }> {
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<void> {
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]);
}

View File

@ -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> = {}): 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();
});
});

View File

@ -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<DeviceAsset, "mediaType">): 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<T>(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<DeviceAsset[]>;
/** Location of one asset, or null when unknown or not permitted. */
locate(id: string): Promise<DeviceLocation | null>;
push(input: PushInput): Promise<PushResult>;
/** How many assets the server holds for this source. */
serverAssetCount(): Promise<number>;
/** Every externalId the server holds for this source. */
serverExternalIds(): Promise<string[]>;
};
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<DeviceIndexResult> {
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<string, DeviceAsset>();
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<string>();
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 };
}

View File

@ -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> = {}): 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");
});
});

View File

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

View File

@ -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();
});
});

View File

@ -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;
}
}

View File

@ -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<string, { upload: MediaUpload; chunks: Map<number, Uint8Array> }>();
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);
});
});

View File

@ -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<MediaUpload, "sizeBytes" | "chunkBytes" | "receivedChunks">,
) {
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<ArrayBuffer>;
/** Reads `length` bytes at `start` from the original. */
export type ReadChunk = (start: number, length: number) => Promise<ChunkBytes> | ChunkBytes;
export async function hashOriginal(
sizeBytes: number,
read: ReadChunk,
options: {
readBytes?: number;
signal?: AbortSignal;
onProgress?: (fraction: number) => void;
} = {},
): Promise<string> {
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<MediaUpload>;
status(uploadId: string): Promise<MediaUpload>;
putChunk(
uploadId: string,
index: number,
bytes: ChunkBytes,
signal?: AbortSignal,
): Promise<MediaUpload>;
complete(uploadId: string): Promise<MediaUpload>;
};
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<void>;
};
const defaultWait = (ms: number, signal?: AbortSignal) =>
new Promise<void>((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<MediaUpload> {
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;
}