Harden async task flows and enhance analysis tooling
这个提交包含在:
@@ -3,6 +3,7 @@ import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { toast } from "sonner";
|
||||
@@ -21,6 +22,22 @@ function formatTaskStatus(status: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTaskTiming(task: {
|
||||
createdAt: string | Date;
|
||||
startedAt?: string | Date | null;
|
||||
completedAt?: string | Date | null;
|
||||
}) {
|
||||
const createdAt = new Date(task.createdAt).getTime();
|
||||
const startedAt = task.startedAt ? new Date(task.startedAt).getTime() : null;
|
||||
const completedAt = task.completedAt ? new Date(task.completedAt).getTime() : null;
|
||||
const durationMs = (completedAt ?? Date.now()) - (startedAt ?? createdAt);
|
||||
const seconds = Math.max(0, Math.round(durationMs / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return `${minutes}m ${rest.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
export function TaskCenter({ compact = false }: { compact?: boolean }) {
|
||||
const utils = trpc.useUtils();
|
||||
const retryMutation = trpc.task.retry.useMutation({
|
||||
@@ -36,6 +53,9 @@ export function TaskCenter({ compact = false }: { compact?: boolean }) {
|
||||
const taskListQuery = trpc.task.list.useQuery(
|
||||
{ limit: 20 },
|
||||
{
|
||||
retry: 3,
|
||||
retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 8_000),
|
||||
placeholderData: (previous) => previous,
|
||||
refetchInterval: (query) => {
|
||||
const hasActiveTask = (query.state.data ?? []).some((task) => task.status === "queued" || task.status === "running");
|
||||
return hasActiveTask ? 3_000 : 8_000;
|
||||
@@ -86,6 +106,15 @@ export function TaskCenter({ compact = false }: { compact?: boolean }) {
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-6rem)] pr-4">
|
||||
<div className="mt-6 space-y-3">
|
||||
{taskListQuery.isError ? (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
任务列表刷新失败,当前显示最近一次成功结果。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{(taskListQuery.data ?? []).length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
当前没有后台任务。
|
||||
@@ -117,7 +146,9 @@ export function TaskCenter({ compact = false }: { compact?: boolean }) {
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{new Date(task.createdAt).toLocaleString("zh-CN")}</span>
|
||||
<span>
|
||||
{new Date(task.createdAt).toLocaleString("zh-CN")} · 耗时 {formatTaskTiming(task)}
|
||||
</span>
|
||||
{task.status === "failed" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -5,6 +5,9 @@ export function useBackgroundTask(taskId: string | null | undefined) {
|
||||
{ taskId: taskId || "" },
|
||||
{
|
||||
enabled: Boolean(taskId),
|
||||
retry: 3,
|
||||
retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 8_000),
|
||||
placeholderData: (previous) => previous,
|
||||
refetchInterval: (query) => {
|
||||
const task = query.state.data;
|
||||
if (!task) return 3_000;
|
||||
|
||||
@@ -271,10 +271,40 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
|
||||
const rightElbowAngle = getAngle(rightShoulder, rightElbow, rightWrist) || 145;
|
||||
const leftElbowAngle = getAngle(leftShoulder, leftElbow, leftWrist) || 145;
|
||||
const footSpread = Math.abs((leftAnkle?.x ?? 0.42) - (rightAnkle?.x ?? 0.58));
|
||||
const shoulderSpan = Math.abs((rightShoulder?.x ?? 0.56) - (leftShoulder?.x ?? 0.44));
|
||||
const wristSpread = Math.abs((rightWrist?.x ?? 0.62) - (leftWrist?.x ?? 0.38));
|
||||
const shoulderCenterX = ((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2;
|
||||
const torsoOffset = Math.abs(shoulderCenterX - hipCenter.x);
|
||||
const rightForward = (rightWrist?.x ?? shoulderCenterX) - hipCenter.x;
|
||||
const leftForward = hipCenter.x - (leftWrist?.x ?? shoulderCenterX);
|
||||
const contactHeight = hipCenter.y - (rightWrist?.y ?? hipCenter.y);
|
||||
const visibility =
|
||||
landmarks.reduce((sum, point) => sum + (point.visibility ?? 0.95), 0) /
|
||||
Math.max(1, landmarks.length);
|
||||
|
||||
if (visibility < 0.42 || shoulderSpan < 0.08) {
|
||||
tracking.prevTimestamp = timestamp;
|
||||
tracking.prevRightWrist = rightWrist;
|
||||
tracking.prevLeftWrist = leftWrist;
|
||||
tracking.prevHipCenter = hipCenter;
|
||||
tracking.lastAction = "unknown";
|
||||
|
||||
return {
|
||||
action: "unknown",
|
||||
confidence: 0.2,
|
||||
score: {
|
||||
overall: 48,
|
||||
posture: 50,
|
||||
balance: 48,
|
||||
technique: 45,
|
||||
footwork: 42,
|
||||
consistency: 40,
|
||||
confidence: 20,
|
||||
},
|
||||
feedback: ["当前画面人体可见度不足,请尽量让头肩和双脚都留在画面内。"],
|
||||
};
|
||||
}
|
||||
|
||||
const posture = clamp(100 - shoulderTilt * 780 - headOffset * 640, 0, 100);
|
||||
const balance = clamp(100 - hipTilt * 900 - Math.max(0, 0.16 - footSpread) * 260, 0, 100);
|
||||
const footwork = clamp(45 + Math.min(36, hipSpeed * 120) + Math.max(0, 165 - kneeBend) * 0.35, 0, 100);
|
||||
@@ -286,6 +316,8 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
|
||||
confidence: clamp(
|
||||
(rightWrist && nose && rightWrist.y < nose.y ? 0.45 : 0.1) +
|
||||
(rightElbow && rightShoulder && rightElbow.y < rightShoulder.y ? 0.18 : 0.04) +
|
||||
clamp(contactHeight * 1.4, 0, 0.14) +
|
||||
clamp((0.24 - footSpread) * 1.2, 0, 0.08) +
|
||||
clamp((rightElbowAngle - 135) / 55, 0, 0.22) +
|
||||
clamp(rightVerticalMotion * 4.5, 0, 0.15),
|
||||
0,
|
||||
@@ -305,8 +337,11 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
|
||||
{
|
||||
action: "forehand",
|
||||
confidence: clamp(
|
||||
(rightWrist && nose && rightWrist.x > nose.x ? 0.28 : 0.08) +
|
||||
clamp(rightSpeed * 0.12, 0, 0.36) +
|
||||
(rightWrist && nose && rightWrist.x > nose.x ? 0.24 : 0.08) +
|
||||
(rightForward > 0.11 ? 0.16 : 0.04) +
|
||||
clamp((wristSpread - 0.2) * 0.8, 0, 0.16) +
|
||||
clamp((0.08 - torsoOffset) * 1.8, 0, 0.08) +
|
||||
clamp(rightSpeed * 0.12, 0, 0.28) +
|
||||
clamp((rightElbowAngle - 85) / 70, 0, 0.2),
|
||||
0,
|
||||
0.94,
|
||||
@@ -315,8 +350,11 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
|
||||
{
|
||||
action: "backhand",
|
||||
confidence: clamp(
|
||||
((leftWrist && nose && leftWrist.x < nose.x) || (rightWrist && nose && rightWrist.x < nose.x) ? 0.28 : 0.08) +
|
||||
clamp(Math.max(leftSpeed, rightSpeed) * 0.1, 0, 0.34) +
|
||||
((leftWrist && nose && leftWrist.x < nose.x) || (rightWrist && nose && rightWrist.x < nose.x) ? 0.2 : 0.06) +
|
||||
(leftForward > 0.1 ? 0.16 : 0.04) +
|
||||
(rightWrist && hipCenter && rightWrist.x < hipCenter.x ? 0.12 : 0.02) +
|
||||
clamp((wristSpread - 0.22) * 0.75, 0, 0.14) +
|
||||
clamp(Math.max(leftSpeed, rightSpeed) * 0.1, 0, 0.22) +
|
||||
clamp((leftElbowAngle - 85) / 70, 0, 0.18),
|
||||
0,
|
||||
0.92,
|
||||
@@ -326,6 +364,7 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
|
||||
action: "volley",
|
||||
confidence: clamp(
|
||||
(rightWrist && rightShoulder && Math.abs(rightWrist.y - rightShoulder.y) < 0.12 ? 0.3 : 0.08) +
|
||||
clamp((0.16 - Math.abs(contactHeight - 0.08)) * 1.2, 0, 0.1) +
|
||||
clamp((0.22 - Math.abs((rightWrist?.x ?? 0.5) - hipCenter.x)) * 1.5, 0, 0.18) +
|
||||
clamp((1.8 - rightSpeed) * 0.14, 0, 0.18),
|
||||
0,
|
||||
@@ -336,6 +375,7 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
|
||||
action: "slice",
|
||||
confidence: clamp(
|
||||
(rightWrist && rightShoulder && rightWrist.y > rightShoulder.y ? 0.18 : 0.06) +
|
||||
clamp((contactHeight + 0.06) * 0.7, 0, 0.08) +
|
||||
clamp((tracking.prevRightWrist && rightWrist && rightWrist.y > tracking.prevRightWrist.y ? 0.18 : 0.04), 0, 0.18) +
|
||||
clamp(rightSpeed * 0.08, 0, 0.24),
|
||||
0,
|
||||
@@ -346,6 +386,7 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
|
||||
action: "lob",
|
||||
confidence: clamp(
|
||||
(rightWrist && nose && rightWrist.y < nose.y + 0.1 ? 0.22 : 0.08) +
|
||||
clamp((0.18 - Math.abs(rightForward)) * 1.2, 0, 0.08) +
|
||||
clamp(rightVerticalMotion * 4.2, 0, 0.28) +
|
||||
clamp((0.18 - Math.abs((rightWrist?.x ?? 0.5) - hipCenter.x)) * 1.4, 0, 0.18),
|
||||
0,
|
||||
@@ -356,7 +397,7 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
|
||||
|
||||
candidates.sort((a, b) => b.confidence - a.confidence);
|
||||
const topCandidate = candidates[0] ?? { action: "unknown" as ActionType, confidence: 0.2 };
|
||||
const action = topCandidate.confidence >= 0.5 ? topCandidate.action : "unknown";
|
||||
const action = topCandidate.confidence >= 0.52 ? topCandidate.action : "unknown";
|
||||
|
||||
const techniqueBase =
|
||||
action === "serve" || action === "overhead"
|
||||
@@ -380,6 +421,9 @@ function analyzePoseFrame(landmarks: Point[], tracking: TrackingState, timestamp
|
||||
if (action === "unknown") {
|
||||
feedback.push("当前片段缺少完整挥拍特征,系统已归为未知动作。");
|
||||
}
|
||||
if (visibility < 0.65) {
|
||||
feedback.push("人体关键点可见度偏低,建议调整机位让双臂和双脚完全入镜。");
|
||||
}
|
||||
if (posture < 72) {
|
||||
feedback.push("上体轴线偏移较明显,击球准备时保持头肩稳定。");
|
||||
}
|
||||
@@ -465,6 +509,16 @@ function ScoreBar({ label, value, accent }: { label: string; value: number; acce
|
||||
);
|
||||
}
|
||||
|
||||
function getSessionBand(input: { overallScore: number; knownRatio: number; effectiveSegments: number }) {
|
||||
if (input.overallScore >= 85 && input.knownRatio >= 0.72 && input.effectiveSegments >= 4) {
|
||||
return { label: "高质量", tone: "bg-emerald-500/10 text-emerald-700" };
|
||||
}
|
||||
if (input.overallScore >= 72 && input.knownRatio >= 0.55 && input.effectiveSegments >= 2) {
|
||||
return { label: "稳定", tone: "bg-sky-500/10 text-sky-700" };
|
||||
}
|
||||
return { label: "待加强", tone: "bg-amber-500/10 text-amber-700" };
|
||||
}
|
||||
|
||||
export default function LiveCamera() {
|
||||
useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
@@ -501,6 +555,7 @@ export default function LiveCamera() {
|
||||
const [feedback, setFeedback] = useState<string[]>([]);
|
||||
const [segments, setSegments] = useState<ActionSegment[]>([]);
|
||||
const [durationMs, setDurationMs] = useState(0);
|
||||
const [segmentFilter, setSegmentFilter] = useState<ActionType | "all">("all");
|
||||
|
||||
const uploadMutation = trpc.video.upload.useMutation();
|
||||
const saveLiveSessionMutation = trpc.analysis.liveSessionSave.useMutation({
|
||||
@@ -520,6 +575,54 @@ export default function LiveCamera() {
|
||||
[segments],
|
||||
);
|
||||
const unknownSegments = useMemo(() => segments.filter((segment) => segment.isUnknown), [segments]);
|
||||
const filteredVisibleSegments = useMemo(
|
||||
() => segmentFilter === "all" ? visibleSegments : visibleSegments.filter((segment) => segment.actionType === segmentFilter),
|
||||
[segmentFilter, visibleSegments],
|
||||
);
|
||||
const actionStats = useMemo(() => {
|
||||
const totals = new Map<ActionType, { count: number; durationMs: number; averageScore: number; averageConfidence: number }>();
|
||||
|
||||
visibleSegments.forEach((segment) => {
|
||||
const current = totals.get(segment.actionType) ?? {
|
||||
count: 0,
|
||||
durationMs: 0,
|
||||
averageScore: 0,
|
||||
averageConfidence: 0,
|
||||
};
|
||||
const nextCount = current.count + 1;
|
||||
totals.set(segment.actionType, {
|
||||
count: nextCount,
|
||||
durationMs: current.durationMs + segment.durationMs,
|
||||
averageScore: ((current.averageScore * current.count) + segment.score) / nextCount,
|
||||
averageConfidence: ((current.averageConfidence * current.count) + segment.confidenceAvg) / nextCount,
|
||||
});
|
||||
});
|
||||
|
||||
const totalDuration = Math.max(1, visibleSegments.reduce((sum, segment) => sum + segment.durationMs, 0));
|
||||
return Array.from(totals.entries())
|
||||
.map(([actionType, value]) => ({
|
||||
actionType,
|
||||
...value,
|
||||
sharePct: Math.round((value.durationMs / totalDuration) * 100),
|
||||
}))
|
||||
.sort((a, b) => b.durationMs - a.durationMs);
|
||||
}, [visibleSegments]);
|
||||
const bestSegment = useMemo(
|
||||
() => visibleSegments.reduce<ActionSegment | null>((best, segment) => {
|
||||
if (!best) return segment;
|
||||
return segment.score > best.score ? segment : best;
|
||||
}, null),
|
||||
[visibleSegments],
|
||||
);
|
||||
const knownRatio = segments.length > 0 ? visibleSegments.length / segments.length : 0;
|
||||
const sessionBand = useMemo(
|
||||
() => getSessionBand({
|
||||
overallScore: liveScore?.overall || 0,
|
||||
knownRatio,
|
||||
effectiveSegments: visibleSegments.length,
|
||||
}),
|
||||
[knownRatio, liveScore?.overall, visibleSegments.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
navigator.mediaDevices?.enumerateDevices().then((devices) => {
|
||||
@@ -1136,12 +1239,34 @@ export default function LiveCamera() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{visibleSegments.length === 0 ? (
|
||||
{actionStats.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={segmentFilter === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSegmentFilter("all")}
|
||||
>
|
||||
全部片段
|
||||
</Button>
|
||||
{actionStats.map((item) => (
|
||||
<Button
|
||||
key={item.actionType}
|
||||
variant={segmentFilter === item.actionType ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSegmentFilter(item.actionType)}
|
||||
>
|
||||
{ACTION_META[item.actionType].label} · {item.count}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filteredVisibleSegments.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
开始分析后,这里会按时间区间显示识别出的动作片段。
|
||||
</div>
|
||||
) : (
|
||||
visibleSegments.map((segment) => {
|
||||
filteredVisibleSegments.map((segment) => {
|
||||
const meta = ACTION_META[segment.actionType];
|
||||
return (
|
||||
<div key={`${segment.actionType}-${segment.startMs}`} className="rounded-2xl border border-border/60 bg-muted/25 p-4">
|
||||
@@ -1151,6 +1276,7 @@ export default function LiveCamera() {
|
||||
<Badge className={meta.tone}>{meta.label}</Badge>
|
||||
<Badge variant="outline">{formatDuration(segment.startMs)} - {formatDuration(segment.endMs)}</Badge>
|
||||
<Badge variant="outline">时长 {formatDuration(segment.durationMs)}</Badge>
|
||||
<Badge variant="outline">关键帧 {segment.keyFrames.length}</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{segment.issueSummary.join(" · ") || "当前片段节奏稳定"}</div>
|
||||
</div>
|
||||
@@ -1189,6 +1315,7 @@ export default function LiveCamera() {
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<Badge className={heroAction.tone}>{heroAction.label}</Badge>
|
||||
<Badge variant="outline">置信度 {liveScore.confidence}%</Badge>
|
||||
<Badge className={sessionBand.tone}>{sessionBand.label}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -1207,6 +1334,39 @@ export default function LiveCamera() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">动作分布</CardTitle>
|
||||
<CardDescription>按识别出的非未知动作统计区间数量、时长和平均质量。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{actionStats.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
累积到稳定动作区间后,这里会展示分布。
|
||||
</div>
|
||||
) : (
|
||||
actionStats.map((item) => (
|
||||
<div key={item.actionType} className="space-y-2 rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={ACTION_META[item.actionType].tone}>{ACTION_META[item.actionType].label}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{item.count} 段</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
平均 {Math.round(item.averageScore)} 分 · {Math.round(item.averageConfidence * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={item.sharePct} className="h-2" />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>累计时长 {formatDuration(item.durationMs)}</span>
|
||||
<span>占有效片段 {item.sharePct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">实时反馈</CardTitle>
|
||||
@@ -1234,6 +1394,18 @@ export default function LiveCamera() {
|
||||
className="mt-3 h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>有效识别率</span>
|
||||
<span className="font-medium">{Math.round(knownRatio * 100)}%</span>
|
||||
</div>
|
||||
<Progress value={knownRatio * 100} className="mt-3 h-2" />
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||||
<div>最佳片段 {bestSegment ? `${Math.round(bestSegment.score)} 分` : "暂无"}</div>
|
||||
<div>主动作 {actionStats[0] ? ACTION_META[actionStats[0].actionType].label : "未知"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1265,6 +1437,17 @@ export default function LiveCamera() {
|
||||
<div>有效片段 {session.effectiveSegments || 0}</div>
|
||||
<div>时长 {formatDuration(session.durationMs || 0)}</div>
|
||||
</div>
|
||||
{session.videoUrl ? (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(session.videoUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
打开回放
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -37,11 +37,30 @@ function formatStructuredValue(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTaskTiming(task: {
|
||||
createdAt: string | Date;
|
||||
startedAt?: string | Date | null;
|
||||
completedAt?: string | Date | null;
|
||||
}) {
|
||||
const createdAt = new Date(task.createdAt).getTime();
|
||||
const startedAt = task.startedAt ? new Date(task.startedAt).getTime() : null;
|
||||
const completedAt = task.completedAt ? new Date(task.completedAt).getTime() : null;
|
||||
const durationMs = (completedAt ?? Date.now()) - (startedAt ?? createdAt);
|
||||
const seconds = Math.max(0, Math.round(durationMs / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return `${minutes}m ${rest.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const utils = trpc.useUtils();
|
||||
const taskListQuery = trpc.task.list.useQuery(
|
||||
{ limit: 50 },
|
||||
{
|
||||
retry: 3,
|
||||
retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 8_000),
|
||||
placeholderData: (previous) => previous,
|
||||
refetchInterval: (query) => {
|
||||
const hasActiveTask = (query.state.data ?? []).some((task) => task.status === "queued" || task.status === "running");
|
||||
return hasActiveTask ? 3_000 : 10_000;
|
||||
@@ -103,6 +122,16 @@ export default function Logs() {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{taskListQuery.isError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>任务列表刷新失败</AlertTitle>
|
||||
<AlertDescription>
|
||||
当前显示最近一次成功拉取的数据。服务恢复后页面会自动继续刷新。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue="tasks" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tasks">后台任务</TabsTrigger>
|
||||
@@ -153,7 +182,9 @@ export default function Logs() {
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>进度 {task.progress}% · 尝试 {task.attempts}/{task.maxAttempts}</span>
|
||||
<span>
|
||||
进度 {task.progress}% · 尝试 {task.attempts}/{task.maxAttempts} · 耗时 {formatTaskTiming(task)}
|
||||
</span>
|
||||
{task.status === "failed" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { toast } from "sonner";
|
||||
import {
|
||||
BarChart3,
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
FileVideo,
|
||||
Play,
|
||||
@@ -110,6 +111,26 @@ function downloadJson(filename: string, data: unknown) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function downloadText(filename: string, data: string) {
|
||||
const blob = new Blob([data], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function buildClipCueSheet(title: string, clips: ClipDraft[]) {
|
||||
return clips.map((clip, index) => (
|
||||
`${index + 1}. ${clip.label}\n` +
|
||||
` 区间: ${formatSeconds(clip.startSec)} - ${formatSeconds(clip.endSec)}\n` +
|
||||
` 时长: ${formatSeconds(Math.max(0, clip.endSec - clip.startSec))}\n` +
|
||||
` 来源: ${clip.source === "manual" ? "手动" : "分析建议"}\n` +
|
||||
` 备注: ${clip.notes || "无"}`
|
||||
)).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${new Date().toLocaleString("zh-CN")}\n`;
|
||||
}
|
||||
|
||||
export default function Videos() {
|
||||
useAuth();
|
||||
const { data: videos, isLoading } = trpc.video.list.useQuery();
|
||||
@@ -125,6 +146,7 @@ export default function Videos() {
|
||||
const [clipLabel, setClipLabel] = useState("");
|
||||
const [clipNotes, setClipNotes] = useState("");
|
||||
const [clipDrafts, setClipDrafts] = useState<ClipDraft[]>([]);
|
||||
const [activePreviewRange, setActivePreviewRange] = useState<[number, number] | null>(null);
|
||||
|
||||
const getAnalysis = useCallback((videoId: number) => {
|
||||
return analyses?.find((analysis: any) => analysis.videoId === videoId);
|
||||
@@ -173,6 +195,22 @@ export default function Videos() {
|
||||
localStorage.setItem(localStorageKey(selectedVideo.id), JSON.stringify(clipDrafts));
|
||||
}, [clipDrafts, selectedVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = previewRef.current;
|
||||
if (!video || !activePreviewRange) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (video.currentTime >= activePreviewRange[1]) {
|
||||
video.currentTime = activePreviewRange[0];
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener("timeupdate", handleTimeUpdate);
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
};
|
||||
}, [activePreviewRange]);
|
||||
|
||||
const openEditor = useCallback((video: any) => {
|
||||
setSelectedVideo(video);
|
||||
setEditorOpen(true);
|
||||
@@ -181,6 +219,7 @@ export default function Videos() {
|
||||
setClipLabel("");
|
||||
setClipNotes("");
|
||||
setClipRange([0, 5]);
|
||||
setActivePreviewRange(null);
|
||||
}, []);
|
||||
|
||||
const addClip = useCallback((source: "manual" | "suggested", preset?: ClipDraft) => {
|
||||
@@ -201,6 +240,11 @@ export default function Videos() {
|
||||
toast.success("片段已加入轻剪辑草稿");
|
||||
}, [clipDrafts.length, clipLabel, clipNotes, clipRange]);
|
||||
|
||||
const totalClipDurationSec = useMemo(
|
||||
() => clipDrafts.reduce((sum, clip) => sum + Math.max(0, clip.endSec - clip.startSec), 0),
|
||||
[clipDrafts],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -364,6 +408,25 @@ export default function Videos() {
|
||||
<CardDescription>建议先在播放器中定位,再设置入点和出点。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">草稿片段</div>
|
||||
<div className="mt-2 text-lg font-semibold">{clipDrafts.length}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">总剪辑时长</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(totalClipDurationSec)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">建议片段</div>
|
||||
<div className="mt-2 text-lg font-semibold">{suggestedClips.length}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">当前区间时长</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(Math.max(0, clipRange[1] - clipRange[0]))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">当前播放</div>
|
||||
@@ -417,6 +480,28 @@ export default function Videos() {
|
||||
>
|
||||
跳到入点
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!previewRef.current) return;
|
||||
setActivePreviewRange([clipRange[0], clipRange[1]]);
|
||||
previewRef.current.currentTime = clipRange[0];
|
||||
await previewRef.current.play().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
循环预览当前区间
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setActivePreviewRange(null);
|
||||
previewRef.current?.pause();
|
||||
}}
|
||||
>
|
||||
停止循环
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
@@ -469,7 +554,20 @@ export default function Videos() {
|
||||
if (previewRef.current) previewRef.current.currentTime = clip.startSec;
|
||||
}}
|
||||
>
|
||||
预览
|
||||
载入区间
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setActivePreviewRange([clip.startSec, clip.endSec]);
|
||||
if (previewRef.current) {
|
||||
previewRef.current.currentTime = clip.startSec;
|
||||
await previewRef.current.play().catch(() => undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
循环预览
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => addClip("suggested", clip)}>加入草稿</Button>
|
||||
</div>
|
||||
@@ -497,11 +595,44 @@ export default function Videos() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{clip.label}</span>
|
||||
<Badge variant="outline">{clip.source === "manual" ? "手动" : "建议"}</Badge>
|
||||
<Badge variant="secondary">{formatSeconds(Math.max(0, clip.endSec - clip.startSec))}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setClipRange([clip.startSec, clip.endSec]);
|
||||
setClipLabel(clip.label);
|
||||
setClipNotes(clip.notes);
|
||||
if (previewRef.current) {
|
||||
previewRef.current.currentTime = clip.startSec;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const value = `${clip.label} ${formatSeconds(clip.startSec)}-${formatSeconds(clip.endSec)} ${clip.notes || ""}`.trim();
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("当前浏览器不支持剪贴板复制");
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(value).then(
|
||||
() => toast.success("片段信息已复制"),
|
||||
() => toast.error("片段复制失败"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -510,6 +641,7 @@ export default function Videos() {
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{clip.notes ? <div className="mt-2 text-sm text-muted-foreground">{clip.notes}</div> : null}
|
||||
</div>
|
||||
))
|
||||
@@ -525,19 +657,31 @@ export default function Videos() {
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!selectedVideo) return;
|
||||
downloadJson(`${selectedVideo.title}-clip-plan.json`, {
|
||||
const payload = {
|
||||
videoId: selectedVideo.id,
|
||||
title: selectedVideo.title,
|
||||
url: selectedVideo.url,
|
||||
clipDrafts,
|
||||
exportedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
downloadJson(`${selectedVideo.title}-clip-plan.json`, payload);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
导出草稿
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!selectedVideo) return;
|
||||
downloadText(`${selectedVideo.title}-clip-cuesheet.txt`, buildClipCueSheet(selectedVideo.title, clipDrafts));
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
导出清单
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setEditorOpen(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -51,7 +51,13 @@ export const ENV = {
|
||||
llmMaxTokens: parseInteger(process.env.LLM_MAX_TOKENS, 32768),
|
||||
llmEnableThinking: parseBoolean(process.env.LLM_ENABLE_THINKING, false),
|
||||
llmThinkingBudget: parseInteger(process.env.LLM_THINKING_BUDGET, 128),
|
||||
llmTimeoutMs: parseInteger(process.env.LLM_TIMEOUT_MS, 45000),
|
||||
llmRetryCount: parseInteger(process.env.LLM_RETRY_COUNT, 1),
|
||||
mediaServiceUrl: process.env.MEDIA_SERVICE_URL ?? "",
|
||||
mediaFetchTimeoutMs: parseInteger(process.env.MEDIA_FETCH_TIMEOUT_MS, 12000),
|
||||
mediaFetchRetryCount: parseInteger(process.env.MEDIA_FETCH_RETRY_COUNT, 2),
|
||||
youtubeApiKey: process.env.YOUTUBE_API_KEY ?? "",
|
||||
backgroundTaskPollMs: parseInteger(process.env.BACKGROUND_TASK_POLL_MS, 3000),
|
||||
backgroundTaskStaleMs: parseInteger(process.env.BACKGROUND_TASK_STALE_MS, 300000),
|
||||
backgroundTaskHeartbeatMs: parseInteger(process.env.BACKGROUND_TASK_HEARTBEAT_MS, 5000),
|
||||
};
|
||||
|
||||
85
server/_core/fetch.ts
普通文件
85
server/_core/fetch.ts
普通文件
@@ -0,0 +1,85 @@
|
||||
type FetchRetryOptions = {
|
||||
timeoutMs: number;
|
||||
retries?: number;
|
||||
retryStatuses?: number[];
|
||||
retryMethods?: string[];
|
||||
baseDelayMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_RETRY_STATUSES = [408, 425, 429, 502, 503, 504];
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function shouldRetryResponse(method: string, response: Response, options: FetchRetryOptions) {
|
||||
const allowedMethods = options.retryMethods ?? ["GET", "HEAD"];
|
||||
const retryStatuses = options.retryStatuses ?? DEFAULT_RETRY_STATUSES;
|
||||
return allowedMethods.includes(method) && retryStatuses.includes(response.status);
|
||||
}
|
||||
|
||||
function shouldRetryError(method: string, error: unknown, options: FetchRetryOptions) {
|
||||
const allowedMethods = options.retryMethods ?? ["GET", "HEAD"];
|
||||
if (!allowedMethods.includes(method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.name === "AbortError" || error.name === "TimeoutError" || error.message.includes("fetch");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(input: string | URL, init: RequestInit | undefined, options: FetchRetryOptions) {
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const retries = Math.max(0, options.retries ?? 0);
|
||||
const baseDelayMs = Math.max(150, options.baseDelayMs ?? 350);
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
const upstreamSignal = init?.signal;
|
||||
let didTimeout = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
didTimeout = true;
|
||||
controller.abort();
|
||||
}, options.timeoutMs);
|
||||
|
||||
const abortHandler = () => controller.abort();
|
||||
upstreamSignal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
try {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (attempt < retries && shouldRetryResponse(method, response, options)) {
|
||||
await response.text().catch(() => undefined);
|
||||
await sleep(baseDelayMs * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (didTimeout) {
|
||||
lastError = new Error(`Request timed out after ${options.timeoutMs}ms`);
|
||||
} else {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
if (attempt >= retries || !shouldRetryError(method, lastError, options)) {
|
||||
throw lastError instanceof Error ? lastError : new Error("Request failed");
|
||||
}
|
||||
|
||||
await sleep(baseDelayMs * (attempt + 1));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
upstreamSignal?.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("Request failed");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ENV } from "./env";
|
||||
import { fetchWithTimeout } from "./fetch";
|
||||
|
||||
export type Role = "system" | "user" | "assistant" | "tool" | "function";
|
||||
|
||||
@@ -323,13 +324,17 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
payload.response_format = normalizedResponseFormat;
|
||||
}
|
||||
|
||||
const response = await fetch(resolveApiUrl(apiUrl), {
|
||||
const response = await fetchWithTimeout(resolveApiUrl(apiUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${apiKey || ENV.llmApiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}, {
|
||||
timeoutMs: ENV.llmTimeoutMs,
|
||||
retries: ENV.llmRetryCount,
|
||||
retryMethods: ["POST"],
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
24
server/db.ts
24
server/db.ts
@@ -1631,7 +1631,11 @@ export async function claimNextBackgroundTask(workerId: string) {
|
||||
|
||||
const now = new Date();
|
||||
const [nextTask] = await db.select().from(backgroundTasks)
|
||||
.where(and(eq(backgroundTasks.status, "queued"), lte(backgroundTasks.runAfter, now)))
|
||||
.where(and(
|
||||
eq(backgroundTasks.status, "queued"),
|
||||
lte(backgroundTasks.runAfter, now),
|
||||
sql`${backgroundTasks.attempts} < ${backgroundTasks.maxAttempts}`,
|
||||
))
|
||||
.orderBy(asc(backgroundTasks.runAfter), asc(backgroundTasks.createdAt))
|
||||
.limit(1);
|
||||
|
||||
@@ -1733,6 +1737,24 @@ export async function retryBackgroundTask(userId: number, taskId: string) {
|
||||
return getBackgroundTaskById(taskId);
|
||||
}
|
||||
|
||||
export async function failExhaustedBackgroundTasks(now: Date = new Date()) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(backgroundTasks).set({
|
||||
status: "failed",
|
||||
progress: 100,
|
||||
message: "任务达到最大重试次数,已停止自动重试",
|
||||
error: sql`coalesce(${backgroundTasks.error}, '任务达到最大重试次数')`,
|
||||
workerId: null,
|
||||
lockedAt: null,
|
||||
completedAt: now,
|
||||
}).where(and(
|
||||
eq(backgroundTasks.status, "queued"),
|
||||
lte(backgroundTasks.runAfter, now),
|
||||
sql`${backgroundTasks.attempts} >= ${backgroundTasks.maxAttempts}`,
|
||||
));
|
||||
}
|
||||
|
||||
export async function requeueStaleBackgroundTasks(staleBefore: Date) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
|
||||
@@ -41,8 +41,16 @@ describe("getRemoteMediaSession", () => {
|
||||
const session = await getRemoteMediaSession("session-1");
|
||||
|
||||
expect(session.id).toBe("session-1");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, "http://127.0.0.1:8081/sessions/session-1");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://127.0.0.1:8081/media/sessions/session-1");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://127.0.0.1:8081/sessions/session-1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://127.0.0.1:8081/media/sessions/session-1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the configured /media base URL directly when already present", async () => {
|
||||
@@ -68,6 +76,9 @@ describe("getRemoteMediaSession", () => {
|
||||
|
||||
expect(session.id).toBe("session-2");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith("http://media:8081/media/sessions/session-2");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://media:8081/media/sessions/session-2",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ENV } from "./_core/env";
|
||||
import { fetchWithTimeout } from "./_core/fetch";
|
||||
|
||||
export type RemoteMediaSession = {
|
||||
id: string;
|
||||
@@ -35,7 +36,11 @@ export async function getRemoteMediaSession(sessionId: string) {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const url of getMediaCandidateUrls(`/sessions/${encodeURIComponent(sessionId)}`)) {
|
||||
const response = await fetch(url);
|
||||
const response = await fetchWithTimeout(url, undefined, {
|
||||
timeoutMs: ENV.mediaFetchTimeoutMs,
|
||||
retries: ENV.mediaFetchRetryCount,
|
||||
retryMethods: ["GET"],
|
||||
});
|
||||
if (response.ok) {
|
||||
const payload = await response.json() as { session: RemoteMediaSession };
|
||||
return payload.session;
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ENV } from "./_core/env";
|
||||
import { storagePut } from "./storage";
|
||||
import * as db from "./db";
|
||||
import { nanoid } from "nanoid";
|
||||
import { getRemoteMediaSession } from "./mediaService";
|
||||
import { prepareCorrectionImageUrls } from "./taskWorker";
|
||||
import { toPublicUrl } from "./publicUrl";
|
||||
import { ACTION_LABELS, refreshUserNtrp, syncAnalysisTrainingData, syncLiveTrainingData } from "./trainingAutomation";
|
||||
@@ -602,11 +601,6 @@ export const appRouter = router({
|
||||
durationMinutes: z.number().min(1).max(720).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getRemoteMediaSession(input.sessionId);
|
||||
if (session.userId !== String(ctx.user.id)) {
|
||||
throw new Error("Media session not found");
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
userId: ctx.user.id,
|
||||
type: "media_finalize",
|
||||
|
||||
@@ -10,6 +10,7 @@ function sleep(ms: number) {
|
||||
}
|
||||
|
||||
async function workOnce() {
|
||||
await db.failExhaustedBackgroundTasks();
|
||||
await db.requeueStaleBackgroundTasks(new Date(Date.now() - ENV.backgroundTaskStaleMs));
|
||||
|
||||
const task = await db.claimNextBackgroundTask(workerId);
|
||||
@@ -17,6 +18,12 @@ async function workOnce() {
|
||||
return false;
|
||||
}
|
||||
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
void db.heartbeatBackgroundTask(task.id, workerId).catch((error) => {
|
||||
console.error(`[worker] heartbeat failed for ${task.id}:`, error);
|
||||
});
|
||||
}, ENV.backgroundTaskHeartbeatMs);
|
||||
|
||||
try {
|
||||
const result = await processBackgroundTask(task);
|
||||
if (result !== null) {
|
||||
@@ -27,6 +34,8 @@ async function workOnce() {
|
||||
await db.failBackgroundTask(task.id, message);
|
||||
await db.failVisionTestRun(task.id, message);
|
||||
console.error(`[worker] task ${task.id} failed:`, error);
|
||||
} finally {
|
||||
clearInterval(heartbeatTimer);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -232,6 +232,10 @@ function createTask(state: MockAppState, input: {
|
||||
progress: input.progress ?? 100,
|
||||
result: input.result ?? null,
|
||||
error: input.error ?? null,
|
||||
attempts: input.status === "failed" ? 2 : 1,
|
||||
maxAttempts: input.type === "media_finalize" ? 90 : 3,
|
||||
startedAt: nowIso(),
|
||||
completedAt: input.status === "queued" || input.status === "running" ? null : nowIso(),
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户