feat: add live camera multi-device viewer mode
这个提交包含在:
@@ -73,6 +73,67 @@ const trainingProfileUpdateSchema = z.object({
|
||||
assessmentNotes: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
const liveRuntimeSnapshotSchema = z.object({
|
||||
phase: z.enum(["idle", "analyzing", "saving", "safe", "failed"]).optional(),
|
||||
startedAt: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
currentAction: z.string().optional(),
|
||||
rawAction: z.string().optional(),
|
||||
feedback: z.array(z.string()).optional(),
|
||||
liveScore: z.record(z.string(), z.number()).nullable().optional(),
|
||||
stabilityMeta: z.record(z.string(), z.any()).optional(),
|
||||
visibleSegments: z.number().optional(),
|
||||
unknownSegments: z.number().optional(),
|
||||
archivedVideoCount: z.number().optional(),
|
||||
recentSegments: z.array(z.object({
|
||||
actionType: z.string(),
|
||||
isUnknown: z.boolean().optional(),
|
||||
startMs: z.number(),
|
||||
endMs: z.number(),
|
||||
durationMs: z.number(),
|
||||
confidenceAvg: z.number().optional(),
|
||||
score: z.number().optional(),
|
||||
clipLabel: z.string().optional(),
|
||||
})).optional(),
|
||||
}).passthrough();
|
||||
|
||||
function getRuntimeOwnerSid(ctx: { sessionSid: string | null; user: { openId: string } }) {
|
||||
return ctx.sessionSid || `legacy:${ctx.user.openId}`;
|
||||
}
|
||||
|
||||
async function resolveLiveRuntimeRole(params: {
|
||||
userId: number;
|
||||
sessionSid: string;
|
||||
}) {
|
||||
let runtime = await db.getUserLiveAnalysisRuntime(params.userId);
|
||||
if (!runtime) {
|
||||
return { role: "idle" as const, runtimeSession: null };
|
||||
}
|
||||
|
||||
const heartbeatAt = runtime.lastHeartbeatAt ?? runtime.updatedAt ?? runtime.startedAt;
|
||||
const isStale =
|
||||
runtime.status === "active" &&
|
||||
(!heartbeatAt || (Date.now() - heartbeatAt.getTime()) > db.LIVE_ANALYSIS_RUNTIME_TIMEOUT_MS);
|
||||
|
||||
if (isStale) {
|
||||
runtime = await db.endUserLiveAnalysisRuntime({
|
||||
userId: params.userId,
|
||||
runtimeId: runtime.id,
|
||||
snapshot: runtime.snapshot,
|
||||
}) ?? null as any;
|
||||
return { role: "idle" as const, runtimeSession: null };
|
||||
}
|
||||
|
||||
if (runtime.status !== "active") {
|
||||
return { role: "idle" as const, runtimeSession: runtime };
|
||||
}
|
||||
|
||||
return {
|
||||
role: runtime.ownerSid === params.sessionSid ? "owner" as const : "viewer" as const,
|
||||
runtimeSession: runtime,
|
||||
};
|
||||
}
|
||||
|
||||
export const appRouter = router({
|
||||
system: systemRouter,
|
||||
|
||||
@@ -455,6 +516,122 @@ export const appRouter = router({
|
||||
return { session, segments };
|
||||
}),
|
||||
|
||||
runtimeGet: protectedProcedure.query(async ({ ctx }) => {
|
||||
const sessionSid = getRuntimeOwnerSid(ctx);
|
||||
return resolveLiveRuntimeRole({
|
||||
userId: ctx.user.id,
|
||||
sessionSid,
|
||||
});
|
||||
}),
|
||||
|
||||
runtimeAcquire: protectedProcedure
|
||||
.input(z.object({
|
||||
title: z.string().min(1).max(256),
|
||||
sessionMode: z.enum(["practice", "pk"]).default("practice"),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const sessionSid = getRuntimeOwnerSid(ctx);
|
||||
const current = await resolveLiveRuntimeRole({
|
||||
userId: ctx.user.id,
|
||||
sessionSid,
|
||||
});
|
||||
|
||||
if (current.role === "viewer" && current.runtimeSession?.status === "active") {
|
||||
return current;
|
||||
}
|
||||
|
||||
const runtime = current.runtimeSession?.status === "active" && current.role === "owner"
|
||||
? await db.updateUserLiveAnalysisRuntime(ctx.user.id, {
|
||||
ownerSid: sessionSid,
|
||||
status: "active",
|
||||
title: input.title,
|
||||
sessionMode: input.sessionMode,
|
||||
startedAt: current.runtimeSession.startedAt ?? new Date(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
})
|
||||
: await db.upsertUserLiveAnalysisRuntime(ctx.user.id, {
|
||||
ownerSid: sessionSid,
|
||||
status: "active",
|
||||
title: input.title,
|
||||
sessionMode: input.sessionMode,
|
||||
mediaSessionId: null,
|
||||
startedAt: new Date(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
snapshot: {
|
||||
phase: "idle",
|
||||
startedAt: Date.now(),
|
||||
durationMs: 0,
|
||||
currentAction: "unknown",
|
||||
rawAction: "unknown",
|
||||
feedback: [],
|
||||
visibleSegments: 0,
|
||||
unknownSegments: 0,
|
||||
archivedVideoCount: 0,
|
||||
recentSegments: [],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
role: "owner" as const,
|
||||
runtimeSession: runtime ?? null,
|
||||
};
|
||||
}),
|
||||
|
||||
runtimeHeartbeat: protectedProcedure
|
||||
.input(z.object({
|
||||
runtimeId: z.number(),
|
||||
mediaSessionId: z.string().max(96).nullable().optional(),
|
||||
snapshot: liveRuntimeSnapshotSchema.optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const sessionSid = getRuntimeOwnerSid(ctx);
|
||||
const runtime = await db.updateLiveAnalysisRuntimeHeartbeat({
|
||||
userId: ctx.user.id,
|
||||
ownerSid: sessionSid,
|
||||
runtimeId: input.runtimeId,
|
||||
mediaSessionId: input.mediaSessionId,
|
||||
snapshot: input.snapshot,
|
||||
});
|
||||
|
||||
if (!runtime) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "当前设备不是实时分析持有端" });
|
||||
}
|
||||
|
||||
return {
|
||||
role: "owner" as const,
|
||||
runtimeSession: runtime,
|
||||
};
|
||||
}),
|
||||
|
||||
runtimeRelease: protectedProcedure
|
||||
.input(z.object({
|
||||
runtimeId: z.number().optional(),
|
||||
snapshot: liveRuntimeSnapshotSchema.optional(),
|
||||
}).optional())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const sessionSid = getRuntimeOwnerSid(ctx);
|
||||
const runtime = await db.endUserLiveAnalysisRuntime({
|
||||
userId: ctx.user.id,
|
||||
ownerSid: sessionSid,
|
||||
runtimeId: input?.runtimeId,
|
||||
snapshot: input?.snapshot,
|
||||
});
|
||||
|
||||
if (!runtime) {
|
||||
const current = await db.getUserLiveAnalysisRuntime(ctx.user.id);
|
||||
if (current?.status === "active" && current.ownerSid !== sessionSid) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "当前设备不是实时分析持有端" });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
runtimeSession: runtime ?? null,
|
||||
};
|
||||
}),
|
||||
|
||||
// Generate AI correction suggestions
|
||||
getCorrections: protectedProcedure
|
||||
.input(z.object({
|
||||
|
||||
在新工单中引用
屏蔽一个用户