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
345 lines
11 KiB
TypeScript
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 },
|
|
});
|
|
}
|