diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..48fe731 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +NODE_ENV=production +PORT=3000 + +# App auth / storage / database +DATABASE_URL=mysql://user:password@127.0.0.1:4000/tennis_training_hub +JWT_SECRET=replace-with-strong-secret +VITE_APP_ID=tennis-training-hub +OAUTH_SERVER_URL= +OWNER_OPEN_ID= +BUILT_IN_FORGE_API_URL= +BUILT_IN_FORGE_API_KEY= +VITE_OAUTH_PORTAL_URL= +VITE_FRONTEND_FORGE_API_URL= +VITE_FRONTEND_FORGE_API_KEY= + +# Optional direct media URL override for browser builds +VITE_MEDIA_BASE_URL=/media + +# Local app-to-media proxy for development or direct container access +MEDIA_SERVICE_URL=http://127.0.0.1:8081 diff --git a/.gitignore b/.gitignore index 24ab932..41217e6 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,8 @@ pids # Coverage directory used by tools like istanbul coverage/ *.lcov +playwright-report/ +test-results/ # nyc test coverage .nyc_output diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02f6653 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM node:22-bookworm-slim AS deps +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +COPY patches ./patches +RUN pnpm install --frozen-lockfile + +FROM node:22-bookworm-slim AS build +WORKDIR /app +RUN corepack enable +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +FROM node:22-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +COPY patches ./patches +RUN pnpm install --prod --frozen-lockfile +COPY --from=build /app/dist ./dist +EXPOSE 3000 +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index af70b19..fa32339 100644 --- a/README.md +++ b/README.md @@ -1,186 +1,102 @@ -# Tennis Training Hub - AI网球训练助手 +# Tennis Training Hub -一个基于AI的在家网球训练平台,通过MediaPipe姿势识别技术帮助用户在只有球拍的条件下进行科学训练,自动分析挥拍姿势并生成个性化训练计划。 +AI 网球训练助手,提供训练计划、姿势分析、实时摄像头分析、在线视频录制与视频库管理。当前版本新增独立 Go 媒体服务,用于处理在线录制、分段上传、实时推流信令和归档回放。 -## 功能概览 +## Architecture -| 功能模块 | 描述 | 技术实现 | -|---------|------|---------| -| 用户名登录 | 无需注册,输入用户名即可使用 | tRPC + JWT Session | -| 训练计划生成 | 根据用户水平(初/中/高级)AI生成训练计划 | LLM结构化输出 | -| 视频上传分析 | 上传训练视频进行姿势识别 | MediaPipe Pose + S3 | -| 实时摄像头分析 | 手机/电脑摄像头实时捕捉分析 | MediaPipe实时推理 | -| 在线录制 | 稳定压缩流录制、断线重连、自动剪辑 | MediaRecorder API | -| 姿势矫正建议 | AI根据分析结果生成矫正方案 | LLM + 姿势数据 | -| NTRP自动评分 | 基于USTA标准的五维度加权评分 | 自动算法 | -| 训练计划自动调整 | 根据分析结果智能调整后续计划 | LLM + 历史数据 | -| 每日打卡 | 连续打卡追踪、训练时长记录 | 日期计算 + 数据库 | -| 成就徽章 | 24种成就徽章激励系统 | 自动检测 + 授予 | -| 社区排行榜 | NTRP评分、训练时长、击球数排名 | 数据库排序查询 | -| 训练进度追踪 | 可视化展示训练历史和改进趋势 | Recharts图表 | -| 视频库管理 | 保存管理所有训练视频及分析结果 | S3 + 数据库 | -| 移动端适配 | 全面响应式设计,手机摄像头优化 | Tailwind响应式 | +- `client/`: React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui +- `server/`: Express + tRPC + Drizzle + MySQL/TiDB,负责业务 API、登录、训练数据与视频库元数据 +- `media/`: Go 媒体服务,负责录制会话、分段上传、WebRTC 信令、关键片段标记与 FFmpeg 归档 +- `docker-compose.yml`: 单机部署编排 +- `deploy/nginx.te.hao.work.conf`: `te.hao.work` 的宿主机 nginx 入口配置 -## 技术栈 +## Online Recording -**前端:** -- React 19 + TypeScript -- Tailwind CSS 4 + shadcn/ui -- MediaPipe Pose(浏览器端姿势识别) -- Recharts(数据可视化) -- Framer Motion(动画效果) -- wouter(路由) +在线录制模块采用双链路设计: -**后端:** -- Express 4 + tRPC 11 -- Drizzle ORM + MySQL/TiDB -- S3文件存储 -- LLM集成(训练计划生成、姿势矫正建议) +- 浏览器端 `MediaRecorder` 本地压缩并每 60 秒自动分段上传 +- 浏览器端 `RTCPeerConnection` 同步建立 WebRTC 低延迟推流链路 +- 客户端运动检测自动写入关键片段 marker,也支持手动标记 +- 摄像头中断后自动重连,保留既有分段与会话 +- 服务端 worker 将分段合并归档,并产出 WebM 回放;FFmpeg 可用时额外生成 MP4 -## 项目结构 +## Quick Start -``` -tennis-training-hub/ -├── client/ -│ ├── src/ -│ │ ├── pages/ -│ │ │ ├── Home.tsx # 落地页 -│ │ │ ├── Login.tsx # 用户名登录 -│ │ │ ├── Dashboard.tsx # 仪表盘 -│ │ │ ├── Training.tsx # 训练计划 -│ │ │ ├── Analysis.tsx # 视频分析(MediaPipe) -│ │ │ ├── LiveCamera.tsx # 实时摄像头分析 -│ │ │ ├── Recorder.tsx # 在线录制 -│ │ │ ├── Videos.tsx # 视频库 -│ │ │ ├── Progress.tsx # 训练进度 -│ │ │ ├── Rating.tsx # NTRP评分详情 -│ │ │ ├── Leaderboard.tsx # 社区排行榜 -│ │ │ └── Checkin.tsx # 每日打卡+徽章 -│ │ ├── components/ -│ │ │ └── DashboardLayout.tsx # 侧边栏导航布局 -│ │ ├── App.tsx # 路由配置 -│ │ └── index.css # 主题样式 -│ └── index.html -├── server/ -│ ├── routers.ts # tRPC路由定义 -│ ├── db.ts # 数据库查询助手 -│ ├── storage.ts # S3存储助手 -│ ├── features.test.ts # 功能测试(47个) -│ └── _core/ # 框架核心(勿修改) -├── drizzle/ -│ └── schema.ts # 数据库表结构 -└── shared/ - └── const.ts # 共享常量 -``` - -## 数据库设计 - -### 核心表 - -| 表名 | 用途 | 关键字段 | -|------|------|---------| -| `users` | 用户信息 | openId, name, skillLevel, ntrpRating, totalSessions, currentStreak | -| `username_accounts` | 用户名登录映射 | username, userId | -| `training_plans` | AI训练计划 | exercises(JSON), skillLevel, durationDays, version | -| `training_videos` | 训练视频 | fileKey, url, format, analysisStatus | -| `pose_analyses` | 姿势分析结果 | overallScore, shotCount, avgSwingSpeed, strokeConsistency | -| `training_records` | 训练记录 | exerciseName, durationMinutes, completed, poseScore | -| `rating_history` | NTRP评分历史 | rating, dimensionScores(JSON), analysisId | -| `daily_checkins` | 每日打卡 | checkinDate, streakCount, minutesTrained | -| `user_badges` | 成就徽章 | badgeKey, earnedAt | - -## NTRP自动评分系统 - -评分基于USTA(美国网球协会)的NTRP标准,范围1.0-5.0,采用五维度加权计算: - -| 维度 | 权重 | 说明 | -|------|------|------| -| 姿势正确性 | 30% | 基于MediaPipe关键点角度分析 | -| 击球一致性 | 25% | 多次挥拍动作的稳定性 | -| 脚步移动 | 20% | 身体重心移动和步法评估 | -| 动作流畅性 | 15% | 挥拍动作的连贯性和自然度 | -| 力量表现 | 10% | 基于挥拍速度估算 | - -**评分映射规则:** -- 0-20分 → NTRP 1.0-1.5(初学者) -- 20-40分 → NTRP 1.5-2.5(初级) -- 40-60分 → NTRP 2.5-3.5(中级) -- 60-80分 → NTRP 3.5-4.5(中高级) -- 80-100分 → NTRP 4.5-5.0(高级) - -评分会根据最近20次视频分析结果自动更新,近期分析权重更高。 - -## 成就徽章系统 - -共24种成就徽章,分为6个类别: - -| 类别 | 徽章数 | 示例 | -|------|--------|------| -| 里程碑 | 1 | 初来乍到(首次登录) | -| 训练 | 6 | 初试身手、十次训练、百次训练、训练时长里程碑 | -| 连续打卡 | 4 | 三日坚持、一周达人、两周勇士、月度冠军 | -| 视频 | 3 | 影像记录、视频达人、视频大师 | -| 分析 | 4 | AI教练、优秀姿势、完美姿势、击球里程碑 | -| 评分 | 3 | NTRP 2.0/3.0/4.0 | - -## 在线录制功能 - -在线录制模块提供专业级录制体验: - -- **稳定压缩流**:使用MediaRecorder API,自适应码率(1-2.5Mbps),支持webm/mp4格式 -- **断线自动重连**:摄像头意外断开时自动检测并重新连接,保存已录制片段 -- **自动剪辑**:基于运动检测自动标记关键时刻,支持手动设置剪辑点 -- **分段录制**:每60秒自动分段,防止数据丢失 -- **手机摄像头优化**:支持前后摄像头切换,自适应分辨率 - -## 移动端适配 - -- 安全区域适配(iPhone X+刘海屏) -- 触摸友好的44px最小点击区域 -- 横屏视频优化 -- 防误触下拉刷新(录制/分析模式) -- 响应式侧边栏导航 -- 移动端底部导航栏 - -## 测试 - -项目包含47个vitest测试用例,覆盖所有核心后端功能: +### Local development ```bash -pnpm test -``` - -测试覆盖范围: -- 认证系统(登录、登出、用户名验证) -- 用户资料管理 -- 训练计划生成(输入验证) -- 视频上传和管理 -- 姿势分析保存和查询 -- 训练记录创建和完成 -- NTRP评分系统 -- 每日打卡系统 -- 成就徽章系统 -- 社区排行榜 - -## 开发 - -```bash -# 安装依赖 pnpm install - -# 启动开发服务器 +cp .env.example .env pnpm dev - -# 运行测试 -pnpm test - -# 类型检查 -pnpm check - -# 构建生产版本 -pnpm build ``` -## 许可证 +本地开发时: -MIT License +- Node 应用默认运行在 `http://localhost:3000` +- 若设置 `MEDIA_SERVICE_URL=http://127.0.0.1:8081`,Express 会把 `/media` 代理到 Go 服务 +- Go 媒体服务可单独启动: + +```bash +cd media +go mod tidy +go run . +``` + +### Checks + +```bash +pnpm check +pnpm test +pnpm test:go +pnpm build +pnpm test:e2e +pnpm verify + +cd media +go build ./... +``` + +首次运行浏览器测试前执行: + +```bash +pnpm exec playwright install chromium +``` + +## Production Deployment + +单机部署推荐: + +1. 宿主机 nginx 处理 `80/443` 和 TLS +2. `docker compose up -d --build` 启动 `app + media + worker` +3. nginx 将 `/` 转发到 `app:3000`,`/media/` 转发到 `media:8081` + +详细步骤见: + +- `docs/deploy.md` +- `docs/media-architecture.md` +- `docs/frontend-recording.md` + +## Documentation Index + +- `docs/FEATURES.md`: 当前功能特性与能力边界 +- `docs/testing.md`: 自动测试分层与运行方式 +- `docs/verified-features.md`: 已验证通过的项目清单 +- `docs/developer-workflow.md`: 阶段可中断的开发与本地提交流程 +- `docs/deploy.md`: 部署指南 +- `docs/media-architecture.md`: 媒体服务架构 +- `docs/frontend-recording.md`: 前端录制与移动端适配说明 + +## Environment + +关键环境变量见 `.env.example`,重点包括: + +- `DATABASE_URL` +- `JWT_SECRET` +- `MEDIA_SERVICE_URL` +- `VITE_MEDIA_BASE_URL` + +## Notes + +- 浏览器兼容目标以 Chrome 为主 +- 录制文件优先产出 WebM,MP4 为服务端可选归档产物 +- 存储策略当前为本地卷优先,适合单机 Compose 部署 diff --git a/client/index.html b/client/index.html index 6310b19..b34d061 100644 --- a/client/index.html +++ b/client/index.html @@ -15,10 +15,6 @@
- diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index f691ea0..2b3c6ea 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -44,6 +44,14 @@ const menuItems = [ { icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" }, ]; +const mobileNavItems = [ + { icon: LayoutDashboard, label: "首页", path: "/dashboard" }, + { icon: Target, label: "计划", path: "/training" }, + { icon: CircleDot, label: "录制", path: "/recorder" }, + { icon: FileVideo, label: "视频", path: "/videos" }, + { icon: Activity, label: "进度", path: "/progress" }, +]; + const SIDEBAR_WIDTH_KEY = "sidebar-width"; const DEFAULT_WIDTH = 260; const MIN_WIDTH = 200; @@ -309,7 +317,31 @@ function DashboardLayoutContent({ )} -
{children}
+
{children}
+ {isMobile && ( + + )} ); diff --git a/client/src/index.css b/client/src/index.css index ea5175b..355028b 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -175,6 +175,10 @@ .mobile-safe-top { padding-top: env(safe-area-inset-top, 0px); } + .mobile-safe-inline { + padding-left: env(safe-area-inset-left, 0px); + padding-right: env(safe-area-inset-right, 0px); + } } /* Touch-friendly tap targets */ @@ -215,8 +219,7 @@ html { /* Prevent pull-to-refresh during camera/recording */ .no-overscroll { - overscroll-behavior: none; - touch-action: none; + overscroll-behavior-y: contain; } /* Video container responsive */ diff --git a/client/src/lib/media.test.ts b/client/src/lib/media.test.ts new file mode 100644 index 0000000..9fafa4c --- /dev/null +++ b/client/src/lib/media.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { formatRecordingTime, pickBitrate } from "./media"; + +describe("media utilities", () => { + it("formats recording time with minute and second padding", () => { + expect(formatRecordingTime(0)).toBe("00:00"); + expect(formatRecordingTime(61_000)).toBe("01:01"); + expect(formatRecordingTime(12 * 60_000 + 9_000)).toBe("12:09"); + }); + + it("selects bitrates by preset and device class", () => { + expect(pickBitrate("economy", true)).toBe(1_000_000); + expect(pickBitrate("clarity", false)).toBe(2_500_000); + expect(pickBitrate("balanced", true)).toBe(1_400_000); + expect(pickBitrate("balanced", false)).toBe(1_900_000); + }); +}); diff --git a/client/src/lib/media.ts b/client/src/lib/media.ts new file mode 100644 index 0000000..f4256af --- /dev/null +++ b/client/src/lib/media.ts @@ -0,0 +1,158 @@ +export type MediaSessionStatus = + | "created" + | "recording" + | "streaming" + | "reconnecting" + | "finalizing" + | "archived" + | "failed"; + +export type ArchiveStatus = + | "idle" + | "queued" + | "processing" + | "completed" + | "failed"; + +export type MediaMarker = { + id: string; + type: string; + label: string; + timestampMs: number; + confidence?: number; + createdAt: string; +}; + +export type MediaSession = { + id: string; + userId: string; + title: string; + status: MediaSessionStatus; + archiveStatus: ArchiveStatus; + format: string; + mimeType: string; + qualityPreset: string; + facingMode: string; + deviceKind: string; + reconnectCount: number; + uploadedSegments: number; + uploadedBytes: number; + durationMs: number; + lastError?: string; + streamConnected: boolean; + lastStreamAt?: string; + playback: { + webmUrl?: string; + mp4Url?: string; + webmSize?: number; + mp4Size?: number; + ready: boolean; + previewUrl?: string; + }; + markers: MediaMarker[]; +}; + +const MEDIA_BASE = (import.meta.env.VITE_MEDIA_BASE_URL || "/media").replace(/\/$/, ""); + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${MEDIA_BASE}${path}`, init); + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + throw new Error(errorBody.error || errorBody.message || `Media service error (${response.status})`); + } + return response.json() as Promise; +} + +export async function createMediaSession(payload: { + userId: string; + title: string; + format: string; + mimeType: string; + qualityPreset: string; + facingMode: string; + deviceKind: string; +}) { + return request<{ session: MediaSession }>("/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function signalMediaSession(sessionId: string, payload: { sdp: string; type: string }) { + return request<{ sdp: string; type: string }>(`/sessions/${sessionId}/signal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function uploadMediaSegment( + sessionId: string, + sequence: number, + durationMs: number, + blob: Blob +) { + return request<{ session: MediaSession }>( + `/sessions/${sessionId}/segments?sequence=${sequence}&durationMs=${durationMs}`, + { + method: "POST", + headers: { "Content-Type": blob.type || "video/webm" }, + body: blob, + } + ); +} + +export async function createMediaMarker( + sessionId: string, + payload: { type: string; label: string; timestampMs: number; confidence?: number } +) { + return request<{ session: MediaSession }>(`/sessions/${sessionId}/markers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function finalizeMediaSession( + sessionId: string, + payload: { title: string; durationMs: number } +) { + return request<{ session: MediaSession }>(`/sessions/${sessionId}/finalize`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function getMediaSession(sessionId: string) { + return request<{ session: MediaSession }>(`/sessions/${sessionId}`); +} + +export function formatRecordingTime(milliseconds: number) { + const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; +} + +export function pickRecorderMimeType() { + const candidates = [ + "video/webm;codecs=vp9,opus", + "video/webm;codecs=vp8,opus", + "video/webm;codecs=h264,opus", + "video/webm", + ]; + return candidates.find((candidate) => window.MediaRecorder?.isTypeSupported(candidate)) || "video/webm"; +} + +export function pickBitrate(preset: string, isMobile: boolean) { + switch (preset) { + case "economy": + return 1_000_000; + case "clarity": + return 2_500_000; + default: + return isMobile ? 1_400_000 : 1_900_000; + } +} diff --git a/client/src/main.tsx b/client/src/main.tsx index 8adf6f5..075e0d6 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -52,6 +52,17 @@ const trpcClient = trpc.createClient({ ], }); +const analyticsEndpoint = import.meta.env.VITE_ANALYTICS_ENDPOINT; +const analyticsWebsiteId = import.meta.env.VITE_ANALYTICS_WEBSITE_ID; + +if (analyticsEndpoint && analyticsWebsiteId && typeof document !== "undefined") { + const script = document.createElement("script"); + script.defer = true; + script.src = `${analyticsEndpoint.replace(/\/$/, "")}/umami`; + script.dataset.websiteId = analyticsWebsiteId; + document.head.appendChild(script); +} + createRoot(document.getElementById("root")!).render( diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 75ed290..6cfebad 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -54,7 +54,7 @@ export default function Dashboard() { {/* Welcome header */}
-

+

欢迎回来,{user?.name || "球友"}

@@ -65,7 +65,7 @@ export default function Dashboard() {
- diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index 1d29ba3..efad014 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -341,7 +341,7 @@ export default function LiveCamera() {

摄像头未启动

-
@@ -357,7 +357,7 @@ export default function LiveCamera() { {/* Controls bar */}
{!cameraActive ? ( - ) : ( diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index 3670bf1..1de870f 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -42,13 +42,14 @@ export default function Login() { - 开始训练 + 开始训练 输入用户名即可开始使用
-
-
+
+ - {/* Settings dialog */} - - - - 录制设置 - 调整录制画质和参数 - -
-
- - {Object.entries(QUALITY_PRESETS).map(([key, preset]) => ( -
setQuality(key as keyof typeof QUALITY_PRESETS)} - className={`p-3 rounded-lg mb-2 cursor-pointer border transition-colors ${ - quality === key ? "border-primary bg-primary/5" : "border-transparent bg-muted/30 hover:bg-muted/50" - }`} - > -

{preset.label}

-

{preset.desc}

-
- ))} +
+
+
+
+ + + {statusBadge.label} + + + {isOnline ? : } + {isOnline ? "网络在线" : "离线缓存中"} + + + + WebRTC 推流 +
-
- - - - -
-
- {/* Camera/Preview */} -
- - -
- {/* Live camera */} -
- - {/* Controls */} -
- {state === "idle" && ( - <> - {!cameraActive ? ( - - ) : ( - <> - - {hasMultipleCameras && ( - - )} - - - )} - - )} - {state === "recording" && ( - <> - - - - )} - {state === "paused" && ( - <> - - - - )} - {state === "stopped" && ( - <> - - - - - )} -
-
-
-
- - {/* Right panel */} -
- {/* Upload card */} - {state === "stopped" && recordedBlob && ( - - - - 上传视频 - - - - setTitle(e.target.value)} - className="text-sm" - /> -
- 大小: {(recordedBlob.size / 1024 / 1024).toFixed(2)} MB · 时长: {formatTime(duration)} -
- {uploading && ( -
- -

{uploadProgress}%

-
- )} - -
-
- )} - - {/* Auto-clips */} - {clips.length > 0 && ( - - - - 自动剪辑片段 - - - - {clips.map((clip) => ( -
-
-

{clip.label}

-

- {formatTime(clip.startTime)} - {formatTime(clip.endTime)} ({clip.duration}s) -

-
- {clip.isKeyMoment && 关键} -
- ))} -
-
- )} - - {/* Recording info */} - - -

📹 录制提示

-
    -
  • · 录制自动使用稳定压缩流技术
  • -
  • · 断网时数据自动缓存,恢复后继续
  • -
  • · 支持暂停/继续录制
  • -
  • · 录制完成后可自动剪辑关键片段
  • -
  • · 建议横屏录制以获得最佳效果
  • -
-
-
-
-
- - {/* Clip editor dialog */} - - - - - 视频剪辑 - - 拖动滑块选择要保留的片段范围 - -
-
-
- 开始: {formatTime(Math.floor((clipRange[0] / 100) * duration))} - 结束: {formatTime(Math.floor((clipRange[1] / 100) * duration))} -
- setClipRange(v as [number, number])} - min={0} - max={100} - step={1} - className="w-full" - /> -

- 保留时长: {formatTime(Math.floor(((clipRange[1] - clipRange[0]) / 100) * duration))} +

+

在线录制控制台

+

+ 录制采用 Chrome 优先的 WebM 压缩流,60 秒自动分段上传,实时推流与本地压缩并行运行。弱网和摄像头中断时会保留已录片段并尝试自动恢复。

- - - - - -
+ +
+
+
录制时长
+
{formatRecordingTime(durationMs)}
+
+
+
已传片段
+
{uploadedSegments}
+
+
+
缓存队列
+
{queuedSegments}
+
+
+
+ + +
+
+ + +
+ {mode === "archived" && currentPlaybackUrl ? ( +
+ +
+
+ setTitle(event.target.value)} + placeholder="本次训练录制标题" + className="h-12 rounded-2xl border-border/60" + /> +
+ {mode === "idle" && ( + <> + {!cameraActive ? ( + + ) : ( + <> + + + + )} + {hasMultipleCameras && ( + + )} + + )} + + {(mode === "recording" || mode === "reconnecting") && ( + <> + + + + )} + + {mode === "archived" && ( + <> + {currentPlaybackUrl && ( + + )} + + + )} + + {mode === "finalizing" && ( + + )} +
+
+
+
+
+ + + + 质量与设备设置 + + +
+ {Object.entries(QUALITY_PRESETS).map(([key, preset]) => { + const active = qualityPreset === key; + return ( + + ); + })} +
+ +
+
+
设备方向
+
+ 当前优先使用 {facingMode === "environment" ? "后置摄像头" : "前置摄像头"},横屏时自动保留更大预览区域。 +
+
+
+
录制容器
+
+ {mimeType} · 每 {SEGMENT_LENGTH_MS / 1000} 秒自动分段上传,服务端归档后提供 WebM/MP4 回放。 +
+
+
+
+
+
+ + +
); } diff --git a/client/src/pages/Training.tsx b/client/src/pages/Training.tsx index 609694d..1233052 100644 --- a/client/src/pages/Training.tsx +++ b/client/src/pages/Training.tsx @@ -95,7 +95,7 @@ export default function Training() {
-

训练计划

+

训练计划

AI个性化训练方案

@@ -143,6 +143,7 @@ export default function Training() {
@@ -77,7 +77,7 @@ export default function Videos() { const status = statusMap[video.analysisStatus] || statusMap.pending; return ( - +
{/* Thumbnail / icon */} diff --git a/deploy/nginx.te.hao.work.conf b/deploy/nginx.te.hao.work.conf new file mode 100644 index 0000000..bf2ecd0 --- /dev/null +++ b/deploy/nginx.te.hao.work.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name te.hao.work; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name te.hao.work; + + ssl_certificate /etc/letsencrypt/live/te.hao.work/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/te.hao.work/privkey.pem; + + client_max_body_size 512m; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /media/ { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e0280c0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + environment: + PORT: 3000 + MEDIA_SERVICE_URL: http://media:8081 + NODE_ENV: production + ports: + - "3000:3000" + depends_on: + - media + restart: unless-stopped + + media: + build: + context: ./media + dockerfile: Dockerfile + environment: + MEDIA_ADDR: ":8081" + MEDIA_DATA_DIR: /data/media + MEDIA_EMBEDDED_WORKER: "0" + ports: + - "8081:8081" + volumes: + - media-data:/data/media + restart: unless-stopped + + worker: + build: + context: ./media + dockerfile: Dockerfile + command: ["media-service"] + environment: + MEDIA_MODE: worker + MEDIA_DATA_DIR: /data/media + volumes: + - media-data:/data/media + depends_on: + - media + restart: unless-stopped + +volumes: + media-data: diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 828f3d2..3d3bc79 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,94 +1,76 @@ -# Tennis Training Hub - 功能列表清单与开发记录 +# Tennis Training Hub 功能特性说明 -## 功能完成状态 +本文档描述当前项目的核心能力、已交付功能边界和后续增强方向。它和 `docs/verified-features.md` 配套使用: -### 核心功能 +- 本文档回答“系统现在具备什么能力” +- `verified-features.md` 回答“哪些能力已经通过自动测试或构建验证” -| 编号 | 功能 | 状态 | 版本 | 说明 | -|------|------|------|------|------| -| F-001 | 用户名简单登录 | 已完成 | v1.0 | 输入用户名即可登录,自动创建账户 | -| F-002 | 训练计划AI生成 | 已完成 | v1.0 | 支持初/中/高级,1-30天计划 | -| F-003 | 视频上传功能 | 已完成 | v1.0 | 支持webm/mp4格式,S3存储 | -| F-004 | MediaPipe姿势识别 | 已完成 | v1.0 | 浏览器端实时分析33个关键点 | -| F-005 | 姿势矫正建议 | 已完成 | v1.0 | AI根据分析数据生成矫正方案 | -| F-006 | 训练计划自动调整 | 已完成 | v1.0 | 基于分析结果智能调整计划 | -| F-007 | 训练进度追踪 | 已完成 | v1.0 | 可视化图表展示训练历史 | -| F-008 | 视频库管理 | 已完成 | v1.0 | 视频列表、详情、分析状态 | +## 核心业务能力 -### 参考tennis_analysis增强功能 +### 用户与训练 -| 编号 | 功能 | 状态 | 版本 | 说明 | -|------|------|------|------|------| -| F-009 | 击球次数统计 | 已完成 | v1.0 | 基于手腕关键点位移检测 | -| F-010 | 挥拍速度估算 | 已完成 | v1.0 | 手臂关键点帧间位移计算 | -| F-011 | 运动轨迹可视化 | 已完成 | v1.0 | 身体中心点移动轨迹绘制 | -| F-012 | 迷你球场叠加 | 已完成 | v1.0 | 视频分析界面球场示意图 | -| F-013 | 球员统计面板 | 已完成 | v1.0 | Dashboard综合数据展示 | -| F-014 | 帧级别关键时刻标注 | 已完成 | v1.0 | 自动标记击球、准备等关键帧 | +- 用户名登录:无需注册,输入用户名即可进入训练工作台 +- AI 训练计划:按技能等级和训练周期生成个性化训练计划 +- 训练进度:展示训练次数、时长、评分趋势、最近分析结果 +- 每日打卡与提醒:支持训练打卡、提醒、通知记录 -### NTRP评分系统 +### 视频与分析 -| 编号 | 功能 | 状态 | 版本 | 说明 | -|------|------|------|------|------| -| F-015 | NTRP自动评分 | 已完成 | v1.0 | 1.0-5.0评分,五维度加权 | -| F-016 | 历史评分自动更新 | 已完成 | v1.0 | 每次分析后自动重新计算 | -| F-017 | 多维度评分展示 | 已完成 | v1.0 | 雷达图展示五维度得分 | -| F-018 | 评分趋势图表 | 已完成 | v1.0 | 折线图展示评分变化趋势 | +- 视频上传分析:上传 `webm/mp4` 视频进入视频库并触发分析流程 +- 实时摄像头分析:浏览器端调用 MediaPipe,进行姿势识别和反馈展示 +- 视频库:集中展示录制结果、上传结果和分析摘要 -### v2.0 新增功能 +### 在线录制与媒体链路 -| 编号 | 功能 | 状态 | 版本 | 说明 | -|------|------|------|------|------| -| F-019 | 社区排行榜 - NTRP排名 | 已完成 | v2.0 | 按评分排序的用户排名 | -| F-020 | 社区排行榜 - 训练时长排名 | 已完成 | v2.0 | 按训练分钟排序 | -| F-021 | 社区排行榜 - 训练次数排名 | 已完成 | v2.0 | 按训练次数排序 | -| F-022 | 社区排行榜 - 击球数排名 | 已完成 | v2.0 | 按总击球数排序 | -| F-023 | 每日打卡系统 | 已完成 | v2.0 | 日历视图、连续天数追踪 | -| F-024 | 成就徽章系统 | 已完成 | v2.0 | 24种徽章,6个类别 | -| F-025 | 实时摄像头分析 | 已完成 | v2.0 | 手机/电脑摄像头实时捕捉 | -| F-026 | 摄像头位置确认提示 | 已完成 | v2.0 | 引导用户调整摄像头位置 | -| F-027 | 在线录制 | 已完成 | v2.0 | 稳定压缩流录制 | -| F-028 | 断线自动重连 | 已完成 | v2.0 | 摄像头断开自动恢复 | -| F-029 | 自动剪辑 | 已完成 | v2.0 | 基于运动检测标记关键片段 | -| F-030 | 移动端全面适配 | 已完成 | v2.0 | 响应式设计、安全区域、触摸优化 | -| F-031 | 手机摄像头优化 | 已完成 | v2.0 | 前后摄像头切换、自适应分辨率 | +- Go 媒体服务:独立处理录制会话、分段上传、marker、归档和回放资源 +- WebRTC 推流:录制时并行建立低延迟实时推流链路 +- MediaRecorder 分段:浏览器本地压缩录制并每 60 秒自动分段上传 +- 自动标记:客户端通过轻量运动检测创建关键片段 marker +- 手动标记:录制中支持手动插入剪辑点 +- 自动重连:摄像头 track 断开时自动尝试恢复 +- 归档回放:worker 合并片段并生成 WebM,FFmpeg 可用时额外生成 MP4 +- 视频库登记:归档完成后自动写回现有视频库 -### v3.0 新增功能 +## 前端体验能力 -| 编号 | 功能 | 状态 | 版本 | 说明 | -|------|------|------|------|------| -| F-032 | 训练视频教程库 | 已完成 | v3.0 | 分类浏览、要点说明、常见错误、学习进度 | -| F-033 | 教程自评系统 | 已完成 | v3.0 | 星级自评、学习笔记、已学标记 | -| F-034 | 训练提醒通知 | 已完成 | v3.0 | 多类型提醒、自定义时间和重复日期 | -| F-035 | 浏览器通知推送 | 已完成 | v3.0 | Notification API集成、权限管理 | -| F-036 | 通知记录管理 | 已完成 | v3.0 | 未读计数、全部已读、历史记录 | -| F-037 | 去除冗余文字 | 已完成 | v3.0 | 简化UI文案,直接信息反馈 | +### 移动端 -## 开发时间线 +- 安全区适配 +- 底部导航 +- 44px 触控热区 +- 横屏视频优先布局 +- 录制页和分析页防下拉刷新干扰 +- 录制时按设备场景自动调整码率和控件密度 -| 日期 | 版本 | 里程碑 | -|------|------|--------| -| 2026-03-14 | v1.0 | 项目初始化、数据库设计、核心功能开发 | -| 2026-03-14 | v1.0 | 完成所有核心页面、MediaPipe集成、NTRP评分 | -| 2026-03-14 | v2.0 | 添加排行榜、打卡、徽章、实时摄像头、在线录制 | -| 2026-03-14 | v2.0 | 移动端适配、测试套件、文档编写 | -| 2026-03-14 | v3.0 | 教程库、训练提醒、通知系统、文案优化 | +### 桌面端 -## 测试覆盖 +- 统一工作台导航 +- 仪表盘、训练、视频、录制、分析等模块一致的布局结构 +- 为后续 PC 粗剪时间线预留媒体域与文档规范 -| 模块 | 测试数 | 覆盖内容 | -|------|--------|---------| -| auth | 5 | me查询、logout、用户名登录验证 | -| profile | 4 | 认证检查、技能等级验证 | -| plan | 5 | 生成验证、列表、激活计划、调整 | -| video | 4 | 上传验证、列表、详情 | -| analysis | 4 | 保存验证、矫正建议、列表、视频查询 | -| record | 4 | 创建验证、完成、列表 | -| rating | 2 | 历史、当前评分 | -| checkin | 5 | 今日状态、打卡、历史 | -| badge | 5 | 列表、检查、定义、数据完整性 | -| leaderboard | 3 | 认证、排序参数、无效参数 | -| tutorial | 4 | 列表查询、分类过滤、进度更新 | -| reminder | 5 | 创建验证、切换、删除、认证 | -| notification | 4 | 列表、未读计数、标记已读 | -| **总计** | **65** | **全部通过** | +## 架构能力 + +- Node 应用负责业务 API、登录、训练数据与视频库元数据 +- Go 服务负责媒体链路与归档 +- `Docker Compose + 宿主机 nginx` 作为标准单机部署方式 +- 统一的本地验证命令: + - `pnpm check` + - `pnpm test` + - `pnpm test:go` + - `pnpm build` + - `pnpm test:e2e` + - `pnpm verify` + +## 已知边界 + +- 浏览器录制兼容目标以 Chrome 为主 +- 当前 WebRTC 重点是浏览器到服务端的实时上行,不是多观众直播分发 +- 当前 PC 剪辑仍处于基础媒体域准备阶段,未交付完整多轨编辑器 +- 当前存储策略为本地卷优先,未接入对象存储归档 + +## 后续增强方向 + +- PC 时间线粗剪与 clip plan 持久化 +- 更细粒度的设备能力自适应 +- 更强的媒体回放和片段导出能力 +- 更深入的前端域拆分和懒加载优化 diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..d81d3ce --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,60 @@ +# Deployment Guide + +## Topology + +- 宿主机 nginx:负责 `te.hao.work` 的 TLS、反向代理与大文件上传入口 +- `app` 容器:Node 应用,端口 `3000` +- `media` 容器:Go 媒体服务,端口 `8081` +- `worker` 容器:Go 媒体归档 worker,共享媒体卷 +- `media-data` 卷:录制片段、会话状态、归档成片 + +## Required files + +- `.env` +- `docker-compose.yml` +- `deploy/nginx.te.hao.work.conf` + +## Startup + +```bash +cp .env.example .env +docker compose up -d --build +``` + +## nginx + +将 `deploy/nginx.te.hao.work.conf` 放到宿主机 nginx 站点目录,确认: + +- `ssl_certificate` +- `ssl_certificate_key` +- `proxy_pass http://127.0.0.1:3000` 对应前端与业务 API +- `proxy_pass http://127.0.0.1:8081` 对应媒体服务 + +启用后重载 nginx: + +```bash +nginx -t +systemctl reload nginx +``` + +## Health checks + +- `curl http://127.0.0.1:3000/api/trpc/auth.me` +- `curl http://127.0.0.1:8081/media/health` + +## Persistent data + +媒体数据默认位于 Docker volume `media-data` 下,目录结构: + +- `sessions//session.json` +- `sessions//segments/*.webm` +- `public/sessions//recording.webm` +- `public/sessions//recording.mp4` + +## Rollback + +1. 保留 `.env` 和 `media-data` +2. 回退 Git 版本 +3. 重新执行 `docker compose up -d --build` + +如果只需停止录制链路,可单独关闭 `media` 与 `worker`,主站业务仍可继续运行。 diff --git a/docs/developer-workflow.md b/docs/developer-workflow.md new file mode 100644 index 0000000..009af3f --- /dev/null +++ b/docs/developer-workflow.md @@ -0,0 +1,73 @@ +# Developer Workflow + +## Working model + +本项目采用“阶段可停可跑”的开发方式。任何较大的改动都应满足: + +- 阶段结束即可本地启动 +- 阶段结束即可执行验证命令 +- 阶段结束即可提交本地 commit + +## Recommended loop + +```bash +pnpm check +pnpm test +pnpm test:go +pnpm build +pnpm test:e2e +``` + +全部通过后再提交: + +```bash +git status +git add . +git commit -m "..." +``` + +## Interrupt-safe development + +如果业务开发中被打断,恢复时按以下顺序: + +1. `git status` 查看当前工作树 +2. 先跑 `pnpm check` +3. 再跑 `pnpm test` +4. 若涉及媒体链路,再跑 `pnpm test:go` +5. 最后跑 `pnpm test:e2e` + +不要在一半状态下长时间保留“能编译但主流程不可用”的改动。 + +## Media-related changes + +修改录制链路时至少检查: + +- `client/src/lib/media.ts` +- `client/src/pages/Recorder.tsx` +- `media/main.go` +- `server/routers.ts` +- `server/_core/mediaProxy.ts` + +媒体改动完成后至少验证: + +- 会话创建 +- marker 写入 +- finalize +- 视频库登记 + +## Documentation discipline + +以下改动必须同步更新文档: + +- 新增脚本或验证入口 +- 新增或变更媒体 API +- 部署拓扑变化 +- 功能能力边界变化 +- 新增自动化测试覆盖范围 + +至少更新: + +- `README.md` +- `docs/testing.md` +- `docs/verified-features.md` +- 相关专题文档 diff --git a/docs/frontend-recording.md b/docs/frontend-recording.md new file mode 100644 index 0000000..8cd7454 --- /dev/null +++ b/docs/frontend-recording.md @@ -0,0 +1,44 @@ +# Frontend Recording Flow + +## UX goals + +- Chrome 优先的低流量录制 +- 录制和实时推流并行 +- 断线后尽量自动恢复 +- 移动端可直接使用 + +## Browser pipeline + +1. 用户启动摄像头预览 +2. 点击开始录制时创建媒体会话 +3. 复用已有预览流,避免重复申请摄像头 +4. 同一条 `MediaStream` 同时接入: + - `MediaRecorder` + - `RTCPeerConnection` +5. 每 60 秒主动 `requestData()` 形成一个上传分段 +6. 画面抽样比较生成自动 marker +7. 结束录制后 flush 队列并调用 finalize +8. 轮询归档状态,完成后把结果注册进视频库 + +## Mobile adaptation + +- 使用安全区样式 `env(safe-area-inset-*)` +- 底部固定导航 +- 最小点击区域 `44px` +- 横屏时保留尽量大的预览画面 +- 录制页容器使用 `overscroll-behavior-y: contain`,避免下拉刷新干扰 + +## Reconnect behavior + +- 摄像头 track 结束时触发重连 +- 主动停止摄像头时使用 suppression 标志,避免误判为故障 +- 重连过程: + - 停止 recorder + - 保留已上传和待上传分段 + - 重新获取摄像头 + - 重建 WebRTC 连接 + - 恢复 recorder + +## Video library sync + +归档完成后前端调用 `video.registerExternal`,把回放资源登记到现有视频库中,避免重写整个视频管理模块。 diff --git a/docs/media-architecture.md b/docs/media-architecture.md new file mode 100644 index 0000000..45d9377 --- /dev/null +++ b/docs/media-architecture.md @@ -0,0 +1,65 @@ +# Media Architecture + +## Responsibilities + +Node 应用负责: + +- 用户登录 +- 训练计划与分析业务 +- 视频库元数据写入 +- 开发时 `/media` 同源代理 + +Go 媒体服务负责: + +- 创建录制会话 +- WebRTC 信令交换 +- 接收分段上传 +- 写入关键片段标记 +- 会话 finalize +- 归档与回放资源生成 + +## HTTP API + +- `POST /media/sessions` +- `POST /media/sessions/{id}/signal` +- `POST /media/sessions/{id}/segments?sequence={n}&durationMs={ms}` +- `POST /media/sessions/{id}/markers` +- `POST /media/sessions/{id}/finalize` +- `GET /media/sessions/{id}` +- `GET /media/sessions/{id}/playback` +- `GET /media/health` + +## Session lifecycle + +- `created`: 会话已建立,等待录制 +- `recording`: 正在录制或接收分段 +- `streaming`: WebRTC 连接已建立 +- `reconnecting`: 摄像头或连接中断,客户端正在恢复 +- `finalizing`: 会话完成,等待 worker 归档 +- `archived`: 回放已生成 +- `failed`: 上传、信令或归档失败 + +## Storage model + +每个会话目录包含: + +- `session.json`: 会话状态和 marker 元数据 +- `segments/`: 原始分段 +- `concat.txt`: FFmpeg 拼接清单 +- `public/sessions//recording.webm` +- `public/sessions//recording.mp4` + +## Archive flow + +1. 浏览器 `finalize` +2. 会话状态变为 `ArchiveQueued` +3. worker 读取全部分段 +4. 优先直接 concat,失败则重新编码为 WebM +5. 可用时生成 MP4 归档 +6. 写回 playback URL 和文件大小 + +## Constraints + +- 当前为单机本地卷模型,不依赖对象存储 +- 当前 WebRTC 仅用于浏览器到服务端的实时上行,不做多观众直播分发 +- Safari 原生 MP4 录制不在当前目标内 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..ec8fdc0 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,77 @@ +# Testing Guide + +## Test layers + +项目当前采用四层测试结构: + +### 1. 静态检查 + +- `pnpm check` +- `pnpm build` +- `go build ./...` + +用于保证类型、打包和 Go 媒体服务编译可通过。 + +### 2. 单元测试 + +- `pnpm test` + +当前覆盖: + +- Node/tRPC 路由输入校验与权限检查 +- 媒体工具函数,例如录制时长格式化与码率选择 + +### 3. Go 媒体服务测试 + +- `pnpm test:go` + +当前覆盖: + +- `media/health` +- 会话创建与状态聚合 +- 归档流程的基础回放产物生成 + +### 4. 浏览器 E2E + +- `pnpm test:e2e` + +使用 Playwright。为保证稳定性: + +- 启动本地测试服务器 `pnpm dev:test` +- 通过路由拦截模拟 tRPC 和 `/media` 接口 +- 注入假媒体设备、假 `MediaRecorder` 和假 `RTCPeerConnection` + +这样可以自动验证前端主流程,而不依赖数据库、真实摄像头权限和真实 WebRTC 网络环境。 + +## Unified verification + +一次性执行全部自动验证: + +```bash +pnpm verify +``` + +执行顺序: + +1. `pnpm check` +2. `pnpm test` +3. `pnpm test:go` +4. `pnpm build` +5. `pnpm test:e2e` + +## Local browser prerequisites + +首次运行 Playwright 前执行: + +```bash +pnpm exec playwright install chromium +``` + +## Notes + +- E2E 目前验证的是“模块主流程是否正常”,不是媒体编码质量本身 +- 若需要真实录制验证,可额外用本地 Chrome 和真实摄像头做手工联调 +- 若 `pnpm test:e2e` 失败,优先检查: + - `PORT=3100` 是否被占用 + - 浏览器依赖是否安装 + - 前端路由或测试标识是否被改动 diff --git a/docs/verified-features.md b/docs/verified-features.md new file mode 100644 index 0000000..ef3f80b --- /dev/null +++ b/docs/verified-features.md @@ -0,0 +1,69 @@ +# Verified Features + +本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-14 21:44 CST。 + +## 最新完整验证记录 + +- 通过命令:`pnpm verify` +- 验证时间:2026-03-14 21:44 CST +- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(69/69),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(5/5) + +## 构建与编译通过 + +| 项目 | 验证方式 | 状态 | +|------|----------|------| +| TypeScript 类型检查 | `pnpm check` | 通过 | +| Node 应用生产构建 | `pnpm build` | 通过 | +| Go 媒体服务编译 | `pnpm test:go` 中的 `go build ./...` | 通过 | + +## 单元与路由验证 + +| 模块 | 验证方式 | 状态 | +|------|----------|------| +| auth | `pnpm test` | 通过 | +| profile | `pnpm test` | 通过 | +| plan | `pnpm test` | 通过 | +| video | `pnpm test` | 通过 | +| analysis | `pnpm test` | 通过 | +| record | `pnpm test` | 通过 | +| rating | `pnpm test` | 通过 | +| checkin | `pnpm test` | 通过 | +| badge | `pnpm test` | 通过 | +| leaderboard | `pnpm test` | 通过 | +| tutorial / reminder / notification 路由校验 | `pnpm test` | 通过 | +| media 工具函数 | `pnpm test` | 通过 | + +## Go 媒体服务验证 + +| 功能 | 验证方式 | 状态 | +|------|----------|------| +| `/media/health` | `go test ./...` | 通过 | +| 会话状态聚合 | `go test ./...` | 通过 | +| 单片段归档回放产物生成 | `go test ./...` | 通过 | + +## 浏览器 E2E 已验证主流程 + +| 模块 | 验证内容 | 状态 | +|------|----------|------| +| 登录 | 用户名输入、登录提交、跳转仪表盘 | 通过 | +| 仪表盘 | 认证后主标题与入口按钮渲染 | 通过 | +| 训练计划 | 训练计划页加载与生成入口可见 | 通过 | +| 视频库 | 视频卡片渲染 | 通过 | +| 实时分析 | 摄像头启动入口渲染 | 通过 | +| 在线录制 | 启动摄像头、开始录制、手动标记、结束归档 | 通过 | +| 录制结果入库 | 归档完成后视频库可见录制结果 | 通过 | + +## 已知非阻断警告 + +- 测试与开发日志中会出现 `OAUTH_SERVER_URL` 未配置提示;当前 mocked auth 和本地验证链路不依赖真实 OAuth 服务,因此不会导致失败 +- `pnpm build` 仍有 Vite 大 chunk 警告;当前属于性能优化待办,不影响本次产物生成 +- Playwright 运行依赖 mocked media/network,不等价于真机摄像头、真实弱网和真实 WebRTC 质量验收 + +## 当前未纳入自动验证的内容 + +- 真实摄像头权限与真实编码质量 +- 真实 WebRTC 网络连通性 +- 真正的 FFmpeg 多片段重编码质量 +- 真机 iOS / Android 浏览器的真实媒体兼容差异 + +以上内容仍建议在预发或本地联调时补充人工验证。 diff --git a/media/Dockerfile b/media/Dockerfile new file mode 100644 index 0000000..0641dab --- /dev/null +++ b/media/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.23-bookworm AS build +WORKDIR /src +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/media-service ./main.go + +FROM debian:bookworm-slim +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates ffmpeg \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /out/media-service /usr/local/bin/media-service +ENV MEDIA_ADDR=:8081 +ENV MEDIA_DATA_DIR=/data/media +EXPOSE 8081 +CMD ["media-service"] diff --git a/media/go.mod b/media/go.mod new file mode 100644 index 0000000..ccc82ad --- /dev/null +++ b/media/go.mod @@ -0,0 +1,28 @@ +module tennis-training-hub/media + +go 1.23.0 + +require github.com/pion/webrtc/v4 v4.1.2 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/pion/datachannel v1.5.10 // indirect + github.com/pion/dtls/v3 v3.0.6 // indirect + github.com/pion/ice/v4 v4.0.10 // indirect + github.com/pion/interceptor v0.1.40 // indirect + github.com/pion/logging v0.2.3 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.15 // indirect + github.com/pion/rtp v1.8.18 // indirect + github.com/pion/sctp v1.8.39 // indirect + github.com/pion/sdp/v3 v3.0.13 // indirect + github.com/pion/srtp/v3 v3.0.5 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v4 v4.0.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/media/go.sum b/media/go.sum new file mode 100644 index 0000000..853856c --- /dev/null +++ b/media/go.sum @@ -0,0 +1,50 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= +github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= +github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= +github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= +github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= +github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= +github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU= +github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= +github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4= +github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo= +github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= +github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= +github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/media/main.go b/media/main.go new file mode 100644 index 0000000..1c12478 --- /dev/null +++ b/media/main.go @@ -0,0 +1,861 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/pion/webrtc/v4" +) + +type SessionStatus string + +const ( + StatusCreated SessionStatus = "created" + StatusRecording SessionStatus = "recording" + StatusStreaming SessionStatus = "streaming" + StatusReconnecting SessionStatus = "reconnecting" + StatusFinalizing SessionStatus = "finalizing" + StatusArchived SessionStatus = "archived" + StatusFailed SessionStatus = "failed" +) + +type ArchiveStatus string + +const ( + ArchiveIdle ArchiveStatus = "idle" + ArchiveQueued ArchiveStatus = "queued" + ArchiveProcessing ArchiveStatus = "processing" + ArchiveCompleted ArchiveStatus = "completed" + ArchiveFailed ArchiveStatus = "failed" +) + +type PlaybackInfo struct { + WebMURL string `json:"webmUrl,omitempty"` + MP4URL string `json:"mp4Url,omitempty"` + WebMSize int64 `json:"webmSize,omitempty"` + MP4Size int64 `json:"mp4Size,omitempty"` + Ready bool `json:"ready"` + PreviewURL string `json:"previewUrl,omitempty"` +} + +type SegmentMeta struct { + Sequence int `json:"sequence"` + Filename string `json:"filename"` + DurationMS int64 `json:"durationMs"` + SizeBytes int64 `json:"sizeBytes"` + UploadedAt string `json:"uploadedAt"` + ContentType string `json:"contentType"` +} + +type Marker struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` + Timestamp int64 `json:"timestampMs"` + Confidence float64 `json:"confidence,omitempty"` + CreatedAt string `json:"createdAt"` +} + +type Session struct { + ID string `json:"id"` + UserID string `json:"userId"` + Title string `json:"title"` + Status SessionStatus `json:"status"` + ArchiveStatus ArchiveStatus `json:"archiveStatus"` + Format string `json:"format"` + MimeType string `json:"mimeType"` + QualityPreset string `json:"qualityPreset"` + FacingMode string `json:"facingMode"` + DeviceKind string `json:"deviceKind"` + ReconnectCount int `json:"reconnectCount"` + UploadedSegments int `json:"uploadedSegments"` + UploadedBytes int64 `json:"uploadedBytes"` + DurationMS int64 `json:"durationMs"` + LastError string `json:"lastError,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + FinalizedAt string `json:"finalizedAt,omitempty"` + StreamConnected bool `json:"streamConnected"` + LastStreamAt string `json:"lastStreamAt,omitempty"` + Playback PlaybackInfo `json:"playback"` + Segments []SegmentMeta `json:"segments"` + Markers []Marker `json:"markers"` +} + +func (s *Session) recomputeAggregates() { + s.UploadedSegments = len(s.Segments) + var totalBytes int64 + var totalDuration int64 + for _, segment := range s.Segments { + totalBytes += segment.SizeBytes + totalDuration += segment.DurationMS + } + s.UploadedBytes = totalBytes + if totalDuration > 0 { + s.DurationMS = totalDuration + } +} + +type CreateSessionRequest struct { + UserID string `json:"userId"` + Title string `json:"title"` + Format string `json:"format"` + MimeType string `json:"mimeType"` + QualityPreset string `json:"qualityPreset"` + FacingMode string `json:"facingMode"` + DeviceKind string `json:"deviceKind"` +} + +type SignalRequest struct { + SDP string `json:"sdp"` + Type string `json:"type"` +} + +type MarkerRequest struct { + Type string `json:"type"` + Label string `json:"label"` + Timestamp int64 `json:"timestampMs"` + Confidence float64 `json:"confidence,omitempty"` +} + +type FinalizeRequest struct { + Title string `json:"title"` + DurationMS int64 `json:"durationMs"` +} + +type sessionStore struct { + rootDir string + public string + mu sync.RWMutex + sessions map[string]*Session + peers map[string]*webrtc.PeerConnection +} + +func newSessionStore(rootDir string) (*sessionStore, error) { + store := &sessionStore{ + rootDir: rootDir, + public: filepath.Join(rootDir, "public"), + sessions: map[string]*Session{}, + peers: map[string]*webrtc.PeerConnection{}, + } + if err := os.MkdirAll(filepath.Join(rootDir, "sessions"), 0o755); err != nil { + return nil, err + } + if err := os.MkdirAll(store.public, 0o755); err != nil { + return nil, err + } + if err := store.load(); err != nil { + return nil, err + } + for _, session := range store.sessions { + session.recomputeAggregates() + } + return store, nil +} + +func (s *sessionStore) load() error { + pattern := filepath.Join(s.rootDir, "sessions", "*", "session.json") + files, err := filepath.Glob(pattern) + if err != nil { + return err + } + for _, file := range files { + body, readErr := os.ReadFile(file) + if readErr != nil { + continue + } + var session Session + if unmarshalErr := json.Unmarshal(body, &session); unmarshalErr != nil { + continue + } + s.sessions[session.ID] = &session + } + return nil +} + +func (s *sessionStore) sessionDir(id string) string { + return filepath.Join(s.rootDir, "sessions", id) +} + +func (s *sessionStore) segmentsDir(id string) string { + return filepath.Join(s.sessionDir(id), "segments") +} + +func (s *sessionStore) publicDir(id string) string { + return filepath.Join(s.public, "sessions", id) +} + +func (s *sessionStore) saveSession(session *Session) error { + session.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + dir := s.sessionDir(session.ID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + body, err := json.MarshalIndent(session, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "session.json"), body, 0o644) +} + +func cloneSession(session *Session) *Session { + body, _ := json.Marshal(session) + var copy Session + _ = json.Unmarshal(body, ©) + return © +} + +func (s *sessionStore) createSession(input CreateSessionRequest) (*Session, error) { + now := time.Now().UTC().Format(time.RFC3339) + session := &Session{ + ID: randomID(), + UserID: strings.TrimSpace(input.UserID), + Title: strings.TrimSpace(input.Title), + Status: StatusCreated, + ArchiveStatus: ArchiveIdle, + Format: defaultString(input.Format, "webm"), + MimeType: defaultString(input.MimeType, "video/webm"), + QualityPreset: defaultString(input.QualityPreset, "balanced"), + FacingMode: defaultString(input.FacingMode, "environment"), + DeviceKind: defaultString(input.DeviceKind, "desktop"), + CreatedAt: now, + UpdatedAt: now, + Segments: []SegmentMeta{}, + Markers: []Marker{}, + } + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[session.ID] = session + if err := os.MkdirAll(s.segmentsDir(session.ID), 0o755); err != nil { + return nil, err + } + if err := s.saveSession(session); err != nil { + return nil, err + } + return cloneSession(session), nil +} + +func (s *sessionStore) getSession(id string) (*Session, error) { + s.mu.RLock() + defer s.mu.RUnlock() + session, ok := s.sessions[id] + if !ok { + return nil, errors.New("session not found") + } + return cloneSession(session), nil +} + +func (s *sessionStore) replacePeer(id string, peer *webrtc.PeerConnection) { + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.peers[id]; ok { + _ = existing.Close() + } + s.peers[id] = peer +} + +func (s *sessionStore) closePeer(id string) { + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.peers[id]; ok { + _ = existing.Close() + delete(s.peers, id) + } +} + +func (s *sessionStore) updateSession(id string, update func(*Session) error) (*Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + session, ok := s.sessions[id] + if !ok { + return nil, errors.New("session not found") + } + if err := update(session); err != nil { + return nil, err + } + session.recomputeAggregates() + if err := s.saveSession(session); err != nil { + return nil, err + } + return cloneSession(session), nil +} + +func (s *sessionStore) listFinalizingSessions() []*Session { + s.mu.RLock() + defer s.mu.RUnlock() + items := make([]*Session, 0, len(s.sessions)) + for _, session := range s.sessions { + if session.ArchiveStatus == ArchiveQueued || session.ArchiveStatus == ArchiveProcessing { + items = append(items, cloneSession(session)) + } + } + return items +} + +type mediaServer struct { + store *sessionStore +} + +func newMediaServer(store *sessionStore) *mediaServer { + return &mediaServer{store: store} +} + +func (m *mediaServer) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/media/health", m.handleHealth) + mux.HandleFunc("/media/sessions", m.handleSessions) + mux.HandleFunc("/media/sessions/", m.handleSession) + fileServer := http.FileServer(http.Dir(m.store.public)) + mux.Handle("/media/assets/", http.StripPrefix("/media/assets/", cacheControl(fileServer))) + return withCORS(mux) +} + +func (m *mediaServer) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (m *mediaServer) handleSessions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var input CreateSessionRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + session, err := m.store.createSession(input) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"session": session}) +} + +func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/media/sessions/") + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) == 0 || parts[0] == "" { + http.NotFound(w, r) + return + } + sessionID := parts[0] + if len(parts) == 1 && r.Method == http.MethodGet { + session, err := m.store.getSession(sessionID) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"session": session}) + return + } + if len(parts) < 2 { + http.NotFound(w, r) + return + } + + switch parts[1] { + case "signal": + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + m.handleSignal(sessionID, w, r) + case "segments": + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + m.handleSegmentUpload(sessionID, w, r) + case "markers": + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + m.handleMarker(sessionID, w, r) + case "finalize": + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + m.handleFinalize(sessionID, w, r) + case "playback": + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + session, err := m.store.getSession(sessionID) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"playback": session.Playback, "session": session}) + default: + http.NotFound(w, r) + } +} + +func (m *mediaServer) handleSignal(sessionID string, w http.ResponseWriter, r *http.Request) { + var input SignalRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + session, err := m.store.getSession(sessionID) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + config := webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}, + } + peer, err := webrtc.NewPeerConnection(config) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create peer connection") + return + } + m.store.replacePeer(sessionID, peer) + _, _ = peer.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionRecvonly, + }) + _, _ = peer.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionRecvonly, + }) + + peer.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { + _, _ = m.store.updateSession(sessionID, func(session *Session) error { + session.StreamConnected = state == webrtc.PeerConnectionStateConnected + session.LastStreamAt = time.Now().UTC().Format(time.RFC3339) + switch state { + case webrtc.PeerConnectionStateConnected: + session.Status = StatusStreaming + session.LastError = "" + case webrtc.PeerConnectionStateDisconnected: + session.Status = StatusReconnecting + session.ReconnectCount++ + case webrtc.PeerConnectionStateFailed: + session.Status = StatusFailed + session.LastError = "webrtc peer connection failed" + case webrtc.PeerConnectionStateClosed: + if session.Status != StatusArchived && session.Status != StatusFinalizing { + session.StreamConnected = false + } + } + return nil + }) + }) + + peer.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { + _ = receiver + go func() { + buffer := make([]byte, 1600) + for { + if _, _, readErr := track.Read(buffer); readErr != nil { + return + } + _, _ = m.store.updateSession(sessionID, func(session *Session) error { + session.StreamConnected = true + session.Status = StatusStreaming + session.LastStreamAt = time.Now().UTC().Format(time.RFC3339) + return nil + }) + } + }() + }) + + offer := webrtc.SessionDescription{ + Type: parseSDPType(input.Type), + SDP: input.SDP, + } + if err := peer.SetRemoteDescription(offer); err != nil { + writeError(w, http.StatusBadRequest, "failed to set remote description") + return + } + answer, err := peer.CreateAnswer(nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create answer") + return + } + gatherComplete := webrtc.GatheringCompletePromise(peer) + if err := peer.SetLocalDescription(answer); err != nil { + writeError(w, http.StatusInternalServerError, "failed to set local description") + return + } + <-gatherComplete + _, _ = m.store.updateSession(session.ID, func(current *Session) error { + current.Status = StatusRecording + current.StreamConnected = true + current.LastStreamAt = time.Now().UTC().Format(time.RFC3339) + return nil + }) + writeJSON(w, http.StatusOK, map[string]any{ + "type": strings.ToLower(peer.LocalDescription().Type.String()), + "sdp": peer.LocalDescription().SDP, + }) +} + +func (m *mediaServer) handleSegmentUpload(sessionID string, w http.ResponseWriter, r *http.Request) { + sequence, err := strconv.Atoi(r.URL.Query().Get("sequence")) + if err != nil || sequence < 0 { + writeError(w, http.StatusBadRequest, "invalid sequence") + return + } + durationMS, _ := strconv.ParseInt(r.URL.Query().Get("durationMs"), 10, 64) + contentType := r.Header.Get("Content-Type") + extension := detectExtension(contentType) + filename := fmt.Sprintf("%06d.%s", sequence, extension) + segmentPath := filepath.Join(m.store.segmentsDir(sessionID), filename) + if err := os.MkdirAll(m.store.segmentsDir(sessionID), 0o755); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + file, err := os.Create(segmentPath) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer file.Close() + size, err := io.Copy(file, r.Body) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + session, err := m.store.updateSession(sessionID, func(session *Session) error { + meta := SegmentMeta{ + Sequence: sequence, + Filename: filename, + DurationMS: durationMS, + SizeBytes: size, + UploadedAt: time.Now().UTC().Format(time.RFC3339), + ContentType: defaultString(contentType, "video/webm"), + } + found := false + for index := range session.Segments { + if session.Segments[index].Sequence == sequence { + session.Segments[index] = meta + found = true + break + } + } + if !found { + session.Segments = append(session.Segments, meta) + } + sort.Slice(session.Segments, func(i, j int) bool { + return session.Segments[i].Sequence < session.Segments[j].Sequence + }) + session.Status = StatusRecording + session.LastError = "" + return nil + }) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"session": session}) +} + +func (m *mediaServer) handleMarker(sessionID string, w http.ResponseWriter, r *http.Request) { + var input MarkerRequest + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + session, err := m.store.updateSession(sessionID, func(session *Session) error { + session.Markers = append(session.Markers, Marker{ + ID: randomID(), + Type: defaultString(input.Type, "manual"), + Label: defaultString(input.Label, "标记点"), + Timestamp: input.Timestamp, + Confidence: input.Confidence, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }) + sort.Slice(session.Markers, func(i, j int) bool { + return session.Markers[i].Timestamp < session.Markers[j].Timestamp + }) + return nil + }) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"session": session}) +} + +func (m *mediaServer) handleFinalize(sessionID string, w http.ResponseWriter, r *http.Request) { + var input FinalizeRequest + _ = json.NewDecoder(r.Body).Decode(&input) + m.store.closePeer(sessionID) + session, err := m.store.updateSession(sessionID, func(session *Session) error { + session.Status = StatusFinalizing + session.ArchiveStatus = ArchiveQueued + session.FinalizedAt = time.Now().UTC().Format(time.RFC3339) + if strings.TrimSpace(input.Title) != "" { + session.Title = strings.TrimSpace(input.Title) + } + if input.DurationMS > 0 { + session.DurationMS = input.DurationMS + } + session.StreamConnected = false + return nil + }) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"session": session}) +} + +func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sessions := store.listFinalizingSessions() + for _, session := range sessions { + if err := processSession(store, session.ID); err != nil { + log.Printf("[worker] failed to process session %s: %v", session.ID, err) + } + } + } + } +} + +func processSession(store *sessionStore, sessionID string) error { + session, err := store.updateSession(sessionID, func(session *Session) error { + if session.ArchiveStatus == ArchiveProcessing { + return errors.New("already processing") + } + session.ArchiveStatus = ArchiveProcessing + session.Status = StatusFinalizing + session.LastError = "" + return nil + }) + if err != nil { + if strings.Contains(err.Error(), "already processing") { + return nil + } + return err + } + if len(session.Segments) == 0 { + _, _ = store.updateSession(sessionID, func(session *Session) error { + session.ArchiveStatus = ArchiveFailed + session.Status = StatusFailed + session.LastError = "no uploaded segments found" + return nil + }) + return errors.New("no uploaded segments found") + } + + publicDir := store.publicDir(sessionID) + if err := os.MkdirAll(publicDir, 0o755); err != nil { + return err + } + outputWebM := filepath.Join(publicDir, "recording.webm") + outputMP4 := filepath.Join(publicDir, "recording.mp4") + listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt") + + inputs := make([]string, 0, len(session.Segments)) + sort.Slice(session.Segments, func(i, j int) bool { + return session.Segments[i].Sequence < session.Segments[j].Sequence + }) + for _, segment := range session.Segments { + inputs = append(inputs, filepath.Join(store.segmentsDir(sessionID), segment.Filename)) + } + if err := writeConcatList(listFile, inputs); err != nil { + return markArchiveError(store, sessionID, err) + } + + if len(inputs) == 1 { + body, copyErr := os.ReadFile(inputs[0]) + if copyErr != nil { + return markArchiveError(store, sessionID, copyErr) + } + if writeErr := os.WriteFile(outputWebM, body, 0o644); writeErr != nil { + return markArchiveError(store, sessionID, writeErr) + } + } else { + copyErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", outputWebM) + if copyErr != nil { + reencodeErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c:v", "libvpx-vp9", "-b:v", "1800k", "-c:a", "libopus", outputWebM) + if reencodeErr != nil { + return markArchiveError(store, sessionID, fmt.Errorf("concat failed: %w / %v", copyErr, reencodeErr)) + } + } + } + + mp4Err := runFFmpeg("-y", "-i", outputWebM, "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-c:a", "aac", "-movflags", "+faststart", outputMP4) + if mp4Err != nil { + log.Printf("[worker] mp4 archive generation failed for %s: %v", sessionID, mp4Err) + } + + webmInfo, webmStatErr := os.Stat(outputWebM) + if webmStatErr != nil { + return markArchiveError(store, sessionID, webmStatErr) + } + var mp4Size int64 + var mp4URL string + if info, statErr := os.Stat(outputMP4); statErr == nil { + mp4Size = info.Size() + mp4URL = fmt.Sprintf("/media/assets/sessions/%s/recording.mp4", sessionID) + } + _, err = store.updateSession(sessionID, func(session *Session) error { + session.ArchiveStatus = ArchiveCompleted + session.Status = StatusArchived + session.Playback = PlaybackInfo{ + WebMURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID), + MP4URL: mp4URL, + WebMSize: webmInfo.Size(), + MP4Size: mp4Size, + Ready: true, + PreviewURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID), + } + session.LastError = "" + return nil + }) + return err +} + +func markArchiveError(store *sessionStore, sessionID string, err error) error { + _, _ = store.updateSession(sessionID, func(session *Session) error { + session.ArchiveStatus = ArchiveFailed + session.Status = StatusFailed + session.LastError = err.Error() + return nil + }) + return err +} + +func writeConcatList(path string, inputs []string) error { + lines := make([]string, 0, len(inputs)) + for _, input := range inputs { + lines = append(lines, fmt.Sprintf("file '%s'", strings.ReplaceAll(input, "'", "'\\''"))) + } + return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644) +} + +func runFFmpeg(args ...string) error { + cmd := exec.Command("ffmpeg", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func parseSDPType(value string) webrtc.SDPType { + switch strings.ToLower(value) { + case "offer": + return webrtc.SDPTypeOffer + case "pranswer": + return webrtc.SDPTypePranswer + case "rollback": + return webrtc.SDPTypeRollback + default: + return webrtc.SDPTypeOffer + } +} + +func detectExtension(contentType string) string { + switch { + case strings.Contains(contentType, "mp4"): + return "mp4" + case strings.Contains(contentType, "ogg"): + return "ogg" + default: + return "webm" + } +} + +func defaultString(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return strings.TrimSpace(value) +} + +func randomID() string { + buffer := make([]byte, 12) + if _, err := rand.Read(buffer); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 36) + } + return hex.EncodeToString(buffer) +} + +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization,X-User-Id") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func cacheControl(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func main() { + mode := defaultString(os.Getenv("MEDIA_MODE"), "serve") + dataDir := defaultString(os.Getenv("MEDIA_DATA_DIR"), "./data/media") + addr := defaultString(os.Getenv("MEDIA_ADDR"), ":8081") + workerInterval := 3 * time.Second + + store, err := newSessionStore(dataDir) + if err != nil { + log.Fatalf("failed to create store: %v", err) + } + + switch mode { + case "worker": + log.Printf("media worker running with data dir %s", dataDir) + runWorkerLoop(context.Background(), store, workerInterval) + default: + server := newMediaServer(store) + if os.Getenv("MEDIA_EMBEDDED_WORKER") != "0" { + go runWorkerLoop(context.Background(), store, workerInterval) + } + log.Printf("media service listening on %s with data dir %s", addr, dataDir) + if err := http.ListenAndServe(addr, server.routes()); err != nil { + log.Fatal(err) + } + } +} diff --git a/media/main_test.go b/media/main_test.go new file mode 100644 index 0000000..edba0a6 --- /dev/null +++ b/media/main_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMediaHealthAndSessionLifecycle(t *testing.T) { + store, err := newSessionStore(t.TempDir()) + if err != nil { + t.Fatalf("newSessionStore: %v", err) + } + + server := newMediaServer(store) + + healthReq := httptest.NewRequest(http.MethodGet, "/media/health", nil) + healthRes := httptest.NewRecorder() + server.routes().ServeHTTP(healthRes, healthReq) + if healthRes.Code != http.StatusOK { + t.Fatalf("expected health 200, got %d", healthRes.Code) + } + + session, err := store.createSession(CreateSessionRequest{ + UserID: "1", + Title: "Test Session", + Format: "webm", + MimeType: "video/webm", + QualityPreset: "balanced", + FacingMode: "environment", + DeviceKind: "desktop", + }) + if err != nil { + t.Fatalf("createSession: %v", err) + } + + if _, err := store.updateSession(session.ID, func(current *Session) error { + current.Segments = append(current.Segments, SegmentMeta{ + Sequence: 0, + Filename: "000000.webm", + DurationMS: 60000, + SizeBytes: 7, + ContentType: "video/webm", + }) + current.Markers = append(current.Markers, Marker{ + ID: "marker-1", + Type: "manual", + Label: "关键片段", + Timestamp: 5000, + CreatedAt: "2026-03-14T00:00:00Z", + }) + return nil + }); err != nil { + t.Fatalf("updateSession: %v", err) + } + + if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil { + t.Fatalf("write segment: %v", err) + } + + current, err := store.getSession(session.ID) + if err != nil { + t.Fatalf("getSession: %v", err) + } + if current.UploadedSegments != 1 { + t.Fatalf("expected uploaded segment count to be recomputed") + } + if current.UploadedBytes != 7 { + t.Fatalf("expected uploaded bytes to be recomputed, got %d", current.UploadedBytes) + } +} + +func TestProcessSessionArchivesPlayback(t *testing.T) { + tempDir := t.TempDir() + store, err := newSessionStore(tempDir) + if err != nil { + t.Fatalf("newSessionStore: %v", err) + } + + session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Archive Session"}) + if err != nil { + t.Fatalf("createSession: %v", err) + } + + if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil { + t.Fatalf("write segment: %v", err) + } + + if _, err := store.updateSession(session.ID, func(current *Session) error { + current.Segments = append(current.Segments, SegmentMeta{ + Sequence: 0, + Filename: "000000.webm", + DurationMS: 60000, + SizeBytes: 7, + ContentType: "video/webm", + }) + current.ArchiveStatus = ArchiveQueued + return nil + }); err != nil { + t.Fatalf("updateSession: %v", err) + } + + fakeFFmpeg := filepath.Join(tempDir, "ffmpeg") + script := "#!/bin/sh\ninput=''\noutput=''\nprev=''\nfor arg in \"$@\"; do\n if [ \"$prev\" = '-i' ]; then input=\"$arg\"; fi\n prev=\"$arg\"\n output=\"$arg\"\ndone\nif [ -n \"$input\" ] && [ -f \"$input\" ]; then cp \"$input\" \"$output\"; else : > \"$output\"; fi\n" + if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil { + t.Fatalf("write fake ffmpeg: %v", err) + } + + originalPath := os.Getenv("PATH") + t.Setenv("PATH", tempDir+string(os.PathListSeparator)+originalPath) + + if err := processSession(store, session.ID); err != nil { + t.Fatalf("processSession: %v", err) + } + + archived, err := store.getSession(session.ID) + if err != nil { + t.Fatalf("getSession: %v", err) + } + + if archived.ArchiveStatus != ArchiveCompleted { + t.Fatalf("expected archive completed, got %s", archived.ArchiveStatus) + } + if archived.Playback.WebMURL == "" || !strings.HasSuffix(archived.Playback.WebMURL, ".webm") { + t.Fatalf("expected webm playback url, got %#v", archived.Playback) + } +} diff --git a/media/media b/media/media new file mode 100755 index 0000000..4c74228 Binary files /dev/null and b/media/media differ diff --git a/package.json b/package.json index 9a0f743..1b8149b 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,15 @@ "license": "MIT", "scripts": { "dev": "NODE_ENV=development tsx watch server/_core/index.ts", + "dev:test": "PORT=41731 STRICT_PORT=1 VITE_APP_ID=test-app VITE_OAUTH_PORTAL_URL=http://127.0.0.1:41731 NODE_ENV=development tsx server/_core/index.ts", "build": "vite build && esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist", "start": "NODE_ENV=production node dist/index.js", "check": "tsc --noEmit", "format": "prettier --write .", "test": "vitest run", + "test:go": "cd media && go test ./... && go build ./...", + "test:e2e": "playwright test", + "verify": "pnpm check && pnpm test && pnpm test:go && pnpm build && pnpm test:e2e", "db:push": "drizzle-kit generate && drizzle-kit migrate" }, "dependencies": { @@ -82,6 +86,7 @@ }, "devDependencies": { "@builder.io/vite-plugin-jsx-loc": "^0.1.1", + "@playwright/test": "^1.55.0", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.1.3", "@types/express": "4.17.21", @@ -94,6 +99,7 @@ "autoprefixer": "^10.4.20", "drizzle-kit": "^0.31.4", "esbuild": "^0.25.0", + "jsdom": "^28.1.0", "pnpm": "^10.15.1", "postcss": "^8.4.47", "prettier": "^3.6.2", @@ -114,4 +120,4 @@ "tailwindcss>nanoid": "3.3.7" } } -} \ No newline at end of file +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..35504bb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + retries: process.env.CI ? 2 : 0, + use: { + baseURL: "http://127.0.0.1:41731", + headless: true, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + webServer: { + command: "pnpm dev:test", + url: "http://127.0.0.1:41731", + timeout: 120_000, + reuseExistingServer: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77b2cf7..4efccc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: '@builder.io/vite-plugin-jsx-loc': specifier: ^0.1.1 version: 0.1.1(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) + '@playwright/test': + specifier: ^1.55.0 + version: 1.58.2 '@tailwindcss/typography': specifier: ^0.5.15 version: 0.5.19(tailwindcss@4.1.14) @@ -254,6 +257,9 @@ importers: esbuild: specifier: ^0.25.0 version: 0.25.10 + jsdom: + specifier: ^28.1.0 + version: 28.1.0 pnpm: specifier: ^10.15.1 version: 10.18.0 @@ -283,16 +289,29 @@ importers: version: 0.0.57 vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@24.7.0)(lightningcss@1.30.1) + version: 2.1.9(@types/node@24.7.0)(jsdom@28.1.0)(lightningcss@1.30.1) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} '@antfu/utils@9.3.0': resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==} + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -546,6 +565,10 @@ packages: '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@builder.io/jsx-loc-internals@0.0.1': resolution: {integrity: sha512-cSADapVCi07DDhcuDmcAVItqSVmji7DNyD3xxYTHyNCwhWMNnTpZjyvDIWwYFJLleyDCJ9VUtbaXtUjjqBiRqw==} @@ -569,6 +592,37 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -1009,6 +1063,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1067,6 +1130,11 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1716,56 +1784,67 @@ packages: resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.52.4': resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.52.4': resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.52.4': resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.4': resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.4': resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.4': resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.4': resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.52.4': resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.4': resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.52.4': resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.4': resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} @@ -2070,24 +2149,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.14': resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.14': resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.14': resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.14': resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==} @@ -2391,6 +2474,10 @@ packages: add@2.0.6: resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -2426,6 +2513,9 @@ packages: resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2561,11 +2651,19 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -2725,6 +2823,10 @@ packages: dagre-d3-es@7.0.11: resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} @@ -2754,6 +2856,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -3078,6 +3183,11 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3173,6 +3283,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3183,6 +3297,14 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -3234,6 +3356,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -3251,6 +3376,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3310,24 +3444,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -3368,6 +3506,10 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3452,6 +3594,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -3657,6 +3802,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3690,6 +3838,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + pnpm@10.18.0: resolution: {integrity: sha512-6AT4ifHOzEDVctsITuw+SIFzn43sacD/ENLRvv+aTjCTg7ontbdQBZ1/TBSVNbbNDSyx7Trrc5I5pChKaPQM+g==} engines: {node: '>=18.12'} @@ -3733,6 +3891,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.13.0: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} @@ -3880,6 +4042,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3903,6 +4069,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4004,6 +4174,9 @@ packages: resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==} engines: {node: '>=10'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} @@ -4051,10 +4224,25 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4091,6 +4279,10 @@ packages: undici-types@7.14.0: resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} + undici@7.24.2: + resolution: {integrity: sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4307,9 +4499,25 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -4320,6 +4528,13 @@ packages: peerDependencies: react: '>=16.8.0' + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4335,6 +4550,8 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.5.0 @@ -4342,6 +4559,24 @@ snapshots: '@antfu/utils@9.3.0': {} + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -4944,6 +5179,10 @@ snapshots: '@braintree/sanitize-url@7.1.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@builder.io/jsx-loc-internals@0.0.1': dependencies: '@babel/parser': 7.28.4 @@ -4972,6 +5211,28 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@date-fns/tz@1.4.1': {} '@drizzle-team/brocli@0.10.2': {} @@ -5199,6 +5460,8 @@ snapshots: '@esbuild/win32-x64@0.25.10': optional: true + '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -5269,6 +5532,10 @@ snapshots: dependencies: langium: 3.3.1 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -6755,6 +7022,8 @@ snapshots: add@2.0.6: {} + agent-base@7.1.4: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -6789,6 +7058,10 @@ snapshots: baseline-browser-mapping@2.8.12: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -6928,8 +7201,20 @@ snapshots: dependencies: layout-base: 2.0.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + css-tree: 3.2.1 + lru-cache: 11.2.7 + csstype@3.1.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): @@ -7116,6 +7401,13 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.21 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + date-fns-jalali@4.1.0-0: {} date-fns@4.1.0: {} @@ -7132,6 +7424,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -7428,6 +7722,9 @@ snapshots: fresh@0.5.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7601,6 +7898,12 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -7613,6 +7916,20 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -7653,6 +7970,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-property@1.0.2: {} is-what@4.1.16: {} @@ -7663,6 +7982,33 @@ snapshots: js-tokens@4.0.0: {} + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json5@2.2.3: {} @@ -7752,6 +8098,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@11.2.7: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7943,6 +8291,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + media-typer@0.3.0: {} merge-descriptors@1.0.3: {} @@ -8273,6 +8623,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-data-parser@0.1.0: {} @@ -8301,6 +8655,14 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + pnpm@10.18.0: {} points-on-curve@0.2.0: {} @@ -8342,6 +8704,8 @@ snapshots: proxy-from-env@1.1.0: {} + punycode@2.3.1: {} + qs@6.13.0: dependencies: side-channel: 1.1.0 @@ -8538,6 +8902,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} robust-predicates@3.0.2: {} @@ -8583,6 +8949,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -8724,6 +9094,8 @@ snapshots: dependencies: copy-anything: 3.0.5 + symbol-tree@3.2.4: {} + tailwind-merge@3.3.1: {} tailwindcss-animate@1.0.7(tailwindcss@4.1.14): @@ -8761,8 +9133,22 @@ snapshots: tinyspy@3.0.2: {} + tldts-core@7.0.25: {} + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + toidentifier@1.0.1: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.25 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -8791,6 +9177,8 @@ snapshots: undici-types@7.14.0: {} + undici@7.24.2: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -8963,7 +9351,7 @@ snapshots: lightningcss: 1.30.1 tsx: 4.20.6 - vitest@2.1.9(@types/node@24.7.0)(lightningcss@1.30.1): + vitest@2.1.9(@types/node@24.7.0)(jsdom@28.1.0)(lightningcss@1.30.1): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.20(@types/node@24.7.0)(lightningcss@1.30.1)) @@ -8987,6 +9375,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.7.0 + jsdom: 28.1.0 transitivePeerDependencies: - less - lightningcss @@ -9015,8 +9404,24 @@ snapshots: vscode-uri@3.0.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -9029,6 +9434,10 @@ snapshots: regexparam: 3.0.0 use-sync-external-store: 1.6.0(react@19.2.1) + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yallist@5.0.0: {} diff --git a/server/_core/index.ts b/server/_core/index.ts index f472331..b949711 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -6,6 +6,7 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { registerOAuthRoutes } from "./oauth"; import { appRouter } from "../routers"; import { createContext } from "./context"; +import { registerMediaProxy } from "./mediaProxy"; import { serveStatic, setupVite } from "./vite"; function isPortAvailable(port: number): Promise { @@ -30,6 +31,7 @@ async function findAvailablePort(startPort: number = 3000): Promise { async function startServer() { const app = express(); const server = createServer(app); + registerMediaProxy(app); // Configure body parser with larger size limit for file uploads app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ limit: "50mb", extended: true })); @@ -51,7 +53,8 @@ async function startServer() { } const preferredPort = parseInt(process.env.PORT || "3000"); - const port = await findAvailablePort(preferredPort); + const strictPort = process.env.STRICT_PORT === "1"; + const port = strictPort ? preferredPort : await findAvailablePort(preferredPort); if (port !== preferredPort) { console.log(`Port ${preferredPort} is busy, using port ${port} instead`); diff --git a/server/_core/mediaProxy.ts b/server/_core/mediaProxy.ts new file mode 100644 index 0000000..0b91df0 --- /dev/null +++ b/server/_core/mediaProxy.ts @@ -0,0 +1,56 @@ +import type { Express, RequestHandler } from "express"; +import http from "node:http"; +import https from "node:https"; + +function createMediaProxy(targetUrl: string): RequestHandler { + const target = new URL(targetUrl); + const transport = target.protocol === "https:" ? https : http; + + return (req, res) => { + const upstreamUrl = new URL(req.originalUrl, target); + const proxyRequest = transport.request( + upstreamUrl, + { + method: req.method, + headers: { + ...req.headers, + host: target.host, + connection: "keep-alive", + }, + }, + (proxyResponse) => { + if (proxyResponse.statusCode) { + res.status(proxyResponse.statusCode); + } + Object.entries(proxyResponse.headers).forEach(([key, value]) => { + if (value !== undefined) { + res.setHeader(key, value); + } + }); + proxyResponse.pipe(res); + } + ); + + proxyRequest.on("error", (error) => { + if (!res.headersSent) { + res.status(502).json({ + error: "media_service_unavailable", + message: error.message, + }); + } else { + res.end(); + } + }); + + req.pipe(proxyRequest); + }; +} + +export function registerMediaProxy(app: Express) { + const mediaServiceUrl = process.env.MEDIA_SERVICE_URL; + if (!mediaServiceUrl) { + return; + } + + app.use("/media", createMediaProxy(mediaServiceUrl)); +} diff --git a/server/features.test.ts b/server/features.test.ts index e406266..bd58e45 100644 --- a/server/features.test.ts +++ b/server/features.test.ts @@ -260,6 +260,37 @@ describe("video.list", () => { }); }); +describe("video.registerExternal input validation", () => { + it("requires authentication", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.video.registerExternal({ + title: "session", + url: "/media/assets/sessions/demo/recording.webm", + fileKey: "media/sessions/demo/recording.webm", + format: "webm", + }) + ).rejects.toThrow(); + }); + + it("rejects missing url", async () => { + const user = createTestUser(); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.video.registerExternal({ + title: "session", + url: "", + fileKey: "media/sessions/demo/recording.webm", + format: "webm", + }) + ).rejects.toThrow(); + }); +}); + describe("video.get input validation", () => { it("requires authentication", async () => { const { ctx } = createMockContext(null); diff --git a/server/routers.ts b/server/routers.ts index 354341c..773ab6b 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -258,6 +258,32 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec return { videoId, url }; }), + registerExternal: protectedProcedure + .input(z.object({ + title: z.string().min(1).max(256), + url: z.string().min(1), + fileKey: z.string().min(1), + format: z.string().min(1).max(16), + fileSize: z.number().optional(), + duration: z.number().optional(), + exerciseType: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + const videoId = await db.createVideo({ + userId: ctx.user.id, + title: input.title, + fileKey: input.fileKey, + url: input.url, + format: input.format, + fileSize: input.fileSize ?? null, + duration: input.duration ?? null, + exerciseType: input.exerciseType || "recording", + analysisStatus: "completed", + }); + + return { videoId, url: input.url }; + }), + list: protectedProcedure.query(async ({ ctx }) => { return db.getUserVideos(ctx.user.id); }), diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts new file mode 100644 index 0000000..2d34a9b --- /dev/null +++ b/tests/e2e/app.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; +import { installAppMocks } from "./helpers/mockApp"; + +test("login redirects into dashboard with mocked auth", async ({ page }) => { + await installAppMocks(page, { authenticated: false }); + + await page.goto("/login"); + await expect(page.getByTestId("login-title")).toBeVisible(); + + await page.getByTestId("login-username-input").fill("TestPlayer"); + await page.getByTestId("login-submit-button").click(); + + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByTestId("dashboard-title")).toContainText("TestPlayer"); +}); + +test("training page shows plan generation flow", async ({ page }) => { + await installAppMocks(page, { authenticated: true }); + + await page.goto("/training"); + await expect(page.getByTestId("training-title")).toBeVisible(); + await expect(page.getByTestId("training-generate-button")).toBeVisible(); +}); + +test("videos page renders video library items", async ({ page }) => { + await installAppMocks(page, { authenticated: true }); + + await page.goto("/videos"); + await expect(page.getByTestId("videos-title")).toBeVisible(); + await expect(page.getByTestId("video-card")).toHaveCount(1); +}); + +test("live camera page exposes camera startup controls", async ({ page }) => { + await installAppMocks(page, { authenticated: true }); + + await page.goto("/live-camera"); + await expect(page.getByTestId("live-camera-start-button")).toBeVisible(); +}); + +test("recorder flow archives a session and exposes it in videos", async ({ page }) => { + await installAppMocks(page, { authenticated: true, videos: [] }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/recorder"); + await expect(page.getByTestId("recorder-title")).toBeVisible(); + + await page.getByTestId("recorder-start-camera-button").click(); + await expect(page.getByTestId("recorder-start-recording-button")).toBeVisible(); + + await page.getByTestId("recorder-start-recording-button").click(); + await expect(page.getByTestId("recorder-marker-button")).toBeVisible(); + + await page.getByTestId("recorder-marker-button").click(); + await expect(page.getByText("手动标记")).toBeVisible(); + + await page.getByTestId("recorder-finish-button").click(); + await expect(page.getByTestId("recorder-reset-button")).toBeVisible({ timeout: 8_000 }); + + await page.goto("/videos"); + await expect(page.getByTestId("video-card")).toHaveCount(1); + await expect(page.getByText("E2E 录制")).toBeVisible(); +}); diff --git a/tests/e2e/helpers/mockApp.ts b/tests/e2e/helpers/mockApp.ts new file mode 100644 index 0000000..816f9de --- /dev/null +++ b/tests/e2e/helpers/mockApp.ts @@ -0,0 +1,419 @@ +import type { Page, Route } from "@playwright/test"; + +type MockUser = { + id: number; + openId: string; + email: string; + name: string; + loginMethod: string; + role: string; + skillLevel: string; + trainingGoals: string | null; + ntrpRating: number; + totalSessions: number; + totalMinutes: number; + totalShots: number; + currentStreak: number; + longestStreak: number; + createdAt: string; + updatedAt: string; + lastSignedIn: string; +}; + +type MockMediaSession = { + id: string; + userId: string; + title: string; + status: string; + archiveStatus: string; + format: string; + mimeType: string; + qualityPreset: string; + facingMode: string; + deviceKind: string; + reconnectCount: number; + uploadedSegments: number; + uploadedBytes: number; + durationMs: number; + streamConnected: boolean; + playback: { + webmUrl?: string; + mp4Url?: string; + webmSize?: number; + mp4Size?: number; + ready: boolean; + previewUrl?: string; + }; + markers: Array<{ + id: string; + type: string; + label: string; + timestampMs: number; + confidence?: number; + createdAt: string; + }>; +}; + +type MockAppState = { + authenticated: boolean; + user: MockUser; + videos: any[]; + analyses: any[]; + mediaSession: MockMediaSession | null; + nextVideoId: number; +}; + +function trpcResult(json: unknown) { + return { result: { data: { json } } }; +} + +function nowIso() { + return new Date("2026-03-14T12:00:00.000Z").toISOString(); +} + +function buildUser(name = "TestPlayer"): MockUser { + return { + id: 1, + openId: "username_test_001", + email: "test@example.com", + name, + loginMethod: "username", + role: "user", + skillLevel: "beginner", + trainingGoals: null, + ntrpRating: 2.8, + totalSessions: 12, + totalMinutes: 320, + totalShots: 280, + currentStreak: 5, + longestStreak: 12, + createdAt: nowIso(), + updatedAt: nowIso(), + lastSignedIn: nowIso(), + }; +} + +function buildStats(user: MockUser) { + return { + ntrpRating: user.ntrpRating, + totalSessions: user.totalSessions, + totalMinutes: user.totalMinutes, + totalShots: user.totalShots, + ratingHistory: [ + { createdAt: nowIso(), rating: 2.4, dimensionScores: {} }, + { createdAt: nowIso(), rating: 2.6, dimensionScores: {} }, + { createdAt: nowIso(), rating: 2.8, dimensionScores: {} }, + ], + recentAnalyses: [ + { + id: 10, + createdAt: nowIso(), + overallScore: 82, + exerciseType: "forehand", + shotCount: 18, + }, + ], + }; +} + +function buildMediaSession(user: MockUser, title: string): MockMediaSession { + return { + id: "session-e2e", + userId: String(user.id), + title, + status: "created", + archiveStatus: "idle", + format: "webm", + mimeType: "video/webm", + qualityPreset: "balanced", + facingMode: "environment", + deviceKind: "mobile", + reconnectCount: 0, + uploadedSegments: 0, + uploadedBytes: 0, + durationMs: 0, + streamConnected: true, + playback: { + ready: false, + }, + markers: [], + }; +} + +async function fulfillJson(route: Route, body: unknown) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +async function handleTrpc(route: Route, state: MockAppState) { + const url = new URL(route.request().url()); + const operations = url.pathname.replace("/api/trpc/", "").split(","); + const results = operations.map((operation) => { + switch (operation) { + case "auth.me": + return trpcResult(state.authenticated ? state.user : null); + case "auth.loginWithUsername": + state.authenticated = true; + return trpcResult({ user: state.user, isNew: false }); + case "profile.stats": + return trpcResult(buildStats(state.user)); + case "plan.active": + return trpcResult(null); + case "plan.list": + return trpcResult([]); + case "video.list": + return trpcResult(state.videos); + case "analysis.list": + return trpcResult(state.analyses); + case "video.registerExternal": + if (state.mediaSession?.playback.webmUrl || state.mediaSession?.playback.mp4Url) { + state.videos = [ + { + id: state.nextVideoId++, + title: state.mediaSession.title, + url: state.mediaSession.playback.webmUrl || state.mediaSession.playback.mp4Url, + format: "webm", + fileSize: state.mediaSession.playback.webmSize || 1024 * 1024, + exerciseType: "recording", + analysisStatus: "completed", + createdAt: nowIso(), + }, + ...state.videos, + ]; + } + return trpcResult({ videoId: state.nextVideoId, url: state.mediaSession?.playback.webmUrl }); + default: + return trpcResult(null); + } + }); + + await fulfillJson(route, results); +} + +async function handleMedia(route: Route, state: MockAppState) { + const url = new URL(route.request().url()); + const path = url.pathname; + + if (path === "/media/health") { + await fulfillJson(route, { ok: true, timestamp: nowIso() }); + return; + } + + if (path === "/media/sessions" && route.request().method() === "POST") { + state.mediaSession = buildMediaSession(state.user, "E2E 录制"); + await fulfillJson(route, { session: state.mediaSession }); + return; + } + + if (!state.mediaSession) { + await route.fulfill({ status: 404, body: "not found" }); + return; + } + + if (path.endsWith("/signal")) { + state.mediaSession.status = "recording"; + await fulfillJson(route, { type: "answer", sdp: "mock-answer" }); + return; + } + + if (path.endsWith("/segments")) { + const buffer = (await route.request().postDataBuffer()) || Buffer.from(""); + state.mediaSession.uploadedSegments += 1; + state.mediaSession.uploadedBytes += buffer.length || 1024; + state.mediaSession.durationMs += 60_000; + state.mediaSession.status = "recording"; + await fulfillJson(route, { session: state.mediaSession }); + return; + } + + if (path.endsWith("/markers")) { + state.mediaSession.markers.push({ + id: `marker-${state.mediaSession.markers.length + 1}`, + type: "manual", + label: "手动剪辑点", + timestampMs: 12_000, + createdAt: nowIso(), + }); + await fulfillJson(route, { session: state.mediaSession }); + return; + } + + if (path.endsWith("/finalize")) { + state.mediaSession.status = "finalizing"; + state.mediaSession.archiveStatus = "queued"; + await fulfillJson(route, { session: state.mediaSession }); + return; + } + + if (path === `/media/sessions/${state.mediaSession.id}`) { + state.mediaSession.status = "archived"; + state.mediaSession.archiveStatus = "completed"; + state.mediaSession.playback = { + ready: true, + webmUrl: "/media/assets/sessions/session-e2e/recording.webm", + mp4Url: "/media/assets/sessions/session-e2e/recording.mp4", + webmSize: 2_400_000, + mp4Size: 1_800_000, + previewUrl: "/media/assets/sessions/session-e2e/recording.webm", + }; + await fulfillJson(route, { session: state.mediaSession }); + return; + } + + if (path.startsWith("/media/assets/")) { + await route.fulfill({ + status: 200, + contentType: path.endsWith(".mp4") ? "video/mp4" : "video/webm", + body: "", + }); + return; + } + + await route.fulfill({ status: 404, body: "not found" }); +} + +export async function installAppMocks( + page: Page, + options?: { + authenticated?: boolean; + videos?: any[]; + analyses?: any[]; + userName?: string; + } +) { + const state: MockAppState = { + authenticated: options?.authenticated ?? false, + user: buildUser(options?.userName), + videos: options?.videos ?? [ + { + id: 1, + title: "正手训练样例", + url: "/media/assets/sessions/demo/recording.webm", + format: "webm", + fileSize: 3_400_000, + exerciseType: "forehand", + analysisStatus: "completed", + createdAt: nowIso(), + }, + ], + analyses: options?.analyses ?? [ + { + id: 8, + videoId: 1, + overallScore: 84, + shotCount: 16, + avgSwingSpeed: 6.2, + strokeConsistency: 82, + createdAt: nowIso(), + }, + ], + mediaSession: null, + nextVideoId: 100, + }; + + await page.addInitScript(() => { + Object.defineProperty(HTMLMediaElement.prototype, "play", { + configurable: true, + value: async () => undefined, + }); + + class FakeMediaRecorder extends EventTarget { + state = "inactive"; + mimeType = "video/webm"; + + constructor(_stream: MediaStream, options?: { mimeType?: string }) { + super(); + this.mimeType = options?.mimeType || "video/webm"; + } + + static isTypeSupported() { + return true; + } + + start() { + this.state = "recording"; + } + + requestData() { + if (this.state !== "recording") return; + const event = new Event("dataavailable") as Event & { data?: Blob }; + event.data = new Blob(["segment"], { type: this.mimeType }); + const handler = (this as unknown as { ondataavailable?: (evt: Event & { data?: Blob }) => void }).ondataavailable; + handler?.(event); + this.dispatchEvent(event); + } + + stop() { + if (this.state === "inactive") return; + this.requestData(); + this.state = "inactive"; + const stopEvent = new Event("stop"); + const handler = (this as unknown as { onstop?: () => void }).onstop; + handler?.(); + this.dispatchEvent(stopEvent); + } + } + + class FakeRTCPeerConnection extends EventTarget { + connectionState = "new"; + iceGatheringState = "new"; + localDescription: { type: string; sdp: string } | null = null; + remoteDescription: { type: string; sdp: string } | null = null; + onconnectionstatechange: (() => void) | null = null; + + addTrack() {} + + async createOffer() { + return { type: "offer", sdp: "mock-offer" }; + } + + async setLocalDescription(description: { type: string; sdp: string }) { + this.localDescription = description; + this.iceGatheringState = "complete"; + this.dispatchEvent(new Event("icegatheringstatechange")); + } + + async setRemoteDescription(description: { type: string; sdp: string }) { + this.remoteDescription = description; + this.connectionState = "connected"; + this.onconnectionstatechange?.(); + } + + close() { + this.connectionState = "closed"; + this.onconnectionstatechange?.(); + } + } + + Object.defineProperty(window, "MediaRecorder", { + configurable: true, + value: FakeMediaRecorder, + }); + + Object.defineProperty(window, "RTCPeerConnection", { + configurable: true, + value: FakeRTCPeerConnection, + }); + + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + getUserMedia: async () => new MediaStream(), + enumerateDevices: async () => [ + { deviceId: "cam-1", kind: "videoinput", label: "Front Camera", groupId: "g1" }, + { deviceId: "cam-2", kind: "videoinput", label: "Back Camera", groupId: "g1" }, + ], + addEventListener: () => undefined, + removeEventListener: () => undefined, + }, + }); + }); + + await page.route("**/api/trpc/**", (route) => handleTrpc(route, state)); + await page.route("**/media/**", (route) => handleMedia(route, state)); + + return state; +} diff --git a/vitest.config.ts b/vitest.config.ts index c549003..df631f0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,15 @@ export default defineConfig({ }, test: { environment: "node", - include: ["server/**/*.test.ts", "server/**/*.spec.ts"], + include: [ + "server/**/*.test.ts", + "server/**/*.spec.ts", + "client/**/*.test.ts", + "client/**/*.spec.ts", + ], + environmentMatchGlobs: [ + ["client/**/*.test.ts", "jsdom"], + ["client/**/*.spec.ts", "jsdom"], + ], }, });