Merge branch 'media-w3-mobile' into claude/gallant-johnson-4lyr66
This commit is contained in:
commit
ed0cb29e16
344
apps/mobile/app/media-project.tsx
Normal file
344
apps/mobile/app/media-project.tsx
Normal 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 },
|
||||
});
|
||||
}
|
||||
189
apps/mobile/app/media-session.tsx
Normal file
189
apps/mobile/app/media-session.tsx
Normal 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
241
apps/mobile/app/media.tsx
Normal 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 },
|
||||
});
|
||||
}
|
||||
374
apps/mobile/lib/media-api.ts
Normal file
374
apps/mobile/lib/media-api.ts
Normal 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]);
|
||||
}
|
||||
215
apps/mobile/lib/media-index.test.ts
Normal file
215
apps/mobile/lib/media-index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
209
apps/mobile/lib/media-index.ts
Normal file
209
apps/mobile/lib/media-index.ts
Normal 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 };
|
||||
}
|
||||
299
apps/mobile/lib/media-project.test.ts
Normal file
299
apps/mobile/lib/media-project.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
227
apps/mobile/lib/media-project.ts
Normal file
227
apps/mobile/lib/media-project.ts
Normal 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}`;
|
||||
}
|
||||
44
apps/mobile/lib/media-sha256.test.ts
Normal file
44
apps/mobile/lib/media-sha256.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
123
apps/mobile/lib/media-sha256.ts
Normal file
123
apps/mobile/lib/media-sha256.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
396
apps/mobile/lib/media-upload.test.ts
Normal file
396
apps/mobile/lib/media-upload.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
249
apps/mobile/lib/media-upload.ts
Normal file
249
apps/mobile/lib/media-upload.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user