feat: async task pipeline for media and llm workflows

这个提交包含在:
cryptocommuniums-afk
2026-03-15 00:12:26 +08:00
父节点 1cc863e60e
当前提交 20e183d2da
修改 36 个文件,包含 1961 行新增339 行删除

查看文件

@@ -18,6 +18,8 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { toast } from "sonner";
import {
Activity,
@@ -34,6 +36,7 @@ import {
ShieldAlert,
Smartphone,
Sparkles,
ListTodo,
Video,
VideoOff,
Wifi,
@@ -126,7 +129,16 @@ function formatFileSize(bytes: number) {
export default function Recorder() {
const { user } = useAuth();
const registerExternalMutation = trpc.video.registerExternal.useMutation();
const utils = trpc.useUtils();
const finalizeTaskMutation = trpc.task.createMediaFinalize.useMutation({
onSuccess: (data) => {
setArchiveTaskId(data.taskId);
toast.success("录制归档任务已提交");
},
onError: (error) => {
toast.error(`录制归档任务提交失败: ${error.message}`);
},
});
const liveVideoRef = useRef<HTMLVideoElement>(null);
const playbackVideoRef = useRef<HTMLVideoElement>(null);
@@ -142,11 +154,9 @@ export default function Recorder() {
const pendingUploadsRef = useRef<PendingSegment[]>([]);
const uploadInFlightRef = useRef(false);
const currentSessionRef = useRef<MediaSession | null>(null);
const registeredSessionIdRef = useRef<string | null>(null);
const segmentTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timerTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const motionTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollTickerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const modeRef = useRef<RecorderMode>("idle");
const reconnectAttemptsRef = useRef(0);
@@ -170,11 +180,13 @@ export default function Recorder() {
const [markers, setMarkers] = useState<MediaMarker[]>([]);
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
const [immersivePreview, setImmersivePreview] = useState(false);
const [archiveTaskId, setArchiveTaskId] = useState<string | null>(null);
const mobile = useMemo(() => isMobileDevice(), []);
const mimeType = useMemo(() => pickRecorderMimeType(), []);
const currentPlaybackUrl = mediaSession?.playback.mp4Url || mediaSession?.playback.webmUrl || "";
const archiveProgress = getArchiveProgress(mediaSession);
const archiveTaskQuery = useBackgroundTask(archiveTaskId);
const syncSessionState = useCallback((session: MediaSession | null) => {
currentSessionRef.current = session;
@@ -196,6 +208,25 @@ export default function Recorder() {
facingModeRef.current = facingMode;
}, [facingMode]);
useEffect(() => {
if (archiveTaskQuery.data?.status === "succeeded") {
void (async () => {
if (currentSessionRef.current?.id) {
const response = await getMediaSession(currentSessionRef.current.id);
syncSessionState(response.session);
}
setMode("archived");
utils.video.list.invalidate();
toast.success("回放文件已归档完成");
setArchiveTaskId(null);
})();
} else if (archiveTaskQuery.data?.status === "failed") {
toast.error(`录制归档失败: ${archiveTaskQuery.data.error || "未知错误"}`);
setMode("idle");
setArchiveTaskId(null);
}
}, [archiveTaskQuery.data, syncSessionState, utils.video.list]);
const stopTickers = useCallback(() => {
if (segmentTickerRef.current) clearInterval(segmentTickerRef.current);
if (timerTickerRef.current) clearInterval(timerTickerRef.current);
@@ -556,10 +587,10 @@ export default function Recorder() {
setUploadBytes(0);
setQueuedSegments(0);
setReconnectAttempts(0);
setArchiveTaskId(null);
segmentSequenceRef.current = 0;
motionFrameRef.current = null;
pendingUploadsRef.current = [];
registeredSessionIdRef.current = null;
const stream = await ensurePreviewStream();
const sessionResponse = await createMediaSession({
@@ -602,62 +633,19 @@ export default function Recorder() {
durationMs: Date.now() - recordingStartedAtRef.current,
});
syncSessionState(response.session);
toast.success("录制已提交,正在生成回放文件");
await finalizeTaskMutation.mutateAsync({
sessionId: session.id,
title: title.trim() || session.title,
exerciseType: "recording",
});
toast.success("录制已提交,后台正在整理回放文件");
} catch (error: any) {
toast.error(`结束录制失败: ${error?.message || "未知错误"}`);
setMode("recording");
return;
}
if (pollTickerRef.current) {
clearInterval(pollTickerRef.current);
}
pollTickerRef.current = setInterval(async () => {
const current = currentSessionRef.current;
if (!current?.id) {
return;
}
try {
const response = await getMediaSession(current.id);
syncSessionState(response.session);
if (response.session.archiveStatus === "completed") {
if (pollTickerRef.current) clearInterval(pollTickerRef.current);
setMode("archived");
toast.success("回放文件已归档完成");
if (registeredSessionIdRef.current !== response.session.id) {
const playbackUrl = response.session.playback.webmUrl || response.session.playback.mp4Url;
const playbackFormat = response.session.playback.webmUrl ? "webm" : response.session.playback.mp4Url ? "mp4" : "";
if (!playbackUrl || !playbackFormat) {
return;
}
registeredSessionIdRef.current = response.session.id;
await registerExternalMutation.mutateAsync({
title: title.trim() || response.session.title,
url: playbackUrl,
fileKey: `media/sessions/${response.session.id}/recording.${playbackFormat}`,
format: playbackFormat,
fileSize: response.session.playback.webmSize || response.session.playback.mp4Size,
duration: response.session.durationMs / 1000,
exerciseType: "recording",
});
}
}
if (response.session.archiveStatus === "failed") {
if (pollTickerRef.current) clearInterval(pollTickerRef.current);
toast.error(response.session.lastError || "归档失败");
}
} catch {
// keep polling
}
}, 3_000);
}, [closePeer, flushPendingSegments, registerExternalMutation, stopCamera, stopRecorder, syncSessionState, title]);
}, [closePeer, finalizeTaskMutation, flushPendingSegments, stopCamera, stopRecorder, syncSessionState, title]);
const resetRecorder = useCallback(async () => {
if (pollTickerRef.current) clearInterval(pollTickerRef.current);
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
stopTickers();
await stopRecorder().catch(() => {});
@@ -667,7 +655,7 @@ export default function Recorder() {
uploadInFlightRef.current = false;
motionFrameRef.current = null;
currentSessionRef.current = null;
registeredSessionIdRef.current = null;
setArchiveTaskId(null);
setMediaSession(null);
setMarkers([]);
setDurationMs(0);
@@ -755,7 +743,6 @@ export default function Recorder() {
useEffect(() => {
return () => {
if (pollTickerRef.current) clearInterval(pollTickerRef.current);
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
stopTickers();
if (recorderRef.current && recorderRef.current.state !== "inactive") {
@@ -988,6 +975,17 @@ export default function Recorder() {
</div>
</section>
{(finalizeTaskMutation.isPending || archiveTaskQuery.data?.status === "queued" || archiveTaskQuery.data?.status === "running") ? (
<Alert>
<ListTodo className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{archiveTaskQuery.data?.message || "录制文件正在后台整理、转码并登记到视频库。"}
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.7fr)_minmax(340px,0.9fr)]">
<section className="space-y-4">
<Card className="overflow-hidden border-0 shadow-lg">