Add multi-session auth and changelog tracking
这个提交包含在:
242
client/src/lib/actionRecognition.ts
普通文件
242
client/src/lib/actionRecognition.ts
普通文件
@@ -0,0 +1,242 @@
|
||||
export type ActionType =
|
||||
| "forehand"
|
||||
| "backhand"
|
||||
| "serve"
|
||||
| "volley"
|
||||
| "overhead"
|
||||
| "slice"
|
||||
| "lob"
|
||||
| "unknown";
|
||||
|
||||
export type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
visibility?: number;
|
||||
};
|
||||
|
||||
export type TrackingState = {
|
||||
prevTimestamp?: number;
|
||||
prevRightWrist?: Point;
|
||||
prevLeftWrist?: Point;
|
||||
prevHipCenter?: Point;
|
||||
};
|
||||
|
||||
export type ActionObservation = {
|
||||
action: ActionType;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export type ActionFrame = {
|
||||
action: ActionType;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export const ACTION_LABELS: Record<ActionType, string> = {
|
||||
forehand: "正手挥拍",
|
||||
backhand: "反手挥拍",
|
||||
serve: "发球",
|
||||
volley: "截击",
|
||||
overhead: "高压",
|
||||
slice: "切削",
|
||||
lob: "挑高球",
|
||||
unknown: "未知动作",
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function distance(a?: Point, b?: Point) {
|
||||
if (!a || !b) return 0;
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function getAngle(a?: Point, b?: Point, c?: Point) {
|
||||
if (!a || !b || !c) return 0;
|
||||
const radians = Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(a.y - b.y, a.x - b.x);
|
||||
let angle = Math.abs((radians * 180) / Math.PI);
|
||||
if (angle > 180) angle = 360 - angle;
|
||||
return angle;
|
||||
}
|
||||
|
||||
export function recognizeActionFrame(landmarks: Point[], tracking: TrackingState, timestamp: number): ActionFrame {
|
||||
const nose = landmarks[0];
|
||||
const leftShoulder = landmarks[11];
|
||||
const rightShoulder = landmarks[12];
|
||||
const leftElbow = landmarks[13];
|
||||
const rightElbow = landmarks[14];
|
||||
const leftWrist = landmarks[15];
|
||||
const rightWrist = landmarks[16];
|
||||
const leftHip = landmarks[23];
|
||||
const rightHip = landmarks[24];
|
||||
const leftKnee = landmarks[25];
|
||||
const rightKnee = landmarks[26];
|
||||
const leftAnkle = landmarks[27];
|
||||
const rightAnkle = landmarks[28];
|
||||
|
||||
const hipCenter = {
|
||||
x: ((leftHip?.x ?? 0.5) + (rightHip?.x ?? 0.5)) / 2,
|
||||
y: ((leftHip?.y ?? 0.7) + (rightHip?.y ?? 0.7)) / 2,
|
||||
};
|
||||
|
||||
const dtMs = tracking.prevTimestamp ? Math.max(16, timestamp - tracking.prevTimestamp) : 33;
|
||||
const rightSpeed = distance(rightWrist, tracking.prevRightWrist) * (1000 / dtMs);
|
||||
const leftSpeed = distance(leftWrist, tracking.prevLeftWrist) * (1000 / dtMs);
|
||||
const hipSpeed = distance(hipCenter, tracking.prevHipCenter) * (1000 / dtMs);
|
||||
const rightVerticalMotion = tracking.prevRightWrist ? tracking.prevRightWrist.y - (rightWrist?.y ?? tracking.prevRightWrist.y) : 0;
|
||||
|
||||
const shoulderTilt = Math.abs((leftShoulder?.y ?? 0.3) - (rightShoulder?.y ?? 0.3));
|
||||
const hipTilt = Math.abs((leftHip?.y ?? 0.55) - (rightHip?.y ?? 0.55));
|
||||
const headOffset = Math.abs((nose?.x ?? 0.5) - (((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2));
|
||||
const kneeBend = ((getAngle(leftHip, leftKnee, leftAnkle) || 165) + (getAngle(rightHip, rightKnee, rightAnkle) || 165)) / 2;
|
||||
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 landmarkVisibility = landmarks
|
||||
.filter((item) => typeof item?.visibility === "number")
|
||||
.map((item) => item.visibility as number);
|
||||
const averageVisibility = landmarkVisibility.length > 0
|
||||
? landmarkVisibility.reduce((sum, item) => sum + item, 0) / landmarkVisibility.length
|
||||
: 0.8;
|
||||
|
||||
tracking.prevTimestamp = timestamp;
|
||||
tracking.prevRightWrist = rightWrist;
|
||||
tracking.prevLeftWrist = leftWrist;
|
||||
tracking.prevHipCenter = hipCenter;
|
||||
|
||||
if (averageVisibility < 0.58 || shoulderSpan < 0.08 || footSpread < 0.05 || headOffset > 0.26) {
|
||||
return { action: "unknown", confidence: 0.28 };
|
||||
}
|
||||
|
||||
const serveConfidence = clamp(
|
||||
rightVerticalMotion * 2.2 +
|
||||
Math.max(0, (hipCenter.y - (rightWrist?.y ?? hipCenter.y)) * 3.4) +
|
||||
(rightWrist?.y ?? 1) < (nose?.y ?? 0.3) ? 0.34 : 0 +
|
||||
rightElbowAngle > 145 ? 0.12 : 0 -
|
||||
shoulderTilt * 1.8,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const overheadConfidence = clamp(
|
||||
serveConfidence * 0.62 +
|
||||
((rightWrist?.y ?? 1) < (nose?.y ?? 0.3) ? 0.22 : 0) +
|
||||
(rightSpeed > 0.34 ? 0.16 : 0) -
|
||||
(kneeBend < 150 ? 0.08 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const forehandConfidence = clamp(
|
||||
(rightSpeed * 1.5) +
|
||||
Math.max(0, rightForward * 2.3) +
|
||||
(rightElbowAngle > 120 ? 0.1 : 0) +
|
||||
(hipSpeed > 0.07 ? 0.08 : 0) +
|
||||
(footSpread > 0.12 ? 0.05 : 0) -
|
||||
shoulderTilt * 1.1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const backhandConfidence = clamp(
|
||||
(leftSpeed * 1.45) +
|
||||
Math.max(0, leftForward * 2.15) +
|
||||
(leftElbowAngle > 118 ? 0.1 : 0) +
|
||||
(wristSpread > shoulderSpan * 1.2 ? 0.08 : 0) +
|
||||
(torsoOffset > 0.04 ? 0.06 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const volleyConfidence = clamp(
|
||||
((rightSpeed + leftSpeed) * 0.8) +
|
||||
(footSpread < 0.12 ? 0.12 : 0) +
|
||||
(kneeBend < 155 ? 0.12 : 0) +
|
||||
(Math.abs(contactHeight) < 0.16 ? 0.1 : 0) +
|
||||
(hipSpeed > 0.08 ? 0.08 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const sliceConfidence = clamp(
|
||||
forehandConfidence * 0.68 +
|
||||
((rightWrist?.y ?? 0.5) > hipCenter.y ? 0.12 : 0) +
|
||||
(contactHeight < 0.05 ? 0.1 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const lobConfidence = clamp(
|
||||
overheadConfidence * 0.55 +
|
||||
((rightWrist?.y ?? 1) < (leftShoulder?.y ?? 0.3) ? 0.14 : 0) +
|
||||
(hipSpeed < 0.08 ? 0.06 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const candidates = ([
|
||||
["serve", serveConfidence],
|
||||
["overhead", overheadConfidence],
|
||||
["forehand", forehandConfidence],
|
||||
["backhand", backhandConfidence],
|
||||
["volley", volleyConfidence],
|
||||
["slice", sliceConfidence],
|
||||
["lob", lobConfidence],
|
||||
] as Array<[ActionType, number]>).sort((left, right) => right[1] - left[1]);
|
||||
|
||||
const [action, confidence] = candidates[0] || ["unknown", 0];
|
||||
if (confidence < 0.45) {
|
||||
return { action: "unknown", confidence: clamp(confidence, 0.18, 0.42) };
|
||||
}
|
||||
|
||||
return { action, confidence: clamp(confidence, 0, 1) };
|
||||
}
|
||||
|
||||
export function stabilizeActionFrame(frame: ActionFrame, history: ActionObservation[]) {
|
||||
const nextHistory = [...history, { action: frame.action, confidence: frame.confidence }].slice(-6);
|
||||
history.splice(0, history.length, ...nextHistory);
|
||||
|
||||
const weights = nextHistory.map((_, index) => index + 1);
|
||||
const scores = nextHistory.reduce<Record<ActionType, number>>((acc, sample, index) => {
|
||||
acc[sample.action] = (acc[sample.action] || 0) + sample.confidence * weights[index];
|
||||
return acc;
|
||||
}, {
|
||||
forehand: 0,
|
||||
backhand: 0,
|
||||
serve: 0,
|
||||
volley: 0,
|
||||
overhead: 0,
|
||||
slice: 0,
|
||||
lob: 0,
|
||||
unknown: 0,
|
||||
});
|
||||
|
||||
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]) as Array<[ActionType, number]>;
|
||||
const [winner = "unknown", winnerScore = 0] = ranked[0] || [];
|
||||
const [, runnerScore = 0] = ranked[1] || [];
|
||||
const winnerSamples = nextHistory.filter((sample) => sample.action === winner);
|
||||
const averageConfidence = winnerSamples.length > 0
|
||||
? winnerSamples.reduce((sum, sample) => sum + sample.confidence, 0) / winnerSamples.length
|
||||
: frame.confidence;
|
||||
|
||||
const stableAction =
|
||||
winner === "unknown" && frame.action !== "unknown" && frame.confidence >= 0.52
|
||||
? frame.action
|
||||
: winnerScore - runnerScore < 0.2 && frame.confidence >= 0.65
|
||||
? frame.action
|
||||
: winner;
|
||||
|
||||
return {
|
||||
action: stableAction,
|
||||
confidence: clamp(stableAction === frame.action ? Math.max(frame.confidence, averageConfidence) : averageConfidence, 0, 1),
|
||||
};
|
||||
}
|
||||
80
client/src/lib/changelog.ts
普通文件
80
client/src/lib/changelog.ts
普通文件
@@ -0,0 +1,80 @@
|
||||
export type ChangeLogEntry = {
|
||||
version: string;
|
||||
releaseDate: string;
|
||||
repoVersion: string;
|
||||
summary: string;
|
||||
features: string[];
|
||||
tests: string[];
|
||||
};
|
||||
|
||||
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
|
||||
{
|
||||
version: "2026.03.15-session-changelog",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "pending-commit",
|
||||
summary: "多端 session、更新日志页面、录制动作摘要与上海时区显示同步收口。",
|
||||
features: [
|
||||
"用户名登录生成独立 sid,同一账号多端登录保持并行有效",
|
||||
"新增 /changelog 页面和侧边栏入口,展示版本、仓库版本和验证记录",
|
||||
"训练进度页可展开查看最近训练记录的具体时间、动作统计和录制有效性",
|
||||
"录制页增加动作抽样摘要、无效录制标记与 media 预归档状态",
|
||||
"Dashboard、任务中心、管理台、评分、日志、视觉测试、视频库等页面统一使用 Asia/Shanghai 时间显示",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm test",
|
||||
"pnpm test:go",
|
||||
"pnpm build",
|
||||
"Playwright smoke: https://te.hao.work/ 双上下文登录 H1 后 dashboard 均保持有效;线上 /changelog 仍显示旧构建,待部署后复测",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-recorder-zoom",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "c4ec397",
|
||||
summary: "补齐录制页与实时分析页的节省流量模式、镜头缩放和移动端控制。",
|
||||
features: [
|
||||
"在线录制默认切换为节省流量模式",
|
||||
"在线录制支持镜头焦距放大缩小",
|
||||
"实时分析支持镜头焦距放大缩小",
|
||||
"页面内增加拍摄与流量设置说明",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run client/src/lib/media.test.ts client/src/lib/camera.test.ts",
|
||||
"Playwright 真实站点检查 /live-camera 与 /recorder 新控件可见",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-videos-crud",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "bd89981",
|
||||
summary: "视频库支持新增、编辑、删除训练视频记录。",
|
||||
features: [
|
||||
"视频库新增外部视频登记",
|
||||
"视频库支持编辑标题和动作类型",
|
||||
"视频库支持删除视频及关联分析引用",
|
||||
"视频详情读取按当前用户权限收敛",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/features.test.ts -t \"video\\\\.\"",
|
||||
"Playwright 真实站点完成 /videos 新增-编辑-删除全链路",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "v3.0.0",
|
||||
releaseDate: "2026-03-14",
|
||||
repoVersion: "历史版本",
|
||||
summary: "教程库、提醒、通知等学习能力上线。",
|
||||
features: [
|
||||
"训练视频教程库",
|
||||
"教程自评与学习进度",
|
||||
"训练提醒通知",
|
||||
"通知历史管理",
|
||||
],
|
||||
tests: [
|
||||
"教程库、提醒、通知相关测试通过",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -14,6 +14,12 @@ export type ArchiveStatus =
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export type PreviewStatus =
|
||||
| "idle"
|
||||
| "processing"
|
||||
| "ready"
|
||||
| "failed";
|
||||
|
||||
export type MediaMarker = {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -29,6 +35,7 @@ export type MediaSession = {
|
||||
title: string;
|
||||
status: MediaSessionStatus;
|
||||
archiveStatus: ArchiveStatus;
|
||||
previewStatus: PreviewStatus;
|
||||
format: string;
|
||||
mimeType: string;
|
||||
qualityPreset: string;
|
||||
@@ -37,8 +44,10 @@ export type MediaSession = {
|
||||
reconnectCount: number;
|
||||
uploadedSegments: number;
|
||||
uploadedBytes: number;
|
||||
previewSegments: number;
|
||||
durationMs: number;
|
||||
lastError?: string;
|
||||
previewUpdatedAt?: string;
|
||||
streamConnected: boolean;
|
||||
lastStreamAt?: string;
|
||||
playback: {
|
||||
|
||||
57
client/src/lib/time.ts
普通文件
57
client/src/lib/time.ts
普通文件
@@ -0,0 +1,57 @@
|
||||
const APP_TIME_ZONE = "Asia/Shanghai";
|
||||
|
||||
type DateLike = string | number | Date | null | undefined;
|
||||
|
||||
function toDate(value: DateLike) {
|
||||
if (value == null) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
export function formatDateTimeShanghai(
|
||||
value: DateLike,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
) {
|
||||
const date = toDate(value);
|
||||
if (!date) return "";
|
||||
return date.toLocaleString("zh-CN", {
|
||||
timeZone: APP_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: options?.second,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateShanghai(
|
||||
value: DateLike,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
) {
|
||||
const date = toDate(value);
|
||||
if (!date) return "";
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
timeZone: APP_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMonthDayShanghai(value: DateLike) {
|
||||
const date = toDate(value);
|
||||
if (!date) return "";
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
timeZone: APP_TIME_ZONE,
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function getAppTimeZoneLabel() {
|
||||
return APP_TIME_ZONE;
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户