Checkpoint: v4.0 media service, compose deploy, and verified docs

这个提交包含在:
cryptocommuniums-afk
2026-03-14 21:45:31 +08:00
父节点 27083d5af9
当前提交 d5431aee0e
修改 41 个文件,包含 4056 行新增883 行删除

20
.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

2
.gitignore vendored
查看文件

@@ -49,6 +49,8 @@ pids
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage/ coverage/
*.lcov *.lcov
playwright-report/
test-results/
# nyc test coverage # nyc test coverage
.nyc_output .nyc_output

24
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"]

258
README.md
查看文件

@@ -1,186 +1,102 @@
# Tennis Training Hub - AI网球训练助手 # Tennis Training Hub
一个基于AI的在家网球训练平台,通过MediaPipe姿势识别技术帮助用户在只有球拍的条件下进行科学训练,自动分析挥拍姿势并生成个性化训练计划 AI 网球训练助手,提供训练计划、姿势分析、实时摄像头分析、在线视频录制与视频库管理。当前版本新增独立 Go 媒体服务,用于处理在线录制、分段上传、实时推流信令和归档回放
## 功能概览 ## Architecture
| 功能模块 | 描述 | 技术实现 | - `client/`: React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui
|---------|------|---------| - `server/`: Express + tRPC + Drizzle + MySQL/TiDB,负责业务 API、登录、训练数据与视频库元数据
| 用户名登录 | 无需注册,输入用户名即可使用 | tRPC + JWT Session | - `media/`: Go 媒体服务,负责录制会话、分段上传、WebRTC 信令、关键片段标记与 FFmpeg 归档
| 训练计划生成 | 根据用户水平(初/中/高级AI生成训练计划 | LLM结构化输出 | - `docker-compose.yml`: 单机部署编排
| 视频上传分析 | 上传训练视频进行姿势识别 | MediaPipe Pose + S3 | - `deploy/nginx.te.hao.work.conf`: `te.hao.work` 的宿主机 nginx 入口配置
| 实时摄像头分析 | 手机/电脑摄像头实时捕捉分析 | MediaPipe实时推理 |
| 在线录制 | 稳定压缩流录制、断线重连、自动剪辑 | MediaRecorder API |
| 姿势矫正建议 | AI根据分析结果生成矫正方案 | LLM + 姿势数据 |
| NTRP自动评分 | 基于USTA标准的五维度加权评分 | 自动算法 |
| 训练计划自动调整 | 根据分析结果智能调整后续计划 | LLM + 历史数据 |
| 每日打卡 | 连续打卡追踪、训练时长记录 | 日期计算 + 数据库 |
| 成就徽章 | 24种成就徽章激励系统 | 自动检测 + 授予 |
| 社区排行榜 | NTRP评分、训练时长、击球数排名 | 数据库排序查询 |
| 训练进度追踪 | 可视化展示训练历史和改进趋势 | Recharts图表 |
| 视频库管理 | 保存管理所有训练视频及分析结果 | S3 + 数据库 |
| 移动端适配 | 全面响应式设计,手机摄像头优化 | Tailwind响应式 |
## 技术栈 ## Online Recording
**前端:** 在线录制模块采用双链路设计:
- React 19 + TypeScript
- Tailwind CSS 4 + shadcn/ui
- MediaPipe Pose浏览器端姿势识别
- Recharts数据可视化
- Framer Motion动画效果
- wouter路由
**后端:** - 浏览器端 `MediaRecorder` 本地压缩并每 60 秒自动分段上传
- Express 4 + tRPC 11 - 浏览器端 `RTCPeerConnection` 同步建立 WebRTC 低延迟推流链路
- Drizzle ORM + MySQL/TiDB - 客户端运动检测自动写入关键片段 marker,也支持手动标记
- S3文件存储 - 摄像头中断后自动重连,保留既有分段与会话
- LLM集成训练计划生成、姿势矫正建议 - 服务端 worker 将分段合并归档,并产出 WebM 回放;FFmpeg 可用时额外生成 MP4
## 项目结构 ## Quick Start
``` ### Local development
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测试用例,覆盖所有核心后端功能
```bash ```bash
pnpm test
```
测试覆盖范围:
- 认证系统(登录、登出、用户名验证)
- 用户资料管理
- 训练计划生成(输入验证)
- 视频上传和管理
- 姿势分析保存和查询
- 训练记录创建和完成
- NTRP评分系统
- 每日打卡系统
- 成就徽章系统
- 社区排行榜
## 开发
```bash
# 安装依赖
pnpm install pnpm install
cp .env.example .env
# 启动开发服务器
pnpm dev 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 部署

查看文件

@@ -15,10 +15,6 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script
defer
src="%VITE_ANALYTICS_ENDPOINT%/umami"
data-website-id="%VITE_ANALYTICS_WEBSITE_ID%"></script>
</body> </body>
</html> </html>

查看文件

@@ -44,6 +44,14 @@ const menuItems = [
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" }, { 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 SIDEBAR_WIDTH_KEY = "sidebar-width";
const DEFAULT_WIDTH = 260; const DEFAULT_WIDTH = 260;
const MIN_WIDTH = 200; const MIN_WIDTH = 200;
@@ -309,7 +317,31 @@ function DashboardLayoutContent({
</div> </div>
</div> </div>
)} )}
<main className="flex-1 p-4 md:p-6">{children}</main> <main className={`flex-1 p-4 md:p-6 ${isMobile ? "pb-28" : ""}`}>{children}</main>
{isMobile && (
<nav className="mobile-safe-bottom fixed inset-x-0 bottom-0 z-50 border-t border-border/70 bg-background/95 px-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur">
<div className="mx-auto grid max-w-xl grid-cols-5 gap-1">
{mobileNavItems.map((item) => {
const isActive = location === item.path;
return (
<button
key={item.path}
type="button"
onClick={() => setLocation(item.path)}
className={`flex min-h-[52px] flex-col items-center justify-center rounded-2xl px-1 py-2 text-[11px] transition ${
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted/70"
}`}
>
<item.icon className="mb-1 h-4 w-4" />
<span>{item.label}</span>
</button>
);
})}
</div>
</nav>
)}
</SidebarInset> </SidebarInset>
</> </>
); );

查看文件

@@ -175,6 +175,10 @@
.mobile-safe-top { .mobile-safe-top {
padding-top: env(safe-area-inset-top, 0px); 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 */ /* Touch-friendly tap targets */
@@ -215,8 +219,7 @@ html {
/* Prevent pull-to-refresh during camera/recording */ /* Prevent pull-to-refresh during camera/recording */
.no-overscroll { .no-overscroll {
overscroll-behavior: none; overscroll-behavior-y: contain;
touch-action: none;
} }
/* Video container responsive */ /* Video container responsive */

查看文件

@@ -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);
});
});

158
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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>;
}
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;
}
}

查看文件

@@ -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( createRoot(document.getElementById("root")!).render(
<trpc.Provider client={trpcClient} queryClient={queryClient}> <trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>

查看文件

@@ -54,7 +54,7 @@ export default function Dashboard() {
{/* Welcome header */} {/* Welcome header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight" data-testid="dashboard-title">
{user?.name || "球友"} {user?.name || "球友"}
</h1> </h1>
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
@@ -65,7 +65,7 @@ export default function Dashboard() {
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={() => setLocation("/training")} className="gap-2"> <Button data-testid="dashboard-training-button" onClick={() => setLocation("/training")} className="gap-2">
<Target className="h-4 w-4" /> <Target className="h-4 w-4" />
</Button> </Button>

查看文件

@@ -341,7 +341,7 @@ export default function LiveCamera() {
<div className="absolute inset-0 flex flex-col items-center justify-center text-white/60"> <div className="absolute inset-0 flex flex-col items-center justify-center text-white/60">
<CameraOff className="h-12 w-12 mb-3" /> <CameraOff className="h-12 w-12 mb-3" />
<p className="text-sm"></p> <p className="text-sm"></p>
<Button variant="secondary" className="mt-3 gap-2" onClick={() => setShowSetupGuide(true)}> <Button data-testid="live-camera-start-button" variant="secondary" className="mt-3 gap-2" onClick={() => setShowSetupGuide(true)}>
<Camera className="h-4 w-4" /> <Camera className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -357,7 +357,7 @@ export default function LiveCamera() {
{/* Controls bar */} {/* Controls bar */}
<div className="flex items-center justify-center gap-3 p-3 bg-muted/30 flex-wrap"> <div className="flex items-center justify-center gap-3 p-3 bg-muted/30 flex-wrap">
{!cameraActive ? ( {!cameraActive ? (
<Button onClick={() => setShowSetupGuide(true)} className="gap-2"> <Button data-testid="live-camera-toolbar-start-button" onClick={() => setShowSetupGuide(true)} className="gap-2">
<Camera className="h-4 w-4" /> <Camera className="h-4 w-4" />
</Button> </Button>
) : ( ) : (

查看文件

@@ -42,13 +42,14 @@ export default function Login() {
<Card className="border-0 shadow-xl"> <Card className="border-0 shadow-xl">
<CardHeader className="text-center pb-2"> <CardHeader className="text-center pb-2">
<CardTitle className="text-xl"></CardTitle> <CardTitle className="text-xl" data-testid="login-title"></CardTitle>
<CardDescription>使</CardDescription> <CardDescription>使</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleLogin} className="space-y-4"> <form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Input <Input
data-testid="login-username-input"
type="text" type="text"
placeholder="请输入您的用户名" placeholder="请输入您的用户名"
value={username} value={username}
@@ -59,6 +60,7 @@ export default function Login() {
/> />
</div> </div>
<Button <Button
data-testid="login-submit-button"
type="submit" type="submit"
className="w-full h-12 text-base font-medium" className="w-full h-12 text-base font-medium"
disabled={loginMutation.isPending || !username.trim()} disabled={loginMutation.isPending || !username.trim()}

文件差异内容过多而无法显示 加载差异

查看文件

@@ -95,7 +95,7 @@ export default function Training() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"></h1> <h1 className="text-2xl font-bold tracking-tight" data-testid="training-title"></h1>
<p className="text-muted-foreground text-sm mt-1">AI个性化训练方案</p> <p className="text-muted-foreground text-sm mt-1">AI个性化训练方案</p>
</div> </div>
</div> </div>
@@ -143,6 +143,7 @@ export default function Training() {
</div> </div>
</div> </div>
<Button <Button
data-testid="training-generate-button"
onClick={() => generateMutation.mutate({ skillLevel, durationDays })} onClick={() => generateMutation.mutate({ skillLevel, durationDays })}
disabled={generateMutation.isPending} disabled={generateMutation.isPending}
className="w-full sm:w-auto gap-2" className="w-full sm:w-auto gap-2"

查看文件

@@ -47,12 +47,12 @@ export default function Videos() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"></h1> <h1 className="text-2xl font-bold tracking-tight" data-testid="videos-title"></h1>
<p className="text-muted-foreground text-sm mt-1"> <p className="text-muted-foreground text-sm mt-1">
· {videos?.length || 0} · {videos?.length || 0}
</p> </p>
</div> </div>
<Button onClick={() => setLocation("/analysis")} className="gap-2"> <Button data-testid="videos-upload-button" onClick={() => setLocation("/analysis")} className="gap-2">
<Video className="h-4 w-4" /> <Video className="h-4 w-4" />
</Button> </Button>
@@ -77,7 +77,7 @@ export default function Videos() {
const status = statusMap[video.analysisStatus] || statusMap.pending; const status = statusMap[video.analysisStatus] || statusMap.pending;
return ( return (
<Card key={video.id} className="border-0 shadow-sm hover:shadow-md transition-shadow"> <Card key={video.id} className="border-0 shadow-sm hover:shadow-md transition-shadow" data-testid="video-card">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{/* Thumbnail / icon */} {/* Thumbnail / icon */}

查看文件

@@ -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;
}
}

47
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:

查看文件

@@ -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增强功能 ### 用户与训练
| 编号 | 功能 | 状态 | 版本 | 说明 | - 用户名登录:无需注册,输入用户名即可进入训练工作台
|------|------|------|------|------| - AI 训练计划:按技能等级和训练周期生成个性化训练计划
| 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 | 自动标记击球、准备等关键帧 |
### NTRP评分系统 ### 视频与分析
| 编号 | 功能 | 状态 | 版本 | 说明 | - 视频上传分析:上传 `webm/mp4` 视频进入视频库并触发分析流程
|------|------|------|------|------| - 实时摄像头分析:浏览器端调用 MediaPipe,进行姿势识别和反馈展示
| F-015 | NTRP自动评分 | 已完成 | v1.0 | 1.0-5.0评分,五维度加权 | - 视频库:集中展示录制结果、上传结果和分析摘要
| F-016 | 历史评分自动更新 | 已完成 | v1.0 | 每次分析后自动重新计算 |
| F-017 | 多维度评分展示 | 已完成 | v1.0 | 雷达图展示五维度得分 |
| F-018 | 评分趋势图表 | 已完成 | v1.0 | 折线图展示评分变化趋势 |
### v2.0 新增功能 ### 在线录制与媒体链路
| 编号 | 功能 | 状态 | 版本 | 说明 | - Go 媒体服务独立处理录制会话、分段上传、marker、归档和回放资源
|------|------|------|------|------| - WebRTC 推流:录制时并行建立低延迟实时推流链路
| F-019 | 社区排行榜 - NTRP排名 | 已完成 | v2.0 | 按评分排序的用户排名 | - MediaRecorder 分段:浏览器本地压缩录制并每 60 秒自动分段上传
| F-020 | 社区排行榜 - 训练时长排名 | 已完成 | v2.0 | 按训练分钟排序 | - 自动标记:客户端通过轻量运动检测创建关键片段 marker
| F-021 | 社区排行榜 - 训练次数排名 | 已完成 | v2.0 | 按训练次数排序 | - 手动标记:录制中支持手动插入剪辑点
| F-022 | 社区排行榜 - 击球数排名 | 已完成 | v2.0 | 按总击球数排序 | - 自动重连:摄像头 track 断开时自动尝试恢复
| F-023 | 每日打卡系统 | 已完成 | v2.0 | 日历视图、连续天数追踪 | - 归档回放worker 合并片段并生成 WebM,FFmpeg 可用时额外生成 MP4
| 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 | 前后摄像头切换、自适应分辨率 |
### 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、用户名登录验证 | - Node 应用负责业务 API、登录、训练数据与视频库元数据
| profile | 4 | 认证检查、技能等级验证 | - Go 服务负责媒体链路与归档
| plan | 5 | 生成验证、列表、激活计划、调整 | - `Docker Compose + 宿主机 nginx` 作为标准单机部署方式
| video | 4 | 上传验证、列表、详情 | - 统一的本地验证命令:
| analysis | 4 | 保存验证、矫正建议、列表、视频查询 | - `pnpm check`
| record | 4 | 创建验证、完成、列表 | - `pnpm test`
| rating | 2 | 历史、当前评分 | - `pnpm test:go`
| checkin | 5 | 今日状态、打卡、历史 | - `pnpm build`
| badge | 5 | 列表、检查、定义、数据完整性 | - `pnpm test:e2e`
| leaderboard | 3 | 认证、排序参数、无效参数 | - `pnpm verify`
| tutorial | 4 | 列表查询、分类过滤、进度更新 |
| reminder | 5 | 创建验证、切换、删除、认证 | ## 已知边界
| notification | 4 | 列表、未读计数、标记已读 |
| **总计** | **65** | **全部通过** | - 浏览器录制兼容目标以 Chrome 为主
- 当前 WebRTC 重点是浏览器到服务端的实时上行,不是多观众直播分发
- 当前 PC 剪辑仍处于基础媒体域准备阶段,未交付完整多轨编辑器
- 当前存储策略为本地卷优先,未接入对象存储归档
## 后续增强方向
- PC 时间线粗剪与 clip plan 持久化
- 更细粒度的设备能力自适应
- 更强的媒体回放和片段导出能力
- 更深入的前端域拆分和懒加载优化

60
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_id>/session.json`
- `sessions/<session_id>/segments/*.webm`
- `public/sessions/<session_id>/recording.webm`
- `public/sessions/<session_id>/recording.mp4`
## Rollback
1. 保留 `.env``media-data`
2. 回退 Git 版本
3. 重新执行 `docker compose up -d --build`
如果只需停止录制链路,可单独关闭 `media``worker`,主站业务仍可继续运行。

73
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`
- 相关专题文档

44
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`,把回放资源登记到现有视频库中,避免重写整个视频管理模块。

65
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/<id>/recording.webm`
- `public/sessions/<id>/recording.mp4`
## Archive flow
1. 浏览器 `finalize`
2. 会话状态变为 `ArchiveQueued`
3. worker 读取全部分段
4. 优先直接 concat,失败则重新编码为 WebM
5. 可用时生成 MP4 归档
6. 写回 playback URL 和文件大小
## Constraints
- 当前为单机本地卷模型,不依赖对象存储
- 当前 WebRTC 仅用于浏览器到服务端的实时上行,不做多观众直播分发
- Safari 原生 MP4 录制不在当前目标内

77
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` 是否被占用
- 浏览器依赖是否安装
- 前端路由或测试标识是否被改动

69
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 浏览器的真实媒体兼容差异
以上内容仍建议在预发或本地联调时补充人工验证。

17
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"]

28
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
)

50
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=

861
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, &copy)
return &copy
}
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)
}
}
}

130
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)
}
}

二进制
media/media 可执行文件

二进制文件未显示。

查看文件

@@ -5,11 +5,15 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "NODE_ENV=development tsx watch server/_core/index.ts", "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", "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", "start": "NODE_ENV=production node dist/index.js",
"check": "tsc --noEmit", "check": "tsc --noEmit",
"format": "prettier --write .", "format": "prettier --write .",
"test": "vitest run", "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" "db:push": "drizzle-kit generate && drizzle-kit migrate"
}, },
"dependencies": { "dependencies": {
@@ -82,6 +86,7 @@
}, },
"devDependencies": { "devDependencies": {
"@builder.io/vite-plugin-jsx-loc": "^0.1.1", "@builder.io/vite-plugin-jsx-loc": "^0.1.1",
"@playwright/test": "^1.55.0",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3", "@tailwindcss/vite": "^4.1.3",
"@types/express": "4.17.21", "@types/express": "4.17.21",
@@ -94,6 +99,7 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"jsdom": "^28.1.0",
"pnpm": "^10.15.1", "pnpm": "^10.15.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.6.2", "prettier": "^3.6.2",

24
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,
},
});

413
pnpm-lock.yaml 自动生成的
查看文件

@@ -218,6 +218,9 @@ importers:
'@builder.io/vite-plugin-jsx-loc': '@builder.io/vite-plugin-jsx-loc':
specifier: ^0.1.1 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)) 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': '@tailwindcss/typography':
specifier: ^0.5.15 specifier: ^0.5.15
version: 0.5.19(tailwindcss@4.1.14) version: 0.5.19(tailwindcss@4.1.14)
@@ -254,6 +257,9 @@ importers:
esbuild: esbuild:
specifier: ^0.25.0 specifier: ^0.25.0
version: 0.25.10 version: 0.25.10
jsdom:
specifier: ^28.1.0
version: 28.1.0
pnpm: pnpm:
specifier: ^10.15.1 specifier: ^10.15.1
version: 10.18.0 version: 10.18.0
@@ -283,16 +289,29 @@ importers:
version: 0.0.57 version: 0.0.57
vitest: vitest:
specifier: ^2.1.4 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: packages:
'@acemir/cssom@0.9.31':
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
'@antfu/install-pkg@1.1.0': '@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@antfu/utils@9.3.0': '@antfu/utils@9.3.0':
resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==} 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': '@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -546,6 +565,10 @@ packages:
'@braintree/sanitize-url@7.1.1': '@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} 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': '@builder.io/jsx-loc-internals@0.0.1':
resolution: {integrity: sha512-cSADapVCi07DDhcuDmcAVItqSVmji7DNyD3xxYTHyNCwhWMNnTpZjyvDIWwYFJLleyDCJ9VUtbaXtUjjqBiRqw==} resolution: {integrity: sha512-cSADapVCi07DDhcuDmcAVItqSVmji7DNyD3xxYTHyNCwhWMNnTpZjyvDIWwYFJLleyDCJ9VUtbaXtUjjqBiRqw==}
@@ -569,6 +592,37 @@ packages:
'@chevrotain/utils@11.0.3': '@chevrotain/utils@11.0.3':
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} 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': '@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
@@ -1009,6 +1063,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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': '@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
@@ -1067,6 +1130,11 @@ packages:
'@mermaid-js/parser@0.6.3': '@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} 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': '@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1716,56 +1784,67 @@ packages:
resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.4': '@rollup/rollup-linux-arm-musleabihf@4.52.4':
resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.4': '@rollup/rollup-linux-arm64-gnu@4.52.4':
resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.4': '@rollup/rollup-linux-arm64-musl@4.52.4':
resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.4': '@rollup/rollup-linux-loong64-gnu@4.52.4':
resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.4': '@rollup/rollup-linux-ppc64-gnu@4.52.4':
resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.4': '@rollup/rollup-linux-riscv64-gnu@4.52.4':
resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.4': '@rollup/rollup-linux-riscv64-musl@4.52.4':
resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.4': '@rollup/rollup-linux-s390x-gnu@4.52.4':
resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.4': '@rollup/rollup-linux-x64-gnu@4.52.4':
resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.4': '@rollup/rollup-linux-x64-musl@4.52.4':
resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.4': '@rollup/rollup-openharmony-arm64@4.52.4':
resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==}
@@ -2070,24 +2149,28 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.14': '@tailwindcss/oxide-linux-arm64-musl@4.1.14':
resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==} resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.14': '@tailwindcss/oxide-linux-x64-gnu@4.1.14':
resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==} resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.14': '@tailwindcss/oxide-linux-x64-musl@4.1.14':
resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==} resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.14': '@tailwindcss/oxide-wasm32-wasi@4.1.14':
resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==} resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==}
@@ -2391,6 +2474,10 @@ packages:
add@2.0.6: add@2.0.6:
resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==} 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: aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2426,6 +2513,9 @@ packages:
resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==}
hasBin: true hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
body-parser@1.20.3: body-parser@1.20.3:
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -2561,11 +2651,19 @@ packages:
cose-base@2.2.0: cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} 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: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true hasBin: true
cssstyle@6.2.0:
resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==}
engines: {node: '>=20'}
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -2725,6 +2823,10 @@ packages:
dagre-d3-es@7.0.11: dagre-d3-es@7.0.11:
resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==} 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: date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
@@ -2754,6 +2856,9 @@ packages:
decimal.js-light@2.5.1: decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decode-named-character-reference@1.2.0: decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
@@ -3078,6 +3183,11 @@ packages:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'} 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: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3173,6 +3283,10 @@ packages:
hastscript@9.0.1: hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} 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: html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -3183,6 +3297,14 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'} 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: iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3234,6 +3356,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'} engines: {node: '>=12'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-property@1.0.2: is-property@1.0.2:
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
@@ -3251,6 +3376,15 @@ packages:
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} 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: jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -3310,24 +3444,28 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1: lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1: lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1: lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1: lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -3368,6 +3506,10 @@ packages:
loupe@3.2.1: loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} 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: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -3452,6 +3594,9 @@ packages:
mdast-util-to-string@4.0.0: mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
media-typer@0.3.0: media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3657,6 +3802,9 @@ packages:
parse5@7.3.0: parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
parseurl@1.3.3: parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -3690,6 +3838,16 @@ packages:
pkg-types@2.3.0: pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} 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: pnpm@10.18.0:
resolution: {integrity: sha512-6AT4ifHOzEDVctsITuw+SIFzn43sacD/ENLRvv+aTjCTg7ontbdQBZ1/TBSVNbbNDSyx7Trrc5I5pChKaPQM+g==} resolution: {integrity: sha512-6AT4ifHOzEDVctsITuw+SIFzn43sacD/ENLRvv+aTjCTg7ontbdQBZ1/TBSVNbbNDSyx7Trrc5I5pChKaPQM+g==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
@@ -3733,6 +3891,10 @@ packages:
proxy-from-env@1.1.0: proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs@6.13.0: qs@6.13.0:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@@ -3880,6 +4042,10 @@ packages:
remark-stringify@11.0.0: remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} 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: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -3903,6 +4069,10 @@ packages:
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} 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==} resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
engines: {node: '>=10'} engines: {node: '>=10'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tailwind-merge@3.3.1: tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
@@ -4051,10 +4224,25 @@ packages:
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
engines: {node: '>=14.0.0'} 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: toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'} 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: trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -4091,6 +4279,10 @@ packages:
undici-types@7.14.0: undici-types@7.14.0:
resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} 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: unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -4307,9 +4499,25 @@ packages:
vscode-uri@3.0.8: vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-namespaces@2.0.1: web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} 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: why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4320,6 +4528,13 @@ packages:
peerDependencies: peerDependencies:
react: '>=16.8.0' 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: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4335,6 +4550,8 @@ packages:
snapshots: snapshots:
'@acemir/cssom@0.9.31': {}
'@antfu/install-pkg@1.1.0': '@antfu/install-pkg@1.1.0':
dependencies: dependencies:
package-manager-detector: 1.5.0 package-manager-detector: 1.5.0
@@ -4342,6 +4559,24 @@ snapshots:
'@antfu/utils@9.3.0': {} '@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': '@aws-crypto/crc32@5.2.0':
dependencies: dependencies:
'@aws-crypto/util': 5.2.0 '@aws-crypto/util': 5.2.0
@@ -4944,6 +5179,10 @@ snapshots:
'@braintree/sanitize-url@7.1.1': {} '@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': '@builder.io/jsx-loc-internals@0.0.1':
dependencies: dependencies:
'@babel/parser': 7.28.4 '@babel/parser': 7.28.4
@@ -4972,6 +5211,28 @@ snapshots:
'@chevrotain/utils@11.0.3': {} '@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': {} '@date-fns/tz@1.4.1': {}
'@drizzle-team/brocli@0.10.2': {} '@drizzle-team/brocli@0.10.2': {}
@@ -5199,6 +5460,8 @@ snapshots:
'@esbuild/win32-x64@0.25.10': '@esbuild/win32-x64@0.25.10':
optional: true optional: true
'@exodus/bytes@1.15.0': {}
'@floating-ui/core@1.7.3': '@floating-ui/core@1.7.3':
dependencies: dependencies:
'@floating-ui/utils': 0.2.10 '@floating-ui/utils': 0.2.10
@@ -5269,6 +5532,10 @@ snapshots:
dependencies: dependencies:
langium: 3.3.1 langium: 3.3.1
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
'@radix-ui/number@1.1.1': {} '@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {} '@radix-ui/primitive@1.1.3': {}
@@ -6755,6 +7022,8 @@ snapshots:
add@2.0.6: {} add@2.0.6: {}
agent-base@7.1.4: {}
aria-hidden@1.2.6: aria-hidden@1.2.6:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -6789,6 +7058,10 @@ snapshots:
baseline-browser-mapping@2.8.12: {} baseline-browser-mapping@2.8.12: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
body-parser@1.20.3: body-parser@1.20.3:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@@ -6928,8 +7201,20 @@ snapshots:
dependencies: dependencies:
layout-base: 2.0.1 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: {} 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: {} csstype@3.1.3: {}
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1):
@@ -7116,6 +7401,13 @@ snapshots:
d3: 7.9.0 d3: 7.9.0
lodash-es: 4.17.21 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-jalali@4.1.0-0: {}
date-fns@4.1.0: {} date-fns@4.1.0: {}
@@ -7132,6 +7424,8 @@ snapshots:
decimal.js-light@2.5.1: {} decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
decode-named-character-reference@1.2.0: decode-named-character-reference@1.2.0:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
@@ -7428,6 +7722,9 @@ snapshots:
fresh@0.5.2: {} fresh@0.5.2: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -7601,6 +7898,12 @@ snapshots:
property-information: 7.1.0 property-information: 7.1.0
space-separated-tokens: 2.0.2 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-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {} html-void-elements@3.0.0: {}
@@ -7613,6 +7916,20 @@ snapshots:
statuses: 2.0.1 statuses: 2.0.1
toidentifier: 1.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: iconv-lite@0.4.24:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -7653,6 +7970,8 @@ snapshots:
is-plain-obj@4.1.0: {} is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
is-property@1.0.2: {} is-property@1.0.2: {}
is-what@4.1.16: {} is-what@4.1.16: {}
@@ -7663,6 +7982,33 @@ snapshots:
js-tokens@4.0.0: {} 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: {} jsesc@3.1.0: {}
json5@2.2.3: {} json5@2.2.3: {}
@@ -7752,6 +8098,8 @@ snapshots:
loupe@3.2.1: {} loupe@3.2.1: {}
lru-cache@11.2.7: {}
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
@@ -7943,6 +8291,8 @@ snapshots:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
mdn-data@2.27.1: {}
media-typer@0.3.0: {} media-typer@0.3.0: {}
merge-descriptors@1.0.3: {} merge-descriptors@1.0.3: {}
@@ -8273,6 +8623,10 @@ snapshots:
dependencies: dependencies:
entities: 6.0.1 entities: 6.0.1
parse5@8.0.0:
dependencies:
entities: 6.0.1
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-data-parser@0.1.0: {} path-data-parser@0.1.0: {}
@@ -8301,6 +8655,14 @@ snapshots:
exsolve: 1.0.7 exsolve: 1.0.7
pathe: 2.0.3 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: {} pnpm@10.18.0: {}
points-on-curve@0.2.0: {} points-on-curve@0.2.0: {}
@@ -8342,6 +8704,8 @@ snapshots:
proxy-from-env@1.1.0: {} proxy-from-env@1.1.0: {}
punycode@2.3.1: {}
qs@6.13.0: qs@6.13.0:
dependencies: dependencies:
side-channel: 1.1.0 side-channel: 1.1.0
@@ -8538,6 +8902,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2 mdast-util-to-markdown: 2.1.2
unified: 11.0.5 unified: 11.0.5
require-from-string@2.0.2: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
robust-predicates@3.0.2: {} robust-predicates@3.0.2: {}
@@ -8583,6 +8949,10 @@ snapshots:
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {} scheduler@0.27.0: {}
semver@6.3.1: {} semver@6.3.1: {}
@@ -8724,6 +9094,8 @@ snapshots:
dependencies: dependencies:
copy-anything: 3.0.5 copy-anything: 3.0.5
symbol-tree@3.2.4: {}
tailwind-merge@3.3.1: {} tailwind-merge@3.3.1: {}
tailwindcss-animate@1.0.7(tailwindcss@4.1.14): tailwindcss-animate@1.0.7(tailwindcss@4.1.14):
@@ -8761,8 +9133,22 @@ snapshots:
tinyspy@3.0.2: {} tinyspy@3.0.2: {}
tldts-core@7.0.25: {}
tldts@7.0.25:
dependencies:
tldts-core: 7.0.25
toidentifier@1.0.1: {} 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: {} trim-lines@3.0.1: {}
trough@2.2.0: {} trough@2.2.0: {}
@@ -8791,6 +9177,8 @@ snapshots:
undici-types@7.14.0: {} undici-types@7.14.0: {}
undici@7.24.2: {}
unified@11.0.5: unified@11.0.5:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@@ -8963,7 +9351,7 @@ snapshots:
lightningcss: 1.30.1 lightningcss: 1.30.1
tsx: 4.20.6 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: dependencies:
'@vitest/expect': 2.1.9 '@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.20(@types/node@24.7.0)(lightningcss@1.30.1)) '@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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 24.7.0 '@types/node': 24.7.0
jsdom: 28.1.0
transitivePeerDependencies: transitivePeerDependencies:
- less - less
- lightningcss - lightningcss
@@ -9015,8 +9404,24 @@ snapshots:
vscode-uri@3.0.8: {} vscode-uri@3.0.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
web-namespaces@2.0.1: {} 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: why-is-node-running@2.3.0:
dependencies: dependencies:
siginfo: 2.0.0 siginfo: 2.0.0
@@ -9029,6 +9434,10 @@ snapshots:
regexparam: 3.0.0 regexparam: 3.0.0
use-sync-external-store: 1.6.0(react@19.2.1) 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@3.1.1: {}
yallist@5.0.0: {} yallist@5.0.0: {}

查看文件

@@ -6,6 +6,7 @@ import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth"; import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers"; import { appRouter } from "../routers";
import { createContext } from "./context"; import { createContext } from "./context";
import { registerMediaProxy } from "./mediaProxy";
import { serveStatic, setupVite } from "./vite"; import { serveStatic, setupVite } from "./vite";
function isPortAvailable(port: number): Promise<boolean> { function isPortAvailable(port: number): Promise<boolean> {
@@ -30,6 +31,7 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
async function startServer() { async function startServer() {
const app = express(); const app = express();
const server = createServer(app); const server = createServer(app);
registerMediaProxy(app);
// Configure body parser with larger size limit for file uploads // Configure body parser with larger size limit for file uploads
app.use(express.json({ limit: "50mb" })); app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true })); app.use(express.urlencoded({ limit: "50mb", extended: true }));
@@ -51,7 +53,8 @@ async function startServer() {
} }
const preferredPort = parseInt(process.env.PORT || "3000"); 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) { if (port !== preferredPort) {
console.log(`Port ${preferredPort} is busy, using port ${port} instead`); console.log(`Port ${preferredPort} is busy, using port ${port} instead`);

56
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));
}

查看文件

@@ -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", () => { describe("video.get input validation", () => {
it("requires authentication", async () => { it("requires authentication", async () => {
const { ctx } = createMockContext(null); const { ctx } = createMockContext(null);

查看文件

@@ -258,6 +258,32 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec
return { videoId, url }; 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 }) => { list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserVideos(ctx.user.id); return db.getUserVideos(ctx.user.id);
}), }),

62
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();
});

419
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;
}

查看文件

@@ -14,6 +14,15 @@ export default defineConfig({
}, },
test: { test: {
environment: "node", 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"],
],
}, },
}); });