feat: async task pipeline for media and llm workflows
这个提交包含在:
@@ -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">
|
||||
|
||||
在新工单中引用
屏蔽一个用户