evOo-local/apps/mobile/app/media-project.tsx
Claude 4c30af9ea3
feat(media): mobile library index, resumable upload, and project variants
Index photos and videos from the device library into a client-pushed
ios_library source, upload originals in resumable chunks, and review,
approve, and share project variants with expo-video and the native share
sheet. Pure logic for chunking, resume, indexing, and variants is unit
tested.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:20:42 +00:00

345 lines
11 KiB
TypeScript

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 },
});
}