commit 15138cb8333b890dd1cf8f541f9a17a36bbd186f Author: eust-w Date: Tue Dec 2 18:54:14 2025 +0800 :tada: init code diff --git a/.cursor/commands/openspec-apply.md b/.cursor/commands/openspec-apply.md new file mode 100644 index 0000000..778249c --- /dev/null +++ b/.cursor/commands/openspec-apply.md @@ -0,0 +1,23 @@ +--- +name: /openspec-apply +id: openspec-apply +category: OpenSpec +description: Implement an approved OpenSpec change and keep tasks in sync. +--- + +**约束条件** +- 优先采用简单、最小化的实现,只有在明确要求或明显需要时才添加复杂性。 +- 保持更改紧密围绕请求的结果。 +- 如需更多 OpenSpec 约定或说明,请参考 `openspec/AGENTS.md`(位于 `openspec/` 目录中——如果看不到,请运行 `ls openspec` 或 `openspec update`)。 + +**步骤** +将这些步骤作为待办事项跟踪,逐一完成。 +1. 阅读 `changes//proposal.md`、`design.md`(如果存在)和 `tasks.md` 以确认范围和验收标准。 +2. 按顺序完成任务,保持编辑最小化并专注于请求的更改。 +3. 在更新状态之前确认完成——确保 `tasks.md` 中的每个项目都已完成。 +4. 在所有工作完成后更新清单,以便每个任务都标记为 `- [x]` 并反映实际情况。 +5. 需要额外上下文时,参考 `openspec list` 或 `openspec show `。 + +**参考** +- 如果在实施过程中需要提案的额外上下文,请使用 `openspec show --json --deltas-only`。 + diff --git a/.cursor/commands/openspec-archive.md b/.cursor/commands/openspec-archive.md new file mode 100644 index 0000000..a4792cf --- /dev/null +++ b/.cursor/commands/openspec-archive.md @@ -0,0 +1,27 @@ +--- +name: /openspec-archive +id: openspec-archive +category: OpenSpec +description: Archive a deployed OpenSpec change and update specs. +--- + +**约束条件** +- 优先采用简单、最小化的实现,只有在明确要求或明显需要时才添加复杂性。 +- 保持更改紧密围绕请求的结果。 +- 如需更多 OpenSpec 约定或说明,请参考 `openspec/AGENTS.md`(位于 `openspec/` 目录中——如果看不到,请运行 `ls openspec` 或 `openspec update`)。 + +**步骤** +1. 确定要归档的变更 ID: + - 如果此提示已包含特定的变更 ID(例如在由斜杠命令参数填充的 `` 块内),请在修剪空白后使用该值。 + - 如果对话中松散地引用了变更(例如通过标题或摘要),运行 `openspec list` 以显示可能的 ID,分享相关候选,并确认用户打算使用哪一个。 + - 否则,查看对话,运行 `openspec list`,并询问用户要归档哪个变更;在继续之前等待确认的变更 ID。 + - 如果您仍然无法识别单个变更 ID,请停止并告诉用户您还无法归档任何内容。 +2. 通过运行 `openspec list`(或 `openspec show `)验证变更 ID,如果变更缺失、已归档或尚未准备好归档,则停止。 +3. 运行 `openspec archive --yes`,以便 CLI 移动变更并应用规范更新而不提示(仅对仅工具工作使用 `--skip-specs`)。 +4. 查看命令输出以确认目标规范已更新,并且变更已进入 `changes/archive/`。 +5. 使用 `openspec validate --strict` 进行验证,如果看起来有问题,请使用 `openspec show ` 进行检查。 + +**参考** +- 在归档之前使用 `openspec list` 确认变更 ID。 +- 使用 `openspec list --specs` 检查刷新的规范,并在移交之前解决任何验证问题。 + diff --git a/.cursor/commands/openspec-proposal.md b/.cursor/commands/openspec-proposal.md new file mode 100644 index 0000000..9268005 --- /dev/null +++ b/.cursor/commands/openspec-proposal.md @@ -0,0 +1,27 @@ +--- +name: /openspec-proposal +id: openspec-proposal +category: OpenSpec +description: Scaffold a new OpenSpec change and validate strictly. +--- + +**约束条件** +- 优先采用简单、最小化的实现,只有在明确要求或明显需要时才添加复杂性。 +- 保持更改紧密围绕请求的结果。 +- 如需更多 OpenSpec 约定或说明,请参考 `openspec/AGENTS.md`(位于 `openspec/` 目录中——如果看不到,请运行 `ls openspec` 或 `openspec update`)。 +- 识别任何模糊或含糊的细节,在编辑文件之前提出必要的后续问题。 + +**步骤** +1. 查看 `openspec/project.md`,运行 `openspec list` 和 `openspec list --specs`,并检查相关代码或文档(例如通过 `rg`/`ls`)以了解当前行为;注意任何需要澄清的空白。 +2. 选择一个唯一的动词引导的 `change-id`,并在 `openspec/changes//` 下搭建 `proposal.md`、`tasks.md` 和 `design.md`(需要时)。 +3. 将变更映射到具体的能力或需求,将多范围的工作分解为具有明确关系和顺序的不同规范增量。 +4. 当解决方案跨越多个系统、引入新模式或需要在提交规范之前进行权衡讨论时,在 `design.md` 中捕获架构推理。 +5. 在 `changes//specs//spec.md` 中起草规范增量(每个能力一个文件夹),使用 `## ADDED|MODIFIED|REMOVED Requirements`,每个需求至少包含一个 `#### Scenario:`,并在相关时交叉引用相关能力。 +6. 将 `tasks.md` 起草为有序的小型、可验证工作项列表,这些工作项提供用户可见的进度,包括验证(测试、工具),并突出依赖关系或可并行化的工作。 +7. 使用 `openspec validate --strict` 进行验证,并在分享提案之前解决所有问题。 + +**参考** +- 当验证失败时,使用 `openspec show --json --deltas-only` 或 `openspec show --type spec` 检查详细信息。 +- 在编写新需求之前,使用 `rg -n "Requirement:|Scenario:" openspec/specs` 搜索现有需求。 +- 使用 `rg `、`ls` 或直接文件读取来探索代码库,以便提案与当前实现现实保持一致。 + diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..0959d4f --- /dev/null +++ b/.drone.yml @@ -0,0 +1,4 @@ +kind: template +load: buildgo.jsonnet +data: + deployName: "videosummary" \ No newline at end of file diff --git a/.farmer.dev.json b/.farmer.dev.json new file mode 100644 index 0000000..97df1af --- /dev/null +++ b/.farmer.dev.json @@ -0,0 +1,15 @@ +{ + "app_id": "videosummary", + "deploy_name": "videosummary", + "service_name": "videosummary", + "app_label": "videosummary", + "image_repo": "ccr-29eug8s3-vpc.cnc.bj.baidubce.com/dcloud/videosummary", + "replicas": 1, + "commands": ["app"], + "ports": [ + { + "port": 80, + "protocol": "tcp" + } + ] + } \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e21a3c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +env/ +ENV/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment variables +.env +.env.local + +# Config file with sensitive data (optional, uncomment if needed) +# config.yaml + +# Uploads +uploads/* +!uploads/.gitkeep + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# MongoDB +*.db + +# Temporary files +*.tmp +*.temp + +# config.yaml +*config.yaml diff --git a/.windsurf/workflows/openspec-apply.md b/.windsurf/workflows/openspec-apply.md new file mode 100644 index 0000000..b42d374 --- /dev/null +++ b/.windsurf/workflows/openspec-apply.md @@ -0,0 +1,21 @@ +--- +description: Implement an approved OpenSpec change and keep tasks in sync. +auto_execution_mode: 3 +--- + +**约束条件** +- 优先采用简单、最小化的实现,只有在明确要求或明显需要时才添加复杂性。 +- 保持更改紧密围绕请求的结果。 +- 如需更多 OpenSpec 约定或说明,请参考 `openspec/AGENTS.md`(位于 `openspec/` 目录中——如果看不到,请运行 `ls openspec` 或 `openspec update`)。 + +**步骤** +将这些步骤作为待办事项跟踪,逐一完成。 +1. 阅读 `changes//proposal.md`、`design.md`(如果存在)和 `tasks.md` 以确认范围和验收标准。 +2. 按顺序完成任务,保持编辑最小化并专注于请求的更改。 +3. 在更新状态之前确认完成——确保 `tasks.md` 中的每个项目都已完成。 +4. 在所有工作完成后更新清单,以便每个任务都标记为 `- [x]` 并反映实际情况。 +5. 需要额外上下文时,参考 `openspec list` 或 `openspec show `。 + +**参考** +- 如果在实施过程中需要提案的额外上下文,请使用 `openspec show --json --deltas-only`。 + diff --git a/.windsurf/workflows/openspec-archive.md b/.windsurf/workflows/openspec-archive.md new file mode 100644 index 0000000..46f7542 --- /dev/null +++ b/.windsurf/workflows/openspec-archive.md @@ -0,0 +1,25 @@ +--- +description: Archive a deployed OpenSpec change and update specs. +auto_execution_mode: 3 +--- + +**约束条件** +- 优先采用简单、最小化的实现,只有在明确要求或明显需要时才添加复杂性。 +- 保持更改紧密围绕请求的结果。 +- 如需更多 OpenSpec 约定或说明,请参考 `openspec/AGENTS.md`(位于 `openspec/` 目录中——如果看不到,请运行 `ls openspec` 或 `openspec update`)。 + +**步骤** +1. 确定要归档的变更 ID: + - 如果此提示已包含特定的变更 ID(例如在由斜杠命令参数填充的 `` 块内),请在修剪空白后使用该值。 + - 如果对话中松散地引用了变更(例如通过标题或摘要),运行 `openspec list` 以显示可能的 ID,分享相关候选,并确认用户打算使用哪一个。 + - 否则,查看对话,运行 `openspec list`,并询问用户要归档哪个变更;在继续之前等待确认的变更 ID。 + - 如果您仍然无法识别单个变更 ID,请停止并告诉用户您还无法归档任何内容。 +2. 通过运行 `openspec list`(或 `openspec show `)验证变更 ID,如果变更缺失、已归档或尚未准备好归档,则停止。 +3. 运行 `openspec archive --yes`,以便 CLI 移动变更并应用规范更新而不提示(仅对仅工具工作使用 `--skip-specs`)。 +4. 查看命令输出以确认目标规范已更新,并且变更已进入 `changes/archive/`。 +5. 使用 `openspec validate --strict` 进行验证,如果看起来有问题,请使用 `openspec show ` 进行检查。 + +**参考** +- 在归档之前使用 `openspec list` 确认变更 ID。 +- 使用 `openspec list --specs` 检查刷新的规范,并在移交之前解决任何验证问题。 + diff --git a/.windsurf/workflows/openspec-proposal.md b/.windsurf/workflows/openspec-proposal.md new file mode 100644 index 0000000..5284899 --- /dev/null +++ b/.windsurf/workflows/openspec-proposal.md @@ -0,0 +1,25 @@ +--- +description: Scaffold a new OpenSpec change and validate strictly. +auto_execution_mode: 3 +--- + +**约束条件** +- 优先采用简单、最小化的实现,只有在明确要求或明显需要时才添加复杂性。 +- 保持更改紧密围绕请求的结果。 +- 如需更多 OpenSpec 约定或说明,请参考 `openspec/AGENTS.md`(位于 `openspec/` 目录中——如果看不到,请运行 `ls openspec` 或 `openspec update`)。 +- 识别任何模糊或含糊的细节,在编辑文件之前提出必要的后续问题。 + +**步骤** +1. 查看 `openspec/project.md`,运行 `openspec list` 和 `openspec list --specs`,并检查相关代码或文档(例如通过 `rg`/`ls`)以了解当前行为;注意任何需要澄清的空白。 +2. 选择一个唯一的动词引导的 `change-id`,并在 `openspec/changes//` 下搭建 `proposal.md`、`tasks.md` 和 `design.md`(需要时)。 +3. 将变更映射到具体的能力或需求,将多范围的工作分解为具有明确关系和顺序的不同规范增量。 +4. 当解决方案跨越多个系统、引入新模式或需要在提交规范之前进行权衡讨论时,在 `design.md` 中捕获架构推理。 +5. 在 `changes//specs//spec.md` 中起草规范增量(每个能力一个文件夹),使用 `## ADDED|MODIFIED|REMOVED Requirements`,每个需求至少包含一个 `#### Scenario:`,并在相关时交叉引用相关能力。 +6. 将 `tasks.md` 起草为有序的小型、可验证工作项列表,这些工作项提供用户可见的进度,包括验证(测试、工具),并突出依赖关系或可并行化的工作。 +7. 使用 `openspec validate --strict` 进行验证,并在分享提案之前解决所有问题。 + +**参考** +- 当验证失败时,使用 `openspec show --json --deltas-only` 或 `openspec show --type spec` 检查详细信息。 +- 在编写新需求之前,使用 `rg -n "Requirement:|Scenario:" openspec/specs` 搜索现有需求。 +- 使用 `rg `、`ls` 或直接文件读取来探索代码库,以便提案与当前实现现实保持一致。 + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7ef7fa6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ + +# OpenSpec 说明 + +这些说明适用于在此项目中工作的 AI 助手。 + +当请求涉及以下情况时,请始终打开 `@/openspec/AGENTS.md`: +- 提及规划或提案(如 proposal、spec、change、plan 等词) +- 引入新功能、破坏性变更、架构调整或重大的性能/安全工作 +- 听起来模糊不清,需要在编码前查看权威规范 + +使用 `@/openspec/AGENTS.md` 了解: +- 如何创建和应用变更提案 +- 规范格式和约定 +- 项目结构和指南 + +保留此托管块,以便 'openspec update' 可以刷新说明。 + + \ No newline at end of file diff --git a/API.md b/API.md new file mode 100644 index 0000000..71f3bc7 --- /dev/null +++ b/API.md @@ -0,0 +1,466 @@ +# Video Analysis API 文档 + +## 概述 + +Video Analysis API 提供视频上传、内容分析、总结生成和视频对比功能。所有 API 端点都遵循 RESTful 设计原则。 + +**Base URL**: `http://localhost:8080/api/videos` + +## 通用响应格式 + +### 成功响应 +```json +{ + "success": true, + "data": { ... }, + "message": "操作成功" +} +``` + +### 错误响应 +```json +{ + "success": false, + "message": "错误信息" +} +``` + +## API 端点 + +### 1. 上传视频 + +上传视频文件到服务器。 + +**端点**: `POST /api/videos/upload` + +**请求格式**: `multipart/form-data` + +**请求参数**: +- `file` (file, required): 视频文件 + +**支持的视频格式**: mp4, avi, mov, mkv, wmv, flv, webm + +**文件大小限制**: 500MB + +**响应示例**: +```json +{ + "success": true, + "data": { + "video_id": "507f1f77bcf86cd799439011", + "filename": "example.mp4", + "file_size": 10485760, + "status": "uploaded", + "upload_time": "2024-01-15T10:30:00.000Z" + }, + "message": "Video uploaded successfully" +} +``` + +**错误响应**: +- `400`: 文件格式不支持或文件大小超限 +- `500`: 服务器内部错误 + +--- + +### 2. 获取视频列表 + +获取所有已上传的视频列表。 + +**端点**: `GET /api/videos` + +**查询参数**: +- `limit` (integer, optional): 返回数量限制,默认 100 +- `skip` (integer, optional): 跳过数量,默认 0 + +**响应示例**: +```json +{ + "success": true, + "data": [ + { + "id": "507f1f77bcf86cd799439011", + "filename": "example.mp4", + "file_size": 10485760, + "status": "analyzed", + "upload_time": "2024-01-15T10:30:00.000Z" + } + ] +} +``` + +--- + +### 3. 获取视频详情 + +获取指定视频的详细信息,包括分析结果和总结。 + +**端点**: `GET /api/videos/{video_id}` + +**路径参数**: +- `video_id` (string, required): 视频 ID + +**响应示例**: +```json +{ + "success": true, + "data": { + "id": "507f1f77bcf86cd799439011", + "filename": "example.mp4", + "file_path": "/path/to/video.mp4", + "file_size": 10485760, + "mime_type": "video/mp4", + "status": "analyzed", + "upload_time": "2024-01-15T10:30:00.000Z", + "analysis": { + "id": "507f1f77bcf86cd799439012", + "content": "这段视频描绘了...", + "fps": 2, + "created_at": "2024-01-15T10:35:00.000Z" + }, + "summary": { + "id": "507f1f77bcf86cd799439013", + "summary_text": "视频主要内容总结...", + "created_at": "2024-01-15T10:40:00.000Z" + } + } +} +``` + +**错误响应**: +- `404`: 视频不存在 + +--- + +### 4. 分析视频内容 + +使用阿里云 DashScope API 分析视频内容。 + +**端点**: `POST /api/videos/{video_id}/analyze` + +**路径参数**: +- `video_id` (string, required): 视频 ID + +**请求体** (可选): +```json +{ + "prompt": "自定义分析提示词" +} +``` + +**响应示例**: +```json +{ + "success": true, + "data": { + "id": "507f1f77bcf86cd799439012", + "video_id": "507f1f77bcf86cd799439011", + "content": "这段视频描绘了...", + "fps": 2, + "created_at": "2024-01-15T10:35:00.000Z" + }, + "message": "Video analyzed successfully" +} +``` + +**错误响应**: +- `400`: 视频不存在或分析失败 +- `404`: 视频不存在 + +**注意**: 分析过程可能需要较长时间,取决于视频长度。 + +--- + +### 5. 生成视频总结 + +为视频生成内容总结。 + +**端点**: `POST /api/videos/{video_id}/summarize` + +**路径参数**: +- `video_id` (string, required): 视频 ID + +**响应示例**: +```json +{ + "success": true, + "data": { + "id": "507f1f77bcf86cd799439013", + "video_id": "507f1f77bcf86cd799439011", + "summary_text": "这段视频主要讲述了...", + "created_at": "2024-01-15T10:40:00.000Z" + }, + "message": "Summary generated successfully" +} +``` + +**错误响应**: +- `400`: 视频不存在或总结生成失败 +- `404`: 视频不存在 + +--- + +### 6. 获取视频总结 + +获取视频的总结内容。 + +**端点**: `GET /api/videos/{video_id}/summary` + +**路径参数**: +- `video_id` (string, required): 视频 ID + +**响应示例**: +```json +{ + "success": true, + "data": { + "id": "507f1f77bcf86cd799439013", + "video_id": "507f1f77bcf86cd799439011", + "summary_text": "这段视频主要讲述了...", + "created_at": "2024-01-15T10:40:00.000Z" + } +} +``` + +**错误响应**: +- `404`: 总结不存在 + +--- + +### 7. 对比多个视频 + +对比多个视频的内容,找出相似之处和不同之处。 + +**端点**: `POST /api/videos/compare` + +**请求体**: +```json +{ + "video_ids": [ + "507f1f77bcf86cd799439011", + "507f1f77bcf86cd799439014" + ] +} +``` + +**请求参数**: +- `video_ids` (array, required): 视频 ID 数组,至少需要 2 个 + +**响应示例**: +```json +{ + "success": true, + "data": { + "id": "507f1f77bcf86cd799439015", + "video_ids": [ + "507f1f77bcf86cd799439011", + "507f1f77bcf86cd799439014" + ], + "comparison_result": "这两个视频的相似之处是...不同之处是...", + "created_at": "2024-01-15T11:00:00.000Z" + }, + "message": "Comparison completed successfully" +} +``` + +**错误响应**: +- `400`: 视频数量不足或对比失败 +- `404`: 某个视频不存在 + +--- + +### 8. 获取对比结果 + +获取指定对比任务的结果。 + +**端点**: `GET /api/videos/compare/{comparison_id}` + +**路径参数**: +- `comparison_id` (string, required): 对比任务 ID + +**响应示例**: +```json +{ + "success": true, + "data": { + "id": "507f1f77bcf86cd799439015", + "video_ids": [ + "507f1f77bcf86cd799439011", + "507f1f77bcf86cd799439014" + ], + "comparison_result": "这两个视频的相似之处是...不同之处是...", + "created_at": "2024-01-15T11:00:00.000Z" + } +} +``` + +**错误响应**: +- `404`: 对比结果不存在 + +--- + +## 状态码说明 + +### 视频状态 (status) +- `uploaded`: 已上传,等待分析 +- `analyzing`: 分析中 +- `analyzed`: 已分析完成 +- `failed`: 分析失败 + +--- + +## 错误码说明 + +| HTTP 状态码 | 说明 | +|------------|------| +| 200 | 请求成功 | +| 201 | 创建成功 | +| 400 | 请求参数错误 | +| 404 | 资源不存在 | +| 500 | 服务器内部错误 | + +--- + +## 使用示例 + +### cURL 示例 + +#### 上传视频 +```bash +curl -X POST http://localhost:8080/api/videos/upload \ + -F "file=@/path/to/video.mp4" +``` + +#### 分析视频 +```bash +curl -X POST http://localhost:8080/api/videos/507f1f77bcf86cd799439011/analyze \ + -H "Content-Type: application/json" +``` + +#### 生成总结 +```bash +curl -X POST http://localhost:8080/api/videos/507f1f77bcf86cd799439011/summarize \ + -H "Content-Type: application/json" +``` + +#### 对比视频 +```bash +curl -X POST http://localhost:8080/api/videos/compare \ + -H "Content-Type: application/json" \ + -d '{ + "video_ids": [ + "507f1f77bcf86cd799439011", + "507f1f77bcf86cd799439014" + ] + }' +``` + +### Python 示例 + +```python +import requests + +# 上传视频 +with open('video.mp4', 'rb') as f: + response = requests.post( + 'http://localhost:8080/api/videos/upload', + files={'file': f} + ) + video_data = response.json() + video_id = video_data['data']['video_id'] + +# 分析视频 +response = requests.post( + f'http://localhost:8080/api/videos/{video_id}/analyze' +) +analysis = response.json() + +# 生成总结 +response = requests.post( + f'http://localhost:8080/api/videos/{video_id}/summarize' +) +summary = response.json() + +# 对比视频 +response = requests.post( + 'http://localhost:8080/api/videos/compare', + json={ + 'video_ids': ['video_id_1', 'video_id_2'] + } +) +comparison = response.json() +``` + +### JavaScript 示例 + +```javascript +// 上传视频 +const formData = new FormData(); +formData.append('file', fileInput.files[0]); + +fetch('http://localhost:8080/api/videos/upload', { + method: 'POST', + body: formData +}) +.then(response => response.json()) +.then(data => { + console.log('Uploaded:', data.data.video_id); +}); + +// 分析视频 +fetch(`http://localhost:8080/api/videos/${videoId}/analyze`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } +}) +.then(response => response.json()) +.then(data => { + console.log('Analysis:', data.data.content); +}); +``` + +--- + +## 配置要求 + +### 主要配置:config.yaml + +所有配置主要在 `config.yaml` 文件中设置: + +```yaml +dashscope: + api_key: "your-dashscope-api-key" # 必需 + +mongodb: + uri: "mongodb://localhost:27017" + database: "videoSummary" + +server: + host: "0.0.0.0" + port: 8080 + mode: "debug" +``` + +**注意**: +- 所有配置均在 `config.yaml` 文件中设置 +- 请确保配置文件中的值正确 + +--- + +## 注意事项 + +1. **API Key 安全**: 请确保 DashScope API Key 在 `config.yaml` 中设置,不要将包含 API Key 的 `config.yaml` 提交到代码仓库 +2. **文件大小**: 当前限制为 500MB,可在 `config.yaml` 中配置 +3. **处理时间**: 视频分析和总结生成可能需要较长时间,取决于视频长度和 API 响应速度 +4. **CORS**: API 已启用 CORS,支持跨域请求 +5. **错误处理**: 所有 API 调用都应检查 `success` 字段并处理错误情况 + +--- + +## 更新日志 + +### v1.0.0 (2024-01-15) +- 初始版本发布 +- 支持视频上传、分析、总结和对比功能 + diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..c7ca51b --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,124 @@ +# 配置说明文档 + +本文档说明运行视频分析服务需要配置的内容。 + +## 必须配置项 + +### 1. DashScope API Key(必需) + +**用途**: 用于调用阿里云 DashScope 多模态 API 进行视频分析 + +**配置方式**: 在 `config.yaml` 文件中配置 + ```yaml + dashscope: + api_key: "your-api-key-here" + ``` + +**获取方式**: +- 访问 https://dashscope.console.aliyun.com/ +- 注册/登录阿里云账号 +- 创建 API Key + +### 2. MongoDB 连接(必需) + +**用途**: 存储视频元数据和分析结果 + +**配置方式**: 在 `config.yaml` 文件中配置 + ```yaml + mongodb: + uri: "mongodb://localhost:27017" + database: "videoSummary" + ``` + +**要求**: +- 确保 MongoDB 服务已启动 +- 确保数据库可访问 + +## 可选配置项 + +### 服务器配置 + +```yaml +server: + host: "0.0.0.0" # 监听地址,默认 0.0.0.0 + port: 8080 # 监听端口,默认 8080 + mode: "debug" # 运行模式: debug, release, test +``` + +### DashScope 模型配置 + +```yaml +dashscope: + model: "qwen3-vl-plus" # 多模态模型名称 + fps: 2 # 视频抽帧频率(每秒帧数) +``` + +### 文件上传配置 + +```yaml +upload: + max_size: 524288000 # 最大文件大小(字节),默认 500MB + allowed_extensions: ["mp4", "avi", "mov", "mkv", "wmv", "flv", "webm"] +``` + +### MongoDB 连接池配置 + +```yaml +mongodb: + max_pool_size: 100 # 最大连接数 + min_pool_size: 10 # 最小连接数 +``` + +### 日志配置 + +```yaml +log: + level: "info" # 日志级别: debug, info, warn, error + format: "json" # 日志格式: json, text + output: "stdout" # 输出位置: stdout 或文件路径 +``` + +## 配置来源 + +所有配置均从 `config.yaml` 文件读取。 + +## 快速开始 + +### 最小配置 + +只需要配置以下两项即可运行: + +1. **DashScope API Key**(在 `config.yaml` 中设置) +2. **MongoDB 连接**(确保 MongoDB 运行在默认地址 `mongodb://localhost:27017`) + +### 配置示例 + +```yaml +# config.yaml 最小配置示例 +dashscope: + api_key: "sk-your-api-key-here" + +mongodb: + uri: "mongodb://localhost:27017" + database: "videoSummary" +``` + +## 注意事项 + +1. **API Key 安全**: + - 不要将 API Key 提交到版本控制系统 + - 建议将 `config.yaml` 添加到 `.gitignore` 或使用配置模板文件 + +2. **MongoDB 连接**: + - 确保 MongoDB 服务已启动 + - 生产环境建议配置认证和 SSL + +3. **文件上传大小**: + - 根据服务器资源调整 `max_size` + - 大文件上传可能需要更长的处理时间 + +4. **DashScope FPS 参数**: + - 较低的 fps 值可以减少 API 调用成本,但可能丢失细节 + - 较高的 fps 值可以获得更详细的分析,但成本更高 + - 默认值 2 是平衡性能和成本的选择 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0bbe9e --- /dev/null +++ b/README.md @@ -0,0 +1,295 @@ +# Video Summary - 视频分析与总结系统 + +基于 Python Flask 和阿里云 DashScope API 的视频内容分析与总结系统。 + +![系统界面](image.png) + +## 功能特性 + +- 📹 **视频上传**: 支持多种视频格式上传(mp4, avi, mov, mkv, wmv, flv, webm) +- 🔍 **视频分析**: 使用阿里云 DashScope 多模态 API 分析视频内容 +- 📝 **自动总结**: 自动生成视频内容摘要 +- 🔄 **视频对比**: 支持多个视频之间的内容对比分析 +- 🎨 **现代化前端**: 简洁美观的静态前端界面 + +## 技术栈 + +- **后端**: Python Flask +- **数据库**: MongoDB +- **AI 服务**: 阿里云 DashScope (qwen3-vl-plus) +- **前端**: HTML/CSS/JavaScript (静态页面) + +## 项目结构 + +``` +videoSummary/ +├── app/ +│ ├── __init__.py # Flask 应用初始化 +│ ├── config.py # 配置管理 +│ ├── routes/ # API 路由 +│ │ └── video_routes.py +│ ├── services/ # 业务逻辑服务 +│ │ ├── video_service.py +│ │ ├── dashscope_service.py +│ │ └── analysis_service.py +│ ├── models/ # 数据模型 +│ │ ├── video.py +│ │ ├── analysis.py +│ │ └── comparison.py +│ └── utils/ # 工具函数 +│ ├── logger.py +│ ├── validators.py +│ └── file_utils.py +├── static/ # 静态前端文件 +│ ├── index.html +│ ├── style.css +│ └── app.js +├── uploads/ # 视频文件存储目录 +├── config.yaml # 配置文件 +├── requirements.txt # Python 依赖 +├── app.py # 应用入口 +├── API.md # API 文档 +└── README.md # 项目说明 +``` + +## 快速开始 + +### 1. 环境要求 + +- Python 3.8+ +- MongoDB 4.0+ +- 阿里云 DashScope API Key + +### 2. 安装依赖 + +#### 方式一:使用启动脚本(推荐) + +**Linux/Mac:** +```bash +./start.sh +``` + +**Windows:** +```cmd +start.bat +``` + +启动脚本会自动: +- 创建虚拟环境(如果不存在) +- 激活虚拟环境 +- 安装/更新依赖 +- 启动应用 + +#### 方式二:手动安装 + +```bash +# 创建虚拟环境 +python3 -m venv venv + +# 激活虚拟环境 +source venv/bin/activate # Linux/Mac +# 或 +venv\Scripts\activate # Windows + +# 安装依赖 +pip install -r requirements.txt +``` + +### 3. 配置 + +#### 主要配置方式:config.yaml(推荐) + +编辑 `config.yaml` 文件,设置所有配置项: + +```yaml +# DashScope API Configuration +dashscope: + api_key: "your-dashscope-api-key" # 必需:设置你的 DashScope API Key + +# MongoDB Configuration +mongodb: + uri: "mongodb://localhost:27017" + database: "videoSummary" + +# Server Configuration +server: + host: "0.0.0.0" + port: 8080 + mode: "debug" +``` + +**所有配置都在 `config.yaml` 中管理,这是主要的配置方式。** + +**注意**: +- 所有配置均在 `config.yaml` 文件中设置 +- 请确保配置文件中的值正确 + +### 4. 配置 DashScope API Key + +在 `config.yaml` 文件中设置你的 DashScope API Key: + +```yaml +dashscope: + api_key: "your-dashscope-api-key-here" +``` + +**重要**: 必须设置 `dashscope.api_key` 才能使用视频分析功能。 + +# 5. 启动 MongoDB + +确保 MongoDB 服务正在运行: + +```bash +# Linux/Mac +mongod + +# Windows +# 启动 MongoDB 服务 +``` + +### 6. 运行应用 + +#### 使用启动脚本(推荐) + +**Linux/Mac:** +```bash +./start.sh +``` + +**Windows:** +```cmd +start.bat +``` + +#### 手动运行 + +确保已激活虚拟环境,然后运行: + +```bash +# 激活虚拟环境 +source venv/bin/activate # Linux/Mac +# 或 +venv\Scripts\activate # Windows + +# 运行应用 +python app.py +``` + +应用将在 `http://localhost:8080` 启动。 + +### 6. 访问前端 + +在浏览器中打开 `http://localhost:8080/static/index.html` + +## API 使用 + +详细的 API 文档请参考 [API.md](API.md) + +### 快速示例 + +#### 上传视频 +```bash +curl -X POST http://localhost:8080/api/videos/upload \ + -F "file=@video.mp4" +``` + +#### 分析视频 +```bash +curl -X POST http://localhost:8080/api/videos/{video_id}/analyze +``` + +#### 生成总结 +```bash +curl -X POST http://localhost:8080/api/videos/{video_id}/summarize +``` + +#### 对比视频 +```bash +curl -X POST http://localhost:8080/api/videos/compare \ + -H "Content-Type: application/json" \ + -d '{"video_ids": ["id1", "id2"]}' +``` + +## 配置说明 + +### config.yaml + +主要配置项: + +- **server**: 服务器配置(host, port, mode) +- **mongodb**: MongoDB 连接配置 +- **dashscope**: DashScope API 配置(api_key, model, fps) +- **upload**: 上传配置(max_size, allowed_extensions) +- **log**: 日志配置(level, format, output) + +### 配置说明 + +所有配置项均在 `config.yaml` 文件中设置。详细配置说明请参考 `CONFIG.md` 文档。 + +## 开发 + +### 项目结构说明 + +- **app/**: Flask 应用代码 + - `routes/`: API 路由定义 + - `services/`: 业务逻辑层 + - `models/`: 数据模型 + - `utils/`: 工具函数 +- **static/**: 前端静态文件 +- **uploads/**: 视频文件存储 + +### 代码规范 + +- 所有代码注释和文档使用英文 +- 遵循 PEP 8 Python 代码规范 +- API 设计遵循 RESTful 原则 + +## 部署 + +### 使用 Gunicorn(生产环境) + +```bash +pip install gunicorn +gunicorn -w 4 -b 0.0.0.0:8080 app:app +``` + +### Docker 部署 + +```dockerfile +FROM python:3.9-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . + +CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8080", "app:app"] +``` + +## 注意事项 + +1. **API Key 安全**: 不要将包含 API Key 的 `config.yaml` 提交到代码仓库。建议: + - 在本地 `config.yaml` 中设置 API Key + - 在 `config.yaml` 中设置 `dashscope.api_key` + - 将 `config.yaml` 添加到 `.gitignore`(如果包含敏感信息) +2. **文件存储**: 视频文件存储在本地 `uploads/` 目录,生产环境建议使用对象存储 +3. **性能优化**: 视频分析可能需要较长时间,建议实现异步任务队列 +4. **存储空间**: 定期清理旧视频文件,避免磁盘空间不足 + +## 许可证 + +MIT License + +## 贡献 + +欢迎提交 Issue 和 Pull Request! + +## 更新日志 + +### v1.0.0 +- 初始版本发布 +- 实现视频上传、分析、总结和对比功能 +- 提供完整的 RESTful API +- 实现现代化前端界面 + diff --git a/app.py b/app.py new file mode 100644 index 0000000..aef766a --- /dev/null +++ b/app.py @@ -0,0 +1,16 @@ +""" +Main application entry point +""" +from app import create_app +from app.config import Config + +app = create_app(Config()) + +if __name__ == '__main__': + config = Config() + app.run( + host=config.server_host, + port=config.server_port, + debug=(config.server_mode == 'debug') + ) + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e112d4b --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,44 @@ +""" +Flask application initialization +""" +from flask import Flask, send_from_directory +from flask_cors import CORS +from pathlib import Path +from app.config import Config +from app.routes.video_routes import video_bp +from app.utils.logger import setup_logger + +def create_app(config_class=Config): + """ + Create and configure Flask application + + Args: + config_class: Configuration class + + Returns: + Flask application instance + """ + # Get project root directory + project_root = Path(__file__).parent.parent + static_folder = project_root / 'static' + + app = Flask(__name__, static_folder=str(static_folder), static_url_path='/static') + app.config.from_object(config_class) + + # Enable CORS for frontend access + CORS(app) + + # Setup logger + setup_logger(app) + + # Register blueprints + app.register_blueprint(video_bp, url_prefix='/api/videos') + + # Serve index.html at root + @app.route('/') + def index(): + """Serve index.html at root""" + return send_from_directory(static_folder, 'index.html') + + return app + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..8f45a7a --- /dev/null +++ b/app/config.py @@ -0,0 +1,105 @@ +""" +Configuration management +Primary configuration source: config.yaml +""" +import yaml +from pathlib import Path + +class Config: + """Application configuration + + All configuration is read from config.yaml file. + """ + + def __init__(self): + """Load configuration from config.yaml""" + config_path = Path(__file__).parent.parent / 'config.yaml' + + # Load config.yaml + if not config_path.exists(): + raise FileNotFoundError(f"Configuration file not found: {config_path}") + + with open(config_path, 'r', encoding='utf-8') as f: + self._config = yaml.safe_load(f) + + @property + def server_host(self): + """Get server host""" + return self._config['server']['host'] + + @property + def server_port(self): + """Get server port""" + return self._config['server']['port'] + + @property + def server_mode(self): + """Get server mode""" + return self._config['server']['mode'] + + @property + def mongodb_uri(self): + """Get MongoDB URI""" + return self._config['mongodb']['uri'] + + @property + def mongodb_database(self): + """Get MongoDB database name""" + return self._config['mongodb']['database'] + + @property + def mongodb_max_pool_size(self): + """Get MongoDB max pool size""" + return self._config['mongodb'].get('max_pool_size', 100) + + @property + def mongodb_min_pool_size(self): + """Get MongoDB min pool size""" + return self._config['mongodb'].get('min_pool_size', 10) + + @property + def dashscope_api_key(self): + """Get DashScope API key""" + return self._config['dashscope']['api_key'] + + @property + def dashscope_model(self): + """Get DashScope model name""" + return self._config['dashscope']['model'] + + @property + def dashscope_fps(self): + """Get DashScope fps parameter""" + return self._config['dashscope'].get('fps', 2) + + @property + def log_level(self): + """Get log level""" + return self._config['log']['level'] + + @property + def log_format(self): + """Get log format""" + return self._config['log']['format'] + + @property + def log_output(self): + """Get log output""" + return self._config['log']['output'] + + @property + def upload_folder(self): + """Get upload folder path""" + return Path(__file__).parent.parent / 'uploads' + + @property + def max_upload_size(self): + """Get max upload file size in bytes (default: 500MB)""" + return self._config.get('upload', {}).get('max_size', 500 * 1024 * 1024) + + @property + def allowed_extensions(self): + """Get allowed file extensions""" + return self._config.get('upload', {}).get('allowed_extensions', + ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm']) + diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..c9d1202 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,9 @@ +""" +Database models +""" +from app.models.video import Video +from app.models.analysis import AnalysisResult, Summary +from app.models.comparison import Comparison + +__all__ = ['Video', 'AnalysisResult', 'Summary', 'Comparison'] + diff --git a/app/models/analysis.py b/app/models/analysis.py new file mode 100644 index 0000000..e288eea --- /dev/null +++ b/app/models/analysis.py @@ -0,0 +1,143 @@ +""" +Analysis result and summary models +""" +from datetime import datetime +from bson import ObjectId +from typing import Optional, Dict, Any +from app.models.database import get_database + +class AnalysisResult: + """Video analysis result model""" + + COLLECTION_NAME = 'analysis_results' + + def __init__(self, + video_id: ObjectId, + content: str, + fps: int, + analysis_type: str = 'content', + created_at: Optional[datetime] = None, + _id: Optional[ObjectId] = None): + """ + Initialize AnalysisResult model + + Args: + video_id: Video document ID + content: Analysis content + fps: Frames per second used + analysis_type: Type of analysis (content, summary) + created_at: Creation timestamp + _id: Document ID + """ + self._id = _id or ObjectId() + self.video_id = video_id + self.content = content + self.fps = fps + self.analysis_type = analysis_type + self.created_at = created_at or datetime.utcnow() + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + return { + '_id': self._id, + 'video_id': self.video_id, + 'content': self.content, + 'fps': self.fps, + 'analysis_type': self.analysis_type, + 'created_at': self.created_at + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'AnalysisResult': + """Create AnalysisResult from dictionary""" + return cls( + _id=data.get('_id'), + video_id=data['video_id'], + content=data['content'], + fps=data['fps'], + analysis_type=data.get('analysis_type', 'content'), + created_at=data.get('created_at') + ) + + def save(self) -> ObjectId: + """Save analysis result to database""" + db = get_database() + result = db[self.COLLECTION_NAME].insert_one(self.to_dict()) + self._id = result.inserted_id + return self._id + + @classmethod + def find_by_video_id(cls, video_id: str, analysis_type: Optional[str] = None) -> Optional['AnalysisResult']: + """Find analysis result by video ID""" + db = get_database() + query = {'video_id': ObjectId(video_id)} + if analysis_type: + query['analysis_type'] = analysis_type + doc = db[cls.COLLECTION_NAME].find_one(query, sort=[('created_at', -1)]) + if doc: + return cls.from_dict(doc) + return None + + +class Summary: + """Video summary model""" + + COLLECTION_NAME = 'summaries' + + def __init__(self, + video_id: ObjectId, + summary_text: str, + created_at: Optional[datetime] = None, + _id: Optional[ObjectId] = None): + """ + Initialize Summary model + + Args: + video_id: Video document ID + summary_text: Summary text + created_at: Creation timestamp + _id: Document ID + """ + self._id = _id or ObjectId() + self.video_id = video_id + self.summary_text = summary_text + self.created_at = created_at or datetime.utcnow() + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + return { + '_id': self._id, + 'video_id': self.video_id, + 'summary_text': self.summary_text, + 'created_at': self.created_at + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Summary': + """Create Summary from dictionary""" + return cls( + _id=data.get('_id'), + video_id=data['video_id'], + summary_text=data['summary_text'], + created_at=data.get('created_at') + ) + + def save(self) -> ObjectId: + """Save summary to database""" + db = get_database() + result = db[self.COLLECTION_NAME].insert_one(self.to_dict()) + self._id = result.inserted_id + return self._id + + @classmethod + def find_by_video_id(cls, video_id: str) -> Optional['Summary']: + """Find summary by video ID""" + db = get_database() + doc = db[cls.COLLECTION_NAME].find_one( + {'video_id': ObjectId(video_id)}, + sort=[('created_at', -1)] + ) + if doc: + return cls.from_dict(doc) + return None + diff --git a/app/models/comparison.py b/app/models/comparison.py new file mode 100644 index 0000000..0185956 --- /dev/null +++ b/app/models/comparison.py @@ -0,0 +1,67 @@ +""" +Video comparison model +""" +from datetime import datetime +from bson import ObjectId +from typing import List, Optional, Dict, Any +from app.models.database import get_database + +class Comparison: + """Video comparison model""" + + COLLECTION_NAME = 'comparisons' + + def __init__(self, + video_ids: List[ObjectId], + comparison_result: str, + created_at: Optional[datetime] = None, + _id: Optional[ObjectId] = None): + """ + Initialize Comparison model + + Args: + video_ids: List of video document IDs + comparison_result: Comparison result text + created_at: Creation timestamp + _id: Document ID + """ + self._id = _id or ObjectId() + self.video_ids = video_ids + self.comparison_result = comparison_result + self.created_at = created_at or datetime.utcnow() + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + return { + '_id': self._id, + 'video_ids': self.video_ids, + 'comparison_result': self.comparison_result, + 'created_at': self.created_at + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Comparison': + """Create Comparison from dictionary""" + return cls( + _id=data.get('_id'), + video_ids=data['video_ids'], + comparison_result=data['comparison_result'], + created_at=data.get('created_at') + ) + + def save(self) -> ObjectId: + """Save comparison to database""" + db = get_database() + result = db[self.COLLECTION_NAME].insert_one(self.to_dict()) + self._id = result.inserted_id + return self._id + + @classmethod + def find_by_id(cls, comparison_id: str) -> Optional['Comparison']: + """Find comparison by ID""" + db = get_database() + doc = db[cls.COLLECTION_NAME].find_one({'_id': ObjectId(comparison_id)}) + if doc: + return cls.from_dict(doc) + return None + diff --git a/app/models/database.py b/app/models/database.py new file mode 100644 index 0000000..04cc478 --- /dev/null +++ b/app/models/database.py @@ -0,0 +1,38 @@ +""" +Database connection management +""" +from pymongo import MongoClient +from pymongo.database import Database +from app.config import Config + +_config = Config() +_client: MongoClient = None +_db: Database = None + +def get_database() -> Database: + """ + Get MongoDB database instance + + Returns: + MongoDB database instance + """ + global _db, _client + + if _db is None: + _client = MongoClient( + _config.mongodb_uri, + maxPoolSize=_config.mongodb_max_pool_size, + minPoolSize=_config.mongodb_min_pool_size + ) + _db = _client[_config.mongodb_database] + + return _db + +def close_database(): + """Close database connection""" + global _client, _db + if _client: + _client.close() + _client = None + _db = None + diff --git a/app/models/video.py b/app/models/video.py new file mode 100644 index 0000000..c005d2f --- /dev/null +++ b/app/models/video.py @@ -0,0 +1,103 @@ +""" +Video model +""" +from datetime import datetime +from bson import ObjectId +from typing import Optional, Dict, Any, List +from app.models.database import get_database + +class Video: + """Video document model""" + + COLLECTION_NAME = 'videos' + + def __init__(self, + filename: str, + file_path: str, + file_size: int, + mime_type: str, + upload_time: Optional[datetime] = None, + status: str = 'uploaded', + _id: Optional[ObjectId] = None): + """ + Initialize Video model + + Args: + filename: Video filename + file_path: Video file path + file_size: File size in bytes + mime_type: MIME type + upload_time: Upload timestamp + status: Video status (uploaded, analyzing, analyzed, failed) + _id: Document ID + """ + self._id = _id or ObjectId() + self.filename = filename + self.file_path = file_path + self.file_size = file_size + self.mime_type = mime_type + self.upload_time = upload_time or datetime.utcnow() + self.status = status + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary""" + return { + '_id': self._id, + 'filename': self.filename, + 'file_path': self.file_path, + 'file_size': self.file_size, + 'mime_type': self.mime_type, + 'upload_time': self.upload_time, + 'status': self.status + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Video': + """Create Video from dictionary""" + return cls( + _id=data.get('_id'), + filename=data['filename'], + file_path=data['file_path'], + file_size=data['file_size'], + mime_type=data['mime_type'], + upload_time=data.get('upload_time'), + status=data.get('status', 'uploaded') + ) + + def save(self) -> ObjectId: + """Save video to database""" + db = get_database() + result = db[self.COLLECTION_NAME].insert_one(self.to_dict()) + self._id = result.inserted_id + return self._id + + @classmethod + def find_by_id(cls, video_id: str) -> Optional['Video']: + """Find video by ID""" + db = get_database() + doc = db[cls.COLLECTION_NAME].find_one({'_id': ObjectId(video_id)}) + if doc: + return cls.from_dict(doc) + return None + + @classmethod + def find_all(cls, limit: int = 100, skip: int = 0) -> List['Video']: + """Find all videos""" + db = get_database() + cursor = db[cls.COLLECTION_NAME].find().sort('upload_time', -1).skip(skip).limit(limit) + return [cls.from_dict(doc) for doc in cursor] + + def update_status(self, status: str): + """Update video status""" + db = get_database() + db[self.COLLECTION_NAME].update_one( + {'_id': self._id}, + {'$set': {'status': status}} + ) + self.status = status + + def delete(self): + """Delete video from database""" + db = get_database() + db[self.COLLECTION_NAME].delete_one({'_id': self._id}) + diff --git a/app/routes/video_routes.py b/app/routes/video_routes.py new file mode 100644 index 0000000..bd53360 --- /dev/null +++ b/app/routes/video_routes.py @@ -0,0 +1,205 @@ +""" +Video API routes +""" +from flask import Blueprint, request, jsonify, current_app +from bson import ObjectId +from app.services.video_service import VideoService +from app.services.analysis_service import AnalysisService +from app.config import Config + +video_bp = Blueprint('videos', __name__) +config = Config() +video_service = VideoService(config) +analysis_service = AnalysisService(config) + +def json_response(data=None, message=None, status_code=200): + """Create JSON response""" + response = {'success': status_code < 400} + if data is not None: + response['data'] = data + if message: + response['message'] = message + return jsonify(response), status_code + +def error_response(message, status_code=400): + """Create error response""" + return json_response(message=message, status_code=status_code) + +@video_bp.route('/upload', methods=['POST']) +def upload_video(): + """Upload video file""" + if 'file' not in request.files: + return error_response('No file provided', 400) + + file = request.files['file'] + if file.filename == '': + return error_response('No file selected', 400) + + video, error = video_service.upload_video(file) + if error: + return error_response(error, 400) + + return json_response({ + 'video_id': str(video._id), + 'filename': video.filename, + 'file_size': video.file_size, + 'status': video.status, + 'upload_time': video.upload_time.isoformat() + }, message='Video uploaded successfully', status_code=201) + +@video_bp.route('', methods=['GET']) +def list_videos(): + """Get list of videos""" + limit = request.args.get('limit', 100, type=int) + skip = request.args.get('skip', 0, type=int) + + videos = video_service.list_videos(limit=limit, skip=skip) + + video_list = [] + for video in videos: + video_list.append({ + 'id': str(video._id), + 'filename': video.filename, + 'file_size': video.file_size, + 'status': video.status, + 'upload_time': video.upload_time.isoformat() + }) + + return json_response(video_list) + +@video_bp.route('/', methods=['GET']) +def get_video(video_id): + """Get video details""" + video = video_service.get_video(video_id) + if not video: + return error_response('Video not found', 404) + + # Get analysis and summary if available + analysis = analysis_service.get_analysis(video_id) + summary = analysis_service.get_summary(video_id) + + data = { + 'id': str(video._id), + 'filename': video.filename, + 'file_path': video.file_path, + 'file_size': video.file_size, + 'mime_type': video.mime_type, + 'status': video.status, + 'upload_time': video.upload_time.isoformat() + } + + if analysis: + data['analysis'] = { + 'id': str(analysis._id), + 'content': analysis.content, + 'fps': analysis.fps, + 'created_at': analysis.created_at.isoformat() + } + + if summary: + data['summary'] = { + 'id': str(summary._id), + 'summary_text': summary.summary_text, + 'created_at': summary.created_at.isoformat() + } + + return json_response(data) + +@video_bp.route('//analyze', methods=['POST']) +def analyze_video(video_id): + """Analyze video content""" + prompt = None + if request.is_json and request.get_json(): + prompt = request.get_json().get('prompt') + + analysis, error = analysis_service.analyze_video(video_id, prompt) + if error: + current_app.logger.error(f"Video analysis failed for {video_id}: {error}") + return error_response(error, 400) + + return json_response({ + 'id': str(analysis._id), + 'video_id': video_id, + 'content': analysis.content, + 'fps': analysis.fps, + 'created_at': analysis.created_at.isoformat() + }, message='Video analyzed successfully', status_code=201) + +@video_bp.route('//summarize', methods=['POST']) +def summarize_video(video_id): + """Generate video summary""" + summary, error = analysis_service.summarize_video(video_id) + if error: + current_app.logger.error(f"Video summarization failed for {video_id}: {error}") + return error_response(error, 400) + + return json_response({ + 'id': str(summary._id), + 'video_id': video_id, + 'summary_text': summary.summary_text, + 'created_at': summary.created_at.isoformat() + }, message='Summary generated successfully', status_code=201) + +@video_bp.route('//summary', methods=['GET']) +def get_summary(video_id): + """Get video summary""" + summary = analysis_service.get_summary(video_id) + if not summary: + return error_response('Summary not found', 404) + + return json_response({ + 'id': str(summary._id), + 'video_id': video_id, + 'summary_text': summary.summary_text, + 'created_at': summary.created_at.isoformat() + }) + +@video_bp.route('/compare', methods=['POST']) +def compare_videos(): + """Compare multiple videos""" + if not request.is_json: + return error_response('JSON body required', 400) + + data = request.json + video_ids = data.get('video_ids', []) + + if not isinstance(video_ids, list) or len(video_ids) < 2: + return error_response('At least two video IDs are required', 400) + + comparison, error = analysis_service.compare_videos(video_ids) + if error: + current_app.logger.error(f"Video comparison failed: {error}") + return error_response(error, 400) + + return json_response({ + 'id': str(comparison._id), + 'video_ids': [str(vid) for vid in comparison.video_ids], + 'comparison_result': comparison.comparison_result, + 'created_at': comparison.created_at.isoformat() + }, message='Comparison completed successfully', status_code=201) + +@video_bp.route('/compare/', methods=['GET']) +def get_comparison(comparison_id): + """Get comparison result""" + from app.models.comparison import Comparison + comparison = Comparison.find_by_id(comparison_id) + if not comparison: + return error_response('Comparison not found', 404) + + return json_response({ + 'id': str(comparison._id), + 'video_ids': [str(vid) for vid in comparison.video_ids], + 'comparison_result': comparison.comparison_result, + 'created_at': comparison.created_at.isoformat() + }) + +@video_bp.errorhandler(404) +def not_found(error): + """Handle 404 errors""" + return error_response('Endpoint not found', 404) + +@video_bp.errorhandler(500) +def internal_error(error): + """Handle 500 errors""" + return error_response('Internal server error', 500) + diff --git a/app/services/analysis_service.py b/app/services/analysis_service.py new file mode 100644 index 0000000..074b01a --- /dev/null +++ b/app/services/analysis_service.py @@ -0,0 +1,163 @@ +""" +Video analysis service +""" +from pathlib import Path +from typing import Optional, Tuple, List +from app.models.video import Video +from app.models.analysis import AnalysisResult, Summary +from app.models.comparison import Comparison +from app.services.dashscope_service import DashScopeService +from app.config import Config + +class AnalysisService: + """Video analysis service""" + + def __init__(self, config: Config): + """ + Initialize analysis service + + Args: + config: Application configuration + """ + self.config = config + self.dashscope_service = DashScopeService(config) + + def analyze_video(self, video_id: str, prompt: Optional[str] = None) -> Tuple[Optional[AnalysisResult], Optional[str]]: + """ + Analyze video content + + Args: + video_id: Video ID + prompt: Optional custom prompt + + Returns: + Tuple of (AnalysisResult, error_message) + """ + video = Video.find_by_id(video_id) + if not video: + return None, "Video not found" + + video_path = Path(video.file_path) + if not video_path.exists(): + return None, "Video file not found" + + # Update status + video.update_status('analyzing') + + # Default prompt + if not prompt: + prompt = "这段视频描绘的是什么景象? 请详细描述视频中的内容。" + + # Call DashScope API + try: + result = self.dashscope_service.analyze_video(video_path, prompt) + except ValueError as e: + video.update_status('failed') + return None, str(e) + + if not result.get('success'): + video.update_status('failed') + return None, result.get('error', 'Analysis failed') + + # Save analysis result + analysis = AnalysisResult( + video_id=video._id, + content=result['content'], + fps=result['fps'], + analysis_type='content' + ) + analysis.save() + + # Update video status + video.update_status('analyzed') + + return analysis, None + + def summarize_video(self, video_id: str) -> Tuple[Optional[Summary], Optional[str]]: + """ + Generate video summary + + Args: + video_id: Video ID + + Returns: + Tuple of (Summary, error_message) + """ + video = Video.find_by_id(video_id) + if not video: + return None, "Video not found" + + video_path = Path(video.file_path) + if not video_path.exists(): + return None, "Video file not found" + + # Call DashScope API for summary + try: + result = self.dashscope_service.summarize_video(video_path) + except ValueError as e: + return None, str(e) + + if not result.get('success'): + return None, result.get('error', 'Summary generation failed') + + # Save summary + summary = Summary( + video_id=video._id, + summary_text=result['content'] + ) + summary.save() + + return summary, None + + def compare_videos(self, video_ids: List[str]) -> Tuple[Optional[Comparison], Optional[str]]: + """ + Compare multiple videos + + Args: + video_ids: List of video IDs + + Returns: + Tuple of (Comparison, error_message) + """ + if len(video_ids) < 2: + return None, "At least two videos are required for comparison" + + # Get all videos + videos = [] + video_paths = [] + for video_id in video_ids: + video = Video.find_by_id(video_id) + if not video: + return None, f"Video not found: {video_id}" + video_path = Path(video.file_path) + if not video_path.exists(): + return None, f"Video file not found: {video_id}" + videos.append(video) + video_paths.append(video_path) + + # Call DashScope API for comparison + try: + result = self.dashscope_service.compare_videos(video_paths) + except ValueError as e: + return None, str(e) + + if not result.get('success'): + return None, result.get('error', 'Comparison failed') + + # Save comparison + comparison = Comparison( + video_ids=[video._id for video in videos], + comparison_result=result['content'] + ) + comparison.save() + + return comparison, None + + def get_analysis(self, video_id: str) -> Optional[AnalysisResult]: + """Get analysis result for video""" + return AnalysisResult.find_by_video_id(video_id, 'content') + + def get_summary(self, video_id: str) -> Optional[Summary]: + """Get summary for video""" + return Summary.find_by_video_id(video_id) + diff --git a/app/services/dashscope_service.py b/app/services/dashscope_service.py new file mode 100644 index 0000000..5c3d8a0 --- /dev/null +++ b/app/services/dashscope_service.py @@ -0,0 +1,499 @@ +""" +DashScope API service +""" +import base64 +import json +import logging +from pathlib import Path +from typing import Optional, Dict, Any +from dashscope import MultiModalConversation +import dashscope +from app.config import Config + +logger = logging.getLogger('videoSummary') + +class DashScopeService: + """DashScope API service wrapper""" + + def __init__(self, config: Config): + """ + Initialize DashScope service + + Args: + config: Application configuration + """ + self.config = config + self.api_key = config.dashscope_api_key + self.model = config.dashscope_model + self.fps = config.dashscope_fps + self._api_key_set = False + + def _ensure_api_key(self): + """ + Ensure API key is set before making API calls + + Raises: + ValueError: If API key is not configured + """ + if not self.api_key: + raise ValueError( + "DashScope API key is required for video analysis. " + "Please set 'dashscope.api_key' in config.yaml file. " + "You can obtain your API key from: https://dashscope.console.aliyun.com/" + ) + + if not self._api_key_set: + dashscope.api_key = self.api_key + self._api_key_set = True + + def _log_request(self, method_name: str, request_data: Dict[str, Any]): + """ + Log complete request data for third-party API calls + + Args: + method_name: Name of the calling method + request_data: Request data dictionary containing model, messages, api_key, etc. + """ + try: + # Create a copy for logging to avoid modifying original + log_data = request_data.copy() + + # Handle Base64 encoded video data - only log summary for large data + if 'messages' in log_data: + messages_copy = [] + for msg in log_data['messages']: + msg_copy = msg.copy() if isinstance(msg, dict) else msg + if isinstance(msg_copy, dict) and 'content' in msg_copy: + content_copy = [] + for content_item in msg_copy['content']: + if isinstance(content_item, dict) and 'video' in content_item: + video_value = content_item['video'] + # If it's a Base64 data URI, only log the prefix and size + if isinstance(video_value, str) and video_value.startswith('data:video'): + # Extract size info if available + size_info = '' + if 'encoded_size' in log_data: + size_info = f" (encoded size: {log_data['encoded_size']} bytes)" + content_copy.append({ + 'video': f"data:video/mp4;base64,[BASE64_DATA{size_info}]", + 'fps': content_item.get('fps', 'N/A') + }) + else: + content_copy.append(content_item) + else: + content_copy.append(content_item) + msg_copy['content'] = content_copy + messages_copy.append(msg_copy) + log_data['messages'] = messages_copy + + # Log complete request at DEBUG level + logger.debug(f"DashScope API Request [{method_name}] - Complete Request Data:") + logger.debug(json.dumps(log_data, indent=2, ensure_ascii=False)) + + # Log summary at INFO level + video_count = log_data.get('video_count', 'N/A') + if video_count == 'N/A' and 'video_paths' in log_data: + video_paths = log_data.get('video_paths', []) + video_count = len(video_paths) if isinstance(video_paths, list) else 'N/A' + + summary = { + 'method': method_name, + 'model': log_data.get('model', 'N/A'), + 'api_key': log_data.get('api_key', 'N/A'), + 'messages_count': len(log_data.get('messages', [])), + 'fps': log_data.get('fps', 'N/A'), + 'video_count': video_count + } + logger.info(f"DashScope API Request [{method_name}] - Summary: {json.dumps(summary, ensure_ascii=False)}") + except Exception as e: + logger.warning(f"Failed to log request data: {str(e)}") + + def _log_response(self, method_name: str, response: Any): + """ + Log complete response data from third-party API calls + + Args: + method_name: Name of the calling method + response: Response object from DashScope API + """ + try: + # Log complete response at DEBUG level + logger.debug(f"DashScope API Response [{method_name}] - Complete Response Data:") + + response_data = { + 'status_code': getattr(response, 'status_code', 'N/A'), + 'message': getattr(response, 'message', 'N/A'), + 'request_id': getattr(response, 'request_id', 'N/A'), + } + + # Try to get response output if available + if hasattr(response, 'output'): + try: + output_dict = { + 'choices': [] + } + if hasattr(response.output, 'choices') and response.output.choices: + for choice in response.output.choices: + choice_dict = {} + if hasattr(choice, 'message'): + if hasattr(choice.message, 'content'): + content_list = [] + for content_item in choice.message.content: + if isinstance(content_item, dict): + # For text content, include full text + if 'text' in content_item: + content_list.append({'text': content_item['text']}) + else: + # For other content types, include type only + content_list.append({k: v for k, v in content_item.items() if k != 'video' or not isinstance(v, str) or len(v) < 100}) + else: + content_list.append(str(content_item)) + choice_dict['message'] = {'content': content_list} + output_dict['choices'].append(choice_dict) + response_data['output'] = output_dict + except Exception as e: + response_data['output'] = f"Error extracting output: {str(e)}" + + logger.debug(json.dumps(response_data, indent=2, ensure_ascii=False)) + + # Log summary at INFO level + summary = { + 'method': method_name, + 'status_code': response_data['status_code'], + 'message': response_data['message'], + 'request_id': response_data['request_id'] + } + logger.info(f"DashScope API Response [{method_name}] - Summary: {json.dumps(summary, ensure_ascii=False)}") + + # Log error details at ERROR level if failed + if response_data['status_code'] != 200: + logger.error(f"DashScope API Error [{method_name}] - Status: {response_data['status_code']}, Message: {response_data['message']}") + except Exception as e: + logger.warning(f"Failed to log response data: {str(e)}") + + def analyze_video(self, video_path: Path, prompt: str, fps: Optional[int] = None) -> Dict[str, Any]: + """ + Analyze video content using DashScope API + + Args: + video_path: Path to video file + prompt: Analysis prompt + fps: Frames per second (overrides config if provided) + + Returns: + API response dictionary + + Raises: + Exception: If API call fails + """ + # Ensure API key is set + self._ensure_api_key() + + if not video_path.exists(): + raise FileNotFoundError(f"Video file not found: {video_path}") + + # According to DashScope documentation for Linux/macOS Python SDK: + # local_path = "xxx/test.mp4" # absolute path string + # video_path = f"file://{local_path}" + # Example from official docs: + # local_path = "xxx/test.mp4" + # video_path = f"file://{local_path}" + absolute_path = video_path.absolute() + local_path = str(absolute_path) + video_path_for_api = f"file://{local_path}" + + # Log for debugging + logger.info(f"Using video path (file:// format as per official docs): {video_path_for_api}") + + # Use provided fps or default from config + fps_value = fps if fps is not None else self.fps + + # Prepare messages according to DashScope official documentation format: + # {'role':'user', 'content': [{'video': video_path, "fps":2}, {'text': '...'}]} + + messages = [ + { + 'role': 'user', + 'content': [ + {'video': video_path_for_api, 'fps': fps_value}, + {'text': prompt} + ] + } + ] + + # Call API + try: + # Prepare request data for logging + request_data = { + 'model': self.model, + 'api_key': self.api_key, + 'messages': messages, + 'fps': fps_value, + 'video_path': video_path_for_api + } + + # Log complete request + self._log_request('analyze_video', request_data) + + response = MultiModalConversation.call( + model=self.model, + messages=messages + ) + + # Log complete response + self._log_response('analyze_video', response) + + if response.status_code == 200: + # Extract text content from response + content = response.output.choices[0].message.content[0]["text"] + return { + 'success': True, + 'content': content, + 'fps': fps_value + } + else: + # If file:// format fails, try Base64 encoding as fallback + # Note: Base64 encoding increases file size by ~33%, and API limit is 10MB for encoded video + logger.warning(f"File:// format failed, trying Base64 encoding. Error: {response.message}") + + try: + file_size = video_path.stat().st_size + # Check if file is too large for Base64 encoding (10MB limit for encoded video) + if file_size > 7 * 1024 * 1024: # ~7MB raw = ~10MB encoded + logger.error(f"Video file too large for Base64 encoding: {file_size} bytes (limit: ~7MB raw)") + return { + 'success': False, + 'error': f"Video file too large for Base64 encoding. File size: {file_size / 1024 / 1024:.2f}MB, limit: ~7MB raw (~10MB encoded). Please use a smaller video file.", + 'status_code': response.status_code + } + + # Read file and encode to Base64 + with open(video_path, 'rb') as f: + video_data = f.read() + video_base64 = base64.b64encode(video_data).decode('utf-8') + + logger.info(f"Using Base64 encoding (file size: {file_size} bytes, encoded size: {len(video_base64)} bytes)") + + # According to DashScope docs, Base64 format should be: base64 encoded string + # Try data URI format first + messages_base64 = [ + { + 'role': 'user', + 'content': [ + {'video': f"data:video/mp4;base64,{video_base64}", 'fps': fps_value}, + {'text': prompt} + ] + } + ] + + # Prepare request data for logging (Base64 encoding) + request_data_base64 = { + 'model': self.model, + 'api_key': self.api_key, + 'messages': messages_base64, + 'fps': fps_value, + 'encoding': 'base64', + 'file_size': file_size, + 'encoded_size': len(video_base64) + } + + # Log complete request (Base64 encoding) + self._log_request('analyze_video_base64', request_data_base64) + + response = MultiModalConversation.call( + model=self.model, + messages=messages_base64 + ) + + # Log complete response (Base64 encoding) + self._log_response('analyze_video_base64', response) + + if response.status_code == 200: + content = response.output.choices[0].message.content[0]["text"] + return { + 'success': True, + 'content': content, + 'fps': fps_value + } + else: + logger.error(f"DashScope API error (all formats including Base64 failed) - Status: {response.status_code}, Message: {response.message}") + return { + 'success': False, + 'error': f"API error: {response.message}", + 'status_code': response.status_code + } + except Exception as e: + logger.error(f"Base64 encoding failed: {str(e)}") + return { + 'success': False, + 'error': f"All upload methods failed. Last error: {response.message}. Base64 encoding error: {str(e)}", + 'status_code': response.status_code + } + except Exception as e: + logger.error(f"DashScope API exception: {str(e)}, Path used: {video_path_for_api}") + return { + 'success': False, + 'error': str(e) + } + + def summarize_video(self, video_path: Path, fps: Optional[int] = None) -> Dict[str, Any]: + """ + Generate video summary + + Args: + video_path: Path to video file + fps: Frames per second (overrides config if provided) + + Returns: + Summary result dictionary + """ + prompt = "请对这段视频进行总结,包括主要内容、关键场景和重要信息。" + return self.analyze_video(video_path, prompt, fps) + + def compare_videos(self, video_paths: list[Path], fps: Optional[int] = None) -> Dict[str, Any]: + """ + Compare multiple videos + + Args: + video_paths: List of video file paths + fps: Frames per second (overrides config if provided) + + Returns: + Comparison result dictionary + """ + # Ensure API key is set + self._ensure_api_key() + + if len(video_paths) < 2: + return { + 'success': False, + 'error': 'At least two videos are required for comparison' + } + + # Validate all video files exist + for path in video_paths: + if not path.exists(): + return { + 'success': False, + 'error': f"Video file not found: {path}" + } + + # Convert paths - try direct absolute path strings first + video_paths_for_api = [] + for path in video_paths: + absolute_path = path.absolute() + local_path = str(absolute_path) + video_paths_for_api.append(local_path) + + fps_value = fps if fps is not None else self.fps + + # Prepare messages with multiple videos using file:// URI format + content = [] + for video_uri in video_paths_for_api: + content.append({'video': video_uri, 'fps': fps_value}) + + prompt = "请对比这些视频的内容,找出它们的相似之处和不同之处,并详细说明。" + content.append({'text': prompt}) + + messages = [ + { + 'role': 'user', + 'content': content + } + ] + + # Call API + try: + # Prepare request data for logging + request_data = { + 'model': self.model, + 'api_key': self.api_key, + 'messages': messages, + 'fps': fps_value, + 'video_paths': video_paths_for_api, + 'video_count': len(video_paths_for_api) + } + + # Log complete request + self._log_request('compare_videos', request_data) + + response = MultiModalConversation.call( + model=self.model, + messages=messages + ) + + # Log complete response + self._log_response('compare_videos', response) + + if response.status_code == 200: + content_text = response.output.choices[0].message.content[0]["text"] + return { + 'success': True, + 'content': content_text, + 'fps': fps_value + } + else: + # If direct paths fail, try file:// format + logger.warning(f"Direct paths failed, trying file:// format. Error: {response.message}") + + video_urls = [] + for path in video_paths: + absolute_path = path.absolute() + local_path = str(absolute_path) + video_urls.append(f"file://{local_path}") + + content_retry = [] + for video_url in video_urls: + content_retry.append({'video': video_url, 'fps': fps_value}) + content_retry.append({'text': prompt}) + + messages_retry = [ + { + 'role': 'user', + 'content': content_retry + } + ] + + # Prepare request data for logging (retry with file:// format) + request_data_retry = { + 'model': self.model, + 'api_key': self.api_key, + 'messages': messages_retry, + 'fps': fps_value, + 'video_paths': video_urls, + 'video_count': len(video_urls), + 'retry': True + } + + # Log complete request (retry) + self._log_request('compare_videos_retry', request_data_retry) + + response = MultiModalConversation.call( + model=self.model, + messages=messages_retry + ) + + # Log complete response (retry) + self._log_response('compare_videos_retry', response) + + if response.status_code == 200: + content_text = response.output.choices[0].message.content[0]["text"] + return { + 'success': True, + 'content': content_text, + 'fps': fps_value + } + else: + logger.error(f"DashScope API error (both attempts failed) - Status: {response.status_code}, Message: {response.message}") + return { + 'success': False, + 'error': f"API error: {response.message}", + 'status_code': response.status_code + } + except Exception as e: + logger.error(f"DashScope API exception (compare): {str(e)}") + return { + 'success': False, + 'error': str(e) + } + diff --git a/app/services/video_service.py b/app/services/video_service.py new file mode 100644 index 0000000..b5482c1 --- /dev/null +++ b/app/services/video_service.py @@ -0,0 +1,144 @@ +""" +Video service +""" +from pathlib import Path +from typing import Optional, Tuple, List +from werkzeug.datastructures import FileStorage +from app.models.video import Video +from app.models.database import get_database +from app.config import Config +from app.utils.validators import is_allowed_file, validate_file_size, secure_file_path +from app.utils.file_utils import ensure_directory_exists, get_file_size + +class VideoService: + """Video management service""" + + def __init__(self, config: Config): + """ + Initialize video service + + Args: + config: Application configuration + """ + self.config = config + self.upload_folder = config.upload_folder + ensure_directory_exists(self.upload_folder) + + def upload_video(self, file: FileStorage) -> Tuple[Optional[Video], Optional[str]]: + """ + Upload video file + + Args: + file: Uploaded file + + Returns: + Tuple of (Video object, error_message) + """ + # Validate filename + if not file.filename: + return None, "No filename provided" + + # Validate file extension + if not is_allowed_file(file.filename, self.config.allowed_extensions): + allowed = ', '.join(self.config.allowed_extensions) + return None, f"File format not supported. Allowed formats: {allowed}" + + # Save file temporarily to get size + temp_path = self.upload_folder / f"temp_{file.filename}" + try: + file.save(str(temp_path)) + file_size = get_file_size(temp_path) + + # Validate file size + is_valid, error_msg = validate_file_size(file_size, self.config.max_upload_size) + if not is_valid: + temp_path.unlink() + return None, error_msg + + # Generate secure file path + secure_path = secure_file_path(file.filename, self.upload_folder) + + # Handle filename conflicts + counter = 1 + original_path = secure_path + while secure_path.exists(): + stem = original_path.stem + suffix = original_path.suffix + secure_path = self.upload_folder / f"{stem}_{counter}{suffix}" + counter += 1 + + # Move file to final location + temp_path.rename(secure_path) + + # Create video document + video = Video( + filename=file.filename, + file_path=str(secure_path), + file_size=file_size, + mime_type=file.content_type or 'video/mp4', + status='uploaded' + ) + video.save() + + return video, None + + except Exception as e: + if temp_path.exists(): + temp_path.unlink() + return None, f"Upload failed: {str(e)}" + + def get_video(self, video_id: str) -> Optional[Video]: + """ + Get video by ID + + Args: + video_id: Video ID + + Returns: + Video object or None + """ + try: + return Video.find_by_id(video_id) + except Exception: + return None + + def list_videos(self, limit: int = 100, skip: int = 0) -> List[Video]: + """ + List all videos + + Args: + limit: Maximum number of videos to return + skip: Number of videos to skip + + Returns: + List of Video objects + """ + return Video.find_all(limit=limit, skip=skip) + + def delete_video(self, video_id: str) -> Tuple[bool, Optional[str]]: + """ + Delete video + + Args: + video_id: Video ID + + Returns: + Tuple of (success, error_message) + """ + video = self.get_video(video_id) + if not video: + return False, "Video not found" + + try: + # Delete file + file_path = Path(video.file_path) + if file_path.exists(): + file_path.unlink() + + # Delete from database + video.delete() + + return True, None + except Exception as e: + return False, f"Delete failed: {str(e)}" + diff --git a/app/utils/file_utils.py b/app/utils/file_utils.py new file mode 100644 index 0000000..7b1458e --- /dev/null +++ b/app/utils/file_utils.py @@ -0,0 +1,39 @@ +""" +File utility functions +""" +from pathlib import Path +from typing import Optional + +def ensure_directory_exists(directory: Path): + """ + Ensure directory exists, create if not + + Args: + directory: Directory path + """ + directory.mkdir(parents=True, exist_ok=True) + +def get_file_size(file_path: Path) -> int: + """ + Get file size in bytes + + Args: + file_path: File path + + Returns: + File size in bytes + """ + return file_path.stat().st_size + +def file_exists(file_path: Path) -> bool: + """ + Check if file exists + + Args: + file_path: File path + + Returns: + True if file exists, False otherwise + """ + return file_path.exists() and file_path.is_file() + diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..682da4b --- /dev/null +++ b/app/utils/logger.py @@ -0,0 +1,44 @@ +""" +Logger setup utility +""" +import logging +import sys +from flask import Flask + +def setup_logger(app: Flask): + """ + Setup application logger + + Args: + app: Flask application instance + """ + config = app.config + + # Get log level + log_level = getattr(logging, config.get('log_level', 'INFO').upper(), logging.INFO) + + # Configure logger + logger = logging.getLogger('videoSummary') + logger.setLevel(log_level) + + # Create formatter + if config.get('log_format') == 'json': + formatter = logging.Formatter( + '{"time": "%(asctime)s", "level": "%(levelname)s", "message": "%(message)s", "module": "%(name)s"}' + ) + else: + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + # Create handler + if config.get('log_output') == 'stdout' or not config.get('log_output'): + handler = logging.StreamHandler(sys.stdout) + else: + handler = logging.FileHandler(config.get('log_output')) + + handler.setFormatter(formatter) + logger.addHandler(handler) + + app.logger = logger + diff --git a/app/utils/validators.py b/app/utils/validators.py new file mode 100644 index 0000000..d96e912 --- /dev/null +++ b/app/utils/validators.py @@ -0,0 +1,53 @@ +""" +File validation utilities +""" +from werkzeug.utils import secure_filename +from pathlib import Path +from typing import Optional, List, Tuple + +def is_allowed_file(filename: str, allowed_extensions: List[str]) -> bool: + """ + Check if file extension is allowed + + Args: + filename: File name + allowed_extensions: List of allowed extensions + + Returns: + True if extension is allowed, False otherwise + """ + if '.' not in filename: + return False + ext = filename.rsplit('.', 1)[1].lower() + return ext in allowed_extensions + +def validate_file_size(file_size: int, max_size: int) -> Tuple[bool, Optional[str]]: + """ + Validate file size + + Args: + file_size: File size in bytes + max_size: Maximum allowed size in bytes + + Returns: + Tuple of (is_valid, error_message) + """ + if file_size > max_size: + max_size_mb = max_size / (1024 * 1024) + return False, f"File size exceeds maximum allowed size of {max_size_mb:.1f}MB" + return True, None + +def secure_file_path(filename: str, upload_folder: Path) -> Path: + """ + Generate secure file path + + Args: + filename: Original filename + upload_folder: Upload folder path + + Returns: + Secure file path + """ + secure_name = secure_filename(filename) + return upload_folder / secure_name + diff --git a/config copy.yaml b/config copy.yaml new file mode 100644 index 0000000..33bafe5 --- /dev/null +++ b/config copy.yaml @@ -0,0 +1,31 @@ +# Video Summary Service Configuration + +# Server Configuration +server: + host: "0.0.0.0" + port: 8080 + mode: "debug" # debug, release, test + +# MongoDB Configuration +mongodb: + uri: "mongodb://localhost:27017" + database: "videoSummary" + max_pool_size: 100 + min_pool_size: 10 + +# DashScope API Configuration +dashscope: + api_key: "sk-test" # Set your DashScope API key here + model: "qwen3-vl-plus" # MultiModal model name + fps: 2 # Frames per second for video frame extraction + +# Upload Configuration +upload: + max_size: 524288000 # 500MB in bytes + allowed_extensions: ["mp4", "avi", "mov", "mkv", "wmv", "flv", "webm"] + +# Logging Configuration +log: + level: "info" # debug, info, warn, error + format: "json" # json, text + output: "stdout" # stdout, file path diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..33bafe5 --- /dev/null +++ b/config.yaml @@ -0,0 +1,31 @@ +# Video Summary Service Configuration + +# Server Configuration +server: + host: "0.0.0.0" + port: 8080 + mode: "debug" # debug, release, test + +# MongoDB Configuration +mongodb: + uri: "mongodb://localhost:27017" + database: "videoSummary" + max_pool_size: 100 + min_pool_size: 10 + +# DashScope API Configuration +dashscope: + api_key: "sk-test" # Set your DashScope API key here + model: "qwen3-vl-plus" # MultiModal model name + fps: 2 # Frames per second for video frame extraction + +# Upload Configuration +upload: + max_size: 524288000 # 500MB in bytes + allowed_extensions: ["mp4", "avi", "mov", "mkv", "wmv", "flv", "webm"] + +# Logging Configuration +log: + level: "info" # debug, info, warn, error + format: "json" # json, text + output: "stdout" # stdout, file path diff --git a/image.png b/image.png new file mode 100644 index 0000000..2b9774a Binary files /dev/null and b/image.png differ diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md new file mode 100644 index 0000000..c844936 --- /dev/null +++ b/openspec/AGENTS.md @@ -0,0 +1,456 @@ +# OpenSpec 说明 + +适用于使用 OpenSpec 进行规范驱动开发的 AI 编码助手的说明。 + +## 快速检查清单 + +- 搜索现有工作:`openspec spec list --long`、`openspec list`(仅在全文搜索时使用 `rg`) +- 确定范围:新功能 vs 修改现有功能 +- 选择唯一的 `change-id`:kebab-case,动词开头(`add-`、`update-`、`remove-`、`refactor-`) +- 搭建:`proposal.md`、`tasks.md`、`design.md`(仅在需要时),以及每个受影响功能的增量规范 +- 编写增量:使用 `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`;每个需求至少包含一个 `#### Scenario:` +- 验证:`openspec validate [change-id] --strict` 并修复问题 +- 请求批准:在提案获得批准之前不要开始实施 + +## 三阶段工作流 + +### 阶段 1:创建变更 +在以下情况下创建提案: +- 添加功能或功能特性 +- 进行破坏性更改(API、架构) +- 更改架构或模式 +- 优化性能(改变行为) +- 更新安全模式 + +触发词(示例): +- "帮我创建一个变更提案" +- "帮我规划一个变更" +- "我想创建一个提案" +- "我想创建一个规范提案" +- "我想创建一个规范" + +宽松匹配指导: +- 包含以下之一:`proposal`、`change`、`spec` +- 与以下之一组合:`create`、`plan`、`make`、`start`、`help` + +跳过提案的情况: +- Bug 修复(恢复预期行为) +- 拼写错误、格式、注释 +- 依赖更新(非破坏性) +- 配置更改 +- 现有行为的测试 + +**工作流** +1. 查看 `openspec/project.md`、`openspec list` 和 `openspec list --specs` 以了解当前上下文。 +2. 选择一个唯一的动词开头的 `change-id`,并在 `openspec/changes//` 下搭建 `proposal.md`、`tasks.md`、可选的 `design.md` 和规范增量。 +3. 使用 `## ADDED|MODIFIED|REMOVED Requirements` 起草规范增量,每个需求至少包含一个 `#### Scenario:`。 +4. 运行 `openspec validate --strict` 并在分享提案之前解决所有问题。 + +### 阶段 2:实施变更 +将这些步骤作为待办事项跟踪,逐一完成。 +1. **阅读 proposal.md** - 了解要构建的内容 +2. **阅读 design.md**(如果存在) - 查看技术决策 +3. **阅读 tasks.md** - 获取实施清单 +4. **按顺序实施任务** - 按顺序完成 +5. **确认完成** - 在更新状态之前,确保 `tasks.md` 中的每个项目都已完成 +6. **更新清单** - 所有工作完成后,将每个任务设置为 `- [x]`,使列表反映实际情况 +7. **批准门控** - 在提案经过审查和批准之前不要开始实施 + +### 阶段 3:归档变更 +部署后,创建单独的 PR: +- 将 `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` +- 如果功能已更改,更新 `specs/` +- 对于仅工具更改,使用 `openspec archive --skip-specs --yes`(始终显式传递变更 ID) +- 运行 `openspec validate --strict` 以确认归档的变更通过检查 + +## 任何任务之前 + +**上下文检查清单:** +- [ ] 在 `specs/[capability]/spec.md` 中阅读相关规范 +- [ ] 检查 `changes/` 中的待处理变更是否存在冲突 +- [ ] 阅读 `openspec/project.md` 了解约定 +- [ ] 运行 `openspec list` 查看活动变更 +- [ ] 运行 `openspec list --specs` 查看现有功能 + +**创建规范之前:** +- 始终检查功能是否已存在 +- 优先修改现有规范而不是创建重复项 +- 使用 `openspec show [spec]` 查看当前状态 +- 如果请求不明确,在搭建之前提出 1-2 个澄清问题 + +### 搜索指导 +- 枚举规范:`openspec spec list --long`(或脚本使用 `--json`) +- 枚举变更:`openspec list`(或 `openspec change list --json` - 已弃用但可用) +- 显示详细信息: + - 规范:`openspec show --type spec`(使用 `--json` 进行过滤) + - 变更:`openspec show --json --deltas-only` +- 全文搜索(使用 ripgrep):`rg -n "Requirement:|Scenario:" openspec/specs` + +## 快速开始 + +### CLI 命令 + +```bash +# 基本命令 +openspec list # 列出活动变更 +openspec list --specs # 列出规范 +openspec show [item] # 显示变更或规范 +openspec validate [item] # 验证变更或规范 +openspec archive [--yes|-y] # 部署后归档(非交互式运行添加 --yes) + +# 项目管理 +openspec init [path] # 初始化 OpenSpec +openspec update [path] # 更新说明文件 + +# 交互模式 +openspec show # 提示选择 +openspec validate # 批量验证模式 + +# 调试 +openspec show [change] --json --deltas-only +openspec validate [change] --strict +``` + +### 命令标志 + +- `--json` - 机器可读输出 +- `--type change|spec` - 消除项目歧义 +- `--strict` - 全面验证 +- `--no-interactive` - 禁用提示 +- `--skip-specs` - 归档时不更新规范 +- `--yes`/`-y` - 跳过确认提示(非交互式归档) + +## 目录结构 + +``` +openspec/ +├── project.md # 项目约定 +├── specs/ # 当前真相 - 已构建的内容 +│ └── [capability]/ # 单一专注的功能 +│ ├── spec.md # 需求和场景 +│ └── design.md # 技术模式 +├── changes/ # 提案 - 应该更改的内容 +│ ├── [change-name]/ +│ │ ├── proposal.md # 原因、内容、影响 +│ │ ├── tasks.md # 实施清单 +│ │ ├── design.md # 技术决策(可选;参见标准) +│ │ └── specs/ # 增量变更 +│ │ └── [capability]/ +│ │ └── spec.md # ADDED/MODIFIED/REMOVED +│ └── archive/ # 已完成的变更 +``` + +## 创建变更提案 + +### 决策树 + +``` +新请求? +├─ 修复规范行为的 Bug? → 直接修复 +├─ 拼写错误/格式/注释? → 直接修复 +├─ 新功能/能力? → 创建提案 +├─ 破坏性更改? → 创建提案 +├─ 架构更改? → 创建提案 +└─ 不明确? → 创建提案(更安全) +``` + +### 提案结构 + +1. **创建目录:** `changes/[change-id]/`(kebab-case,动词开头,唯一) + +2. **编写 proposal.md:** +```markdown +# 变更:[变更的简要描述] + +## 原因 +[关于问题/机会的 1-2 句话] + +## 变更内容 +- [变更的要点列表] +- [用 **BREAKING** 标记破坏性更改] + +## 影响 +- 受影响的规范:[列出功能] +- 受影响的代码:[关键文件/系统] +``` + +3. **创建规范增量:** `specs/[capability]/spec.md` +```markdown +## ADDED Requirements +### Requirement: 新功能 +系统应提供... + +#### Scenario: 成功情况 +- **WHEN** 用户执行操作 +- **THEN** 预期结果 + +## MODIFIED Requirements +### Requirement: 现有功能 +[完整的修改后需求] + +## REMOVED Requirements +### Requirement: 旧功能 +**原因**:[为什么移除] +**迁移**:[如何处理] +``` +如果多个功能受到影响,在 `changes/[change-id]/specs//spec.md` 下创建多个增量文件——每个功能一个。 + +4. **创建 tasks.md:** +```markdown +## 1. 实施 +- [ ] 1.1 创建数据库架构 +- [ ] 1.2 实现 API 端点 +- [ ] 1.3 添加前端组件 +- [ ] 1.4 编写测试 +``` + +5. **在需要时创建 design.md:** +如果以下任何情况适用,则创建 `design.md`;否则省略: +- 跨领域更改(多个服务/模块)或新的架构模式 +- 新的外部依赖或重大的数据模型更改 +- 安全、性能或迁移复杂性 +- 在编码之前从技术决策中受益的模糊性 + +最小 `design.md` 骨架: +```markdown +## 上下文 +[背景、约束、利益相关者] + +## 目标 / 非目标 +- 目标:[...] +- 非目标:[...] + +## 决策 +- 决策:[内容和原因] +- 考虑的替代方案:[选项 + 理由] + +## 风险 / 权衡 +- [风险] → 缓解措施 + +## 迁移计划 +[步骤、回滚] + +## 开放问题 +- [...] +``` + +## 规范文件格式 + +### 关键:场景格式 + +**正确**(使用 #### 标题): +```markdown +#### Scenario: 用户登录成功 +- **WHEN** 提供有效凭据 +- **THEN** 返回 JWT 令牌 +``` + +**错误**(不要使用项目符号或粗体): +```markdown +- **Scenario: 用户登录** ❌ +**Scenario**: 用户登录 ❌ +### Scenario: 用户登录 ❌ +``` + +每个需求必须至少有一个场景。 + +### 需求措辞 +- 对规范性需求使用 SHALL/MUST(除非有意非规范性,否则避免 should/may) + +### 增量操作 + +- `## ADDED Requirements` - 新功能 +- `## MODIFIED Requirements` - 更改的行为 +- `## REMOVED Requirements` - 已弃用的功能 +- `## RENAMED Requirements` - 名称更改 + +标题与 `trim(header)` 匹配 - 忽略空白。 + +#### 何时使用 ADDED vs MODIFIED +- ADDED:引入可以独立作为需求的新功能或子功能。当更改是正交的(例如,添加"斜杠命令配置")而不是改变现有需求的语义时,优先使用 ADDED。 +- MODIFIED:更改现有行为、范围或验收标准。始终粘贴完整的、更新的需求内容(标题 + 所有场景)。归档器将用您在此处提供的内容替换整个需求;部分增量将丢失先前的详细信息。 +- RENAMED:仅在名称更改时使用。如果您还更改行为,请使用 RENAMED(名称)加上 MODIFIED(内容),引用新名称。 + +常见陷阱:使用 MODIFIED 添加新关注点而不包含先前的文本。这会在归档时导致详细信息丢失。如果您没有明确更改现有需求,请在 ADDED 下添加新需求。 + +正确编写 MODIFIED 需求: +1) 在 `openspec/specs//spec.md` 中找到现有需求。 +2) 复制整个需求块(从 `### Requirement: ...` 到其场景)。 +3) 将其粘贴到 `## MODIFIED Requirements` 下并编辑以反映新行为。 +4) 确保标题文本完全匹配(忽略空白)并至少保留一个 `#### Scenario:`。 + +RENAMED 示例: +```markdown +## RENAMED Requirements +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` + +## 故障排除 + +### 常见错误 + +**"变更必须至少有一个增量"** +- 检查 `changes/[name]/specs/` 是否存在 .md 文件 +- 验证文件具有操作前缀(## ADDED Requirements) + +**"需求必须至少有一个场景"** +- 检查场景是否使用 `#### Scenario:` 格式(4 个井号) +- 不要对场景标题使用项目符号或粗体 + +**静默场景解析失败** +- 需要精确格式:`#### Scenario: Name` +- 使用以下命令调试:`openspec show [change] --json --deltas-only` + +### 验证提示 + +```bash +# 始终使用严格模式进行全面检查 +openspec validate [change] --strict + +# 调试增量解析 +openspec show [change] --json | jq '.deltas' + +# 检查特定需求 +openspec show [spec] --json -r 1 +``` + +## 成功路径脚本 + +```bash +# 1) 探索当前状态 +openspec spec list --long +openspec list +# 可选全文搜索: +# rg -n "Requirement:|Scenario:" openspec/specs +# rg -n "^#|Requirement:" openspec/changes + +# 2) 选择变更 id 并搭建 +CHANGE=add-two-factor-auth +mkdir -p openspec/changes/$CHANGE/{specs/auth} +printf "## 原因\n...\n\n## 变更内容\n- ...\n\n## 影响\n- ...\n" > openspec/changes/$CHANGE/proposal.md +printf "## 1. 实施\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md + +# 3) 添加增量(示例) +cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' +## ADDED Requirements +### Requirement: 双因素认证 +用户必须在登录时提供第二个因素。 + +#### Scenario: 需要 OTP +- **WHEN** 提供有效凭据 +- **THEN** 需要 OTP 挑战 +EOF + +# 4) 验证 +openspec validate $CHANGE --strict +``` + +## 多功能示例 + +``` +openspec/changes/add-2fa-notify/ +├── proposal.md +├── tasks.md +└── specs/ + ├── auth/ + │ └── spec.md # ADDED: 双因素认证 + └── notifications/ + └── spec.md # ADDED: OTP 电子邮件通知 +``` + +auth/spec.md +```markdown +## ADDED Requirements +### Requirement: 双因素认证 +... +``` + +notifications/spec.md +```markdown +## ADDED Requirements +### Requirement: OTP 电子邮件通知 +... +``` + +## 最佳实践 + +### 简单优先 +- 默认 <100 行新代码 +- 单文件实现,直到证明不足 +- 没有明确理由时避免框架 +- 选择无聊、经过验证的模式 + +### 复杂性触发器 +仅在以下情况下添加复杂性: +- 性能数据表明当前解决方案太慢 +- 具体的规模要求(>1000 用户,>100MB 数据) +- 需要抽象的多个经过验证的用例 + +### 清晰的引用 +- 使用 `file.ts:42` 格式表示代码位置 +- 将规范引用为 `specs/auth/spec.md` +- 链接相关变更和 PR + +### 功能命名 +- 使用动词-名词:`user-auth`、`payment-capture` +- 每个功能单一目的 +- 10 分钟可理解性规则 +- 如果描述需要"AND",则拆分 + +### 变更 ID 命名 +- 使用 kebab-case,简短且描述性:`add-two-factor-auth` +- 优先使用动词开头的前缀:`add-`、`update-`、`remove-`、`refactor-` +- 确保唯一性;如果已使用,追加 `-2`、`-3` 等 + +## 工具选择指南 + +| 任务 | 工具 | 原因 | +|------|------|-----| +| 按模式查找文件 | Glob | 快速模式匹配 | +| 搜索代码内容 | Grep | 优化的正则表达式搜索 | +| 读取特定文件 | Read | 直接文件访问 | +| 探索未知范围 | Task | 多步骤调查 | + +## 错误恢复 + +### 变更冲突 +1. 运行 `openspec list` 查看活动变更 +2. 检查重叠的规范 +3. 与变更所有者协调 +4. 考虑合并提案 + +### 验证失败 +1. 使用 `--strict` 标志运行 +2. 检查 JSON 输出以获取详细信息 +3. 验证规范文件格式 +4. 确保场景格式正确 + +### 缺少上下文 +1. 首先阅读 project.md +2. 检查相关规范 +3. 查看最近的归档 +4. 请求澄清 + +## 快速参考 + +### 阶段指示器 +- `changes/` - 已提议,尚未构建 +- `specs/` - 已构建并部署 +- `archive/` - 已完成的变更 + +### 文件用途 +- `proposal.md` - 原因和内容 +- `tasks.md` - 实施步骤 +- `design.md` - 技术决策 +- `spec.md` - 需求和行为 + +### CLI 要点 +```bash +openspec list # 正在进行什么? +openspec show [item] # 查看详细信息 +openspec validate --strict # 是否正确? +openspec archive [--yes|-y] # 标记完成(自动化添加 --yes) +``` + +记住:规范是真相。变更是提案。保持它们同步。 diff --git a/openspec/changes/add-third-party-api-logging/proposal.md b/openspec/changes/add-third-party-api-logging/proposal.md new file mode 100644 index 0000000..efa4e93 --- /dev/null +++ b/openspec/changes/add-third-party-api-logging/proposal.md @@ -0,0 +1,22 @@ +# 变更:添加第三方 API 请求响应原始内容日志记录 + +## 原因 +当前系统在调用第三方 API(如 DashScope)时,只记录了部分信息(如路径、FPS、模型名称),但没有记录完整的请求和响应原始内容。这导致在调试 API 调用问题时,无法查看完整的请求参数和响应数据,难以快速定位问题。 + +为了便于调试和问题排查,需要记录所有第三方 API 调用的完整请求和响应原始内容。 + +## 变更内容 +- 在 `DashScopeService` 中添加完整的请求和响应日志记录 +- 记录请求的完整内容(包括 messages、model、api_key 等所有参数) +- 记录响应的完整内容(包括 status_code、response body、headers 等) +- 使用适当的日志级别(DEBUG 级别记录完整内容,INFO 级别记录摘要) +- 记录所有第三方 API 调用的原始请求和响应数据,便于调试和问题排查 + +## 影响 +- 受影响的规范:`specs/video-analysis/spec.md` - 需要添加日志记录要求 +- 受影响的代码: + - `app/services/dashscope_service.py` - 添加详细的请求响应日志记录 + - `app/config.py` - 可能需要添加日志配置选项 +- 有助于调试和问题排查 +- 不影响现有功能,只是增强日志记录 + diff --git a/openspec/changes/add-third-party-api-logging/specs/logging/spec.md b/openspec/changes/add-third-party-api-logging/specs/logging/spec.md new file mode 100644 index 0000000..6d88bdf --- /dev/null +++ b/openspec/changes/add-third-party-api-logging/specs/logging/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Third-Party API Request/Response Logging +The system SHALL log complete request and response content for all third-party API calls (such as DashScope API). The system SHALL record request parameters, response data, and status information. The system SHALL mask sensitive information (such as API keys) in logs. The system SHALL use appropriate log levels (DEBUG for detailed content, INFO for summaries). + +#### Scenario: Successful API Call Logging +- **WHEN** the system makes a third-party API call (e.g., DashScope API for video analysis) +- **THEN** the system SHALL log the complete request content including: + - **AND** request parameters (model, messages, fps, etc.) + - **AND** request headers (with API key masked) + - **AND** request timestamp +- **AND** the system SHALL log the complete response content including: + - **AND** response status code + - **AND** response body/content + - **AND** response headers (if available) + - **AND** response timestamp +- **AND** the system SHALL use DEBUG log level for detailed content +- **AND** the system SHALL use INFO log level for summary information + +#### Scenario: API Key Masking in Logs +- **WHEN** the system logs API request information containing sensitive data (API keys) +- **THEN** the system SHALL mask the API key showing only the first few and last few characters +- **AND** the system SHALL replace the middle portion with asterisks or similar masking characters +- **AND** the system SHALL ensure no sensitive information is exposed in logs + +#### Scenario: Failed API Call Logging +- **WHEN** a third-party API call fails +- **THEN** the system SHALL log the complete error response including: + - **AND** error status code + - **AND** error message + - **AND** error details (if available) + - **AND** request that caused the error +- **AND** the system SHALL use ERROR or WARNING log level for failed calls + +#### Scenario: Large Response Logging +- **WHEN** the system receives a large response from a third-party API +- **THEN** the system SHALL log the response content +- **AND** the system SHALL handle large responses appropriately (e.g., truncate very large content or log summary) +- **AND** the system SHALL ensure logs remain readable and manageable + diff --git a/openspec/changes/add-third-party-api-logging/specs/video-analysis/spec.md b/openspec/changes/add-third-party-api-logging/specs/video-analysis/spec.md new file mode 100644 index 0000000..1acef6e --- /dev/null +++ b/openspec/changes/add-third-party-api-logging/specs/video-analysis/spec.md @@ -0,0 +1,48 @@ +## MODIFIED Requirements + +### Requirement: Video Content Analysis +The system SHALL provide functionality to analyze video content using Alibaba Cloud DashScope MultiModal API. The system SHALL extract video frames at a configurable fps rate. The system SHALL call the DashScope API with the video file and analysis prompt. The system SHALL log complete request and response content for all DashScope API calls. The system SHALL save the analysis results to MongoDB. + +#### Scenario: Successful Video Analysis +- **WHEN** a user requests analysis for an uploaded video +- **THEN** the system SHALL read the video file from storage +- **AND** the system SHALL call DashScope MultiModalConversation API with the video file and fps parameter (default: 2) +- **AND** the system SHALL log the complete request content including model, messages, fps, and other parameters (with sensitive information masked) +- **AND** the system SHALL receive the analysis result from the API +- **AND** the system SHALL log the complete response content including status code, response body, and headers +- **AND** the system SHALL save the analysis result to MongoDB with video_id, analysis_type, content, fps, and created_at +- **AND** the system SHALL update the video status to "analyzed" +- **AND** the system SHALL return the analysis result to the user + +#### Scenario: Analysis API Failure +- **WHEN** the DashScope API call fails (network error, API error, etc.) +- **THEN** the system SHALL handle the error gracefully +- **AND** the system SHALL log the complete error response including status code, error message, and request details +- **AND** the system SHALL update the video status to "failed" +- **AND** the system SHALL return an appropriate error message to the user +- **AND** the system SHALL log the error for debugging + +### Requirement: Video Summary Generation +The system SHALL provide functionality to generate a summary of video content. The system SHALL use the DashScope API to analyze the video using the correct local file path format (`file://{absolute_path}`) and generate a summary based on a predefined prompt template. The system SHALL log complete request and response content for all DashScope API calls. The system SHALL save the summary to MongoDB. + +#### Scenario: Successful Summary Generation +- **WHEN** a user requests a summary for an analyzed video +- **THEN** the system SHALL construct the file path in the format `file://{absolute_path}` +- **AND** the system SHALL call the DashScope API with the correctly formatted video file path and summary prompt +- **AND** the system SHALL log the complete request and response content +- **AND** the system SHALL receive the summary text from the API +- **AND** the system SHALL save the summary to MongoDB with video_id, summary_text, and created_at +- **AND** the system SHALL return the summary to the user + +### Requirement: Video Comparison +The system SHALL provide functionality to compare content between multiple videos. The system SHALL extract content from each video using the correct local file path format (`file://{absolute_path}`). The system SHALL generate a comparison analysis highlighting similarities and differences. The system SHALL log complete request and response content for all DashScope API calls. The system SHALL save the comparison result to MongoDB. + +#### Scenario: Successful Video Comparison +- **WHEN** a user requests comparison between two or more videos +- **THEN** the system SHALL construct file paths for each video in the format `file://{absolute_path}` +- **AND** the system SHALL call the DashScope API with multiple correctly formatted video file paths and comparison prompt +- **AND** the system SHALL log the complete request and response content +- **AND** the system SHALL generate a comparison result +- **AND** the system SHALL save the comparison to MongoDB with video_ids, comparison_result, and created_at +- **AND** the system SHALL return the comparison result to the user + diff --git a/openspec/changes/add-third-party-api-logging/tasks.md b/openspec/changes/add-third-party-api-logging/tasks.md new file mode 100644 index 0000000..78cd358 --- /dev/null +++ b/openspec/changes/add-third-party-api-logging/tasks.md @@ -0,0 +1,26 @@ +## 1. 实施请求响应日志记录 + +- [ ] 1.1 在 `DashScopeService` 中添加请求日志记录方法 +- [ ] 1.2 记录完整的请求内容(messages、model、fps 等参数) +- [ ] 1.3 实现 API Key 脱敏处理(只显示前几位和后几位) +- [ ] 1.4 记录完整的响应内容(status_code、response body、headers) +- [ ] 1.5 使用 DEBUG 级别记录完整内容,INFO 级别记录摘要 +- [ ] 1.6 在 `analyze_video` 方法中添加请求响应日志 +- [ ] 1.7 在 `compare_videos` 方法中添加请求响应日志 +- [ ] 1.8 在 Base64 编码方式调用时也记录完整日志 + +## 2. 配置和优化 + +- [ ] 2.1 检查是否需要添加配置选项控制详细日志(可选) +- [ ] 2.2 确保日志格式清晰易读(使用 JSON 格式或结构化格式) +- [ ] 2.3 处理大文件/大响应的日志记录(避免日志过大) +- [ ] 2.4 确保敏感信息不会泄露到日志中 + +## 3. 测试和验证 + +- [ ] 3.1 测试视频分析功能的日志记录 +- [ ] 3.2 测试视频总结功能的日志记录 +- [ ] 3.3 测试视频对比功能的日志记录 +- [ ] 3.4 验证 API Key 脱敏是否正确 +- [ ] 3.5 验证日志内容是否完整且可读 + diff --git a/openspec/changes/add-video-analysis-feature/design.md b/openspec/changes/add-video-analysis-feature/design.md new file mode 100644 index 0000000..1a30325 --- /dev/null +++ b/openspec/changes/add-video-analysis-feature/design.md @@ -0,0 +1,175 @@ +# 技术设计文档 + +## 上下文 +项目需要实现视频内容分析和总结功能,使用阿里云 DashScope 多模态 API 进行视频解析。系统需要支持视频上传、内容解析、自动总结以及多视频对比功能。 + +## 目标 / 非目标 + +### 目标 +- 提供 RESTful API 用于视频文件上传和管理 +- 集成阿里云 DashScope API 进行视频内容解析 +- 自动生成视频内容总结 +- 支持多个视频之间的内容对比 +- 使用 MongoDB 持久化存储视频元数据和分析结果 +- 提供简洁的静态前端页面进行交互 + +### 非目标 +- 视频流式处理(当前仅支持文件上传) +- 实时视频分析(异步处理) +- 视频编辑功能 +- 用户认证和权限管理(当前版本) + +## 决策 + +### 1. 后端框架:Python Flask +**决策**:使用 Python Flask 构建 RESTful API 后端 + +**原因**: +- Python 对 AI/ML 生态支持良好,DashScope SDK 为 Python 原生 +- Flask 轻量级,适合快速开发 RESTful API +- 易于集成第三方服务 + +**考虑的替代方案**: +- FastAPI:性能更好,但 Flask 更简单直接 +- Django:功能完整但过于重量级 + +### 2. 数据库:MongoDB +**决策**:使用 MongoDB 存储视频元数据和分析结果 + +**原因**: +- 文档数据库适合存储非结构化的分析结果 +- 灵活的 schema 便于扩展 +- 项目已有 MongoDB 配置 + +**考虑的替代方案**: +- PostgreSQL:关系型数据库,但分析结果结构可能变化 +- SQLite:轻量但不适合生产环境 + +### 3. 视频解析服务:阿里云 DashScope +**决策**:使用 DashScope MultiModalConversation API(qwen3-vl-plus 模型) + +**原因**: +- 官方提供的多模态视频理解能力 +- 支持视频抽帧分析(fps 参数) +- API 调用简单,集成方便 + +**配置要点**: +- fps 参数控制抽帧频率(默认 2,即每 0.5 秒一帧) +- API Key 通过环境变量或配置文件管理 +- 需要处理 API 调用失败和重试 + +### 4. 文件存储:本地文件系统 +**决策**:使用本地 uploads/ 目录存储视频文件 + +**原因**: +- 简单直接,无需额外服务 +- 适合中小规模部署 + +**考虑的替代方案**: +- 对象存储(OSS/S3):适合大规模部署,但增加复杂度 +- 数据库存储:不适合大文件 + +### 5. 前端:静态页面 +**决策**:使用纯 HTML/CSS/JavaScript 静态页面 + +**原因**: +- 简单直接,无需构建工具 +- 易于部署和维护 +- 满足当前功能需求 + +**考虑的替代方案**: +- React/Vue 框架:功能强大但增加复杂度 +- 服务端渲染:当前不需要 SEO + +## 架构设计 + +### 后端架构 +``` +app/ +├── __init__.py # Flask 应用初始化 +├── config.py # 配置管理 +├── routes/ +│ └── video_routes.py # 视频相关路由 +├── services/ +│ ├── video_service.py # 视频处理服务 +│ ├── dashscope_service.py # DashScope API 封装 +│ └── analysis_service.py # 分析业务逻辑 +├── models/ +│ ├── video.py # 视频模型 +│ ├── analysis.py # 分析结果模型 +│ └── comparison.py # 对比结果模型 +└── utils/ + ├── file_utils.py # 文件处理工具 + └── validators.py # 验证工具 +``` + +### API 端点设计 +``` +POST /api/videos/upload # 上传视频 +GET /api/videos # 获取视频列表 +GET /api/videos/{video_id} # 获取视频详情 +POST /api/videos/{video_id}/analyze # 解析视频内容 +POST /api/videos/{video_id}/summarize # 生成视频总结 +GET /api/videos/{video_id}/summary # 获取视频总结 +POST /api/videos/compare # 对比多个视频 +GET /api/videos/compare/{comparison_id} # 获取对比结果 +``` + +### 数据模型设计 + +**Video 文档**: +```python +{ + "_id": ObjectId, + "filename": str, + "file_path": str, + "upload_time": datetime, + "file_size": int, + "status": str, # "uploaded", "analyzing", "analyzed", "failed" + "mime_type": str +} +``` + +**AnalysisResult 文档**: +```python +{ + "_id": ObjectId, + "video_id": ObjectId, + "analysis_type": str, # "content", "summary" + "content": str, + "fps": int, + "created_at": datetime +} +``` + +**Comparison 文档**: +```python +{ + "_id": ObjectId, + "video_ids": [ObjectId], + "comparison_result": str, + "created_at": datetime +} +``` + +## 风险 / 权衡 + +### 风险 +1. **API 调用失败** → 实现重试机制和错误处理 +2. **大文件上传超时** → 设置合理的文件大小限制和超时时间 +3. **视频解析耗时** → 异步处理,返回任务 ID,客户端轮询状态 +4. **存储空间增长** → 定期清理或归档旧文件 + +### 权衡 +- **同步 vs 异步处理**:当前采用同步处理,简单但可能阻塞。后续可改为异步任务队列 +- **本地存储 vs 对象存储**:当前使用本地存储,简单但扩展性有限 + +## 迁移计划 +- 无需迁移(新功能) + +## 开放问题 +- [ ] 是否需要实现视频文件清理策略(自动删除旧文件)? +- [ ] 是否需要支持批量视频上传? +- [ ] 视频解析的 fps 参数是否需要可配置? +- [ ] 是否需要实现解析结果缓存机制? + diff --git a/openspec/changes/add-video-analysis-feature/proposal.md b/openspec/changes/add-video-analysis-feature/proposal.md new file mode 100644 index 0000000..3a8ff3d --- /dev/null +++ b/openspec/changes/add-video-analysis-feature/proposal.md @@ -0,0 +1,23 @@ +# 变更:添加视频分析与总结功能 + +## 原因 +需要构建一个视频内容分析系统,能够解析上传的视频文件,提取视频内容信息,生成视频总结,并支持多个视频之间的内容对比。这将帮助用户快速理解视频内容,提高信息处理效率。 + +## 变更内容 +- 添加视频文件上传功能,支持视频文件存储和管理 +- 集成阿里云 DashScope 多模态 API(qwen3-vl-plus 模型)进行视频内容解析 +- 实现视频内容总结功能,自动生成视频内容摘要 +- 实现视频对比功能,支持多个视频之间的内容对比分析 +- 使用 Python Flask 构建 RESTful API 后端服务 +- 使用 MongoDB 存储视频元数据、解析结果和总结内容 +- 使用 config.yaml 进行配置管理 +- 提供静态前端页面用于视频上传、查看总结和对比结果 + +## 影响 +- 受影响的规范:新增 `video-analysis` 功能规范 +- 受影响的代码: + - 后端:需要创建 Flask 应用、API 路由、视频处理服务、MongoDB 模型 + - 前端:需要创建静态 HTML/CSS/JavaScript 页面 + - 配置:更新 config.yaml 添加 DashScope API 配置 +- 技术栈变更:从 Go 迁移到 Python Flask(如适用) + diff --git a/openspec/changes/add-video-analysis-feature/specs/video-analysis/spec.md b/openspec/changes/add-video-analysis-feature/specs/video-analysis/spec.md new file mode 100644 index 0000000..cf017ca --- /dev/null +++ b/openspec/changes/add-video-analysis-feature/specs/video-analysis/spec.md @@ -0,0 +1,120 @@ +# Video Analysis Specification + +## ADDED Requirements + +### Requirement: Video File Upload +The system SHALL provide an API endpoint for uploading video files. The system SHALL validate the uploaded file format and size. The system SHALL store the video file in the designated uploads directory. The system SHALL save video metadata to MongoDB. + +#### Scenario: Successful Video Upload +- **WHEN** a user uploads a valid video file (supported formats: mp4, avi, mov, etc.) +- **THEN** the system SHALL save the file to the uploads directory +- **AND** the system SHALL create a video document in MongoDB with metadata (filename, file_path, upload_time, file_size, status) +- **AND** the system SHALL return a response with video_id and upload status + +#### Scenario: Invalid File Format Upload +- **WHEN** a user uploads a file with unsupported format +- **THEN** the system SHALL reject the upload +- **AND** the system SHALL return an error message indicating the file format is not supported + +#### Scenario: File Size Exceeds Limit +- **WHEN** a user uploads a video file exceeding the size limit +- **THEN** the system SHALL reject the upload +- **AND** the system SHALL return an error message indicating the file size limit + +### Requirement: Video Content Analysis +The system SHALL provide functionality to analyze video content using Alibaba Cloud DashScope MultiModal API. The system SHALL extract video frames at a configurable fps rate. The system SHALL call the DashScope API with the video file and analysis prompt. The system SHALL save the analysis results to MongoDB. + +#### Scenario: Successful Video Analysis +- **WHEN** a user requests analysis for an uploaded video +- **THEN** the system SHALL read the video file from storage +- **AND** the system SHALL call DashScope MultiModalConversation API with the video file and fps parameter (default: 2) +- **AND** the system SHALL receive the analysis result from the API +- **AND** the system SHALL save the analysis result to MongoDB with video_id, analysis_type, content, fps, and created_at +- **AND** the system SHALL update the video status to "analyzed" +- **AND** the system SHALL return the analysis result to the user + +#### Scenario: Analysis API Failure +- **WHEN** the DashScope API call fails (network error, API error, etc.) +- **THEN** the system SHALL handle the error gracefully +- **AND** the system SHALL update the video status to "failed" +- **AND** the system SHALL return an appropriate error message to the user +- **AND** the system SHALL log the error for debugging + +### Requirement: Video Summary Generation +The system SHALL provide functionality to generate a summary of video content. The system SHALL use the DashScope API to analyze the video and generate a summary based on a predefined prompt template. The system SHALL save the summary to MongoDB. + +#### Scenario: Successful Summary Generation +- **WHEN** a user requests a summary for an analyzed video +- **THEN** the system SHALL call the DashScope API with the video file and summary prompt +- **AND** the system SHALL receive the summary text from the API +- **AND** the system SHALL save the summary to MongoDB with video_id, summary_text, and created_at +- **AND** the system SHALL return the summary to the user + +#### Scenario: Summary for Unanalyzed Video +- **WHEN** a user requests a summary for a video that has not been analyzed +- **THEN** the system SHALL first perform video analysis +- **AND** the system SHALL then generate the summary +- **AND** the system SHALL return both analysis and summary results + +### Requirement: Video Comparison +The system SHALL provide functionality to compare content between multiple videos. The system SHALL extract content from each video. The system SHALL generate a comparison analysis highlighting similarities and differences. The system SHALL save the comparison result to MongoDB. + +#### Scenario: Successful Video Comparison +- **WHEN** a user requests comparison between two or more videos +- **THEN** the system SHALL analyze each video if not already analyzed +- **AND** the system SHALL extract content from each video +- **AND** the system SHALL call the DashScope API with multiple video contents and comparison prompt +- **AND** the system SHALL generate a comparison result +- **AND** the system SHALL save the comparison to MongoDB with video_ids, comparison_result, and created_at +- **AND** the system SHALL return the comparison result to the user + +#### Scenario: Comparison with Insufficient Videos +- **WHEN** a user requests comparison with less than two videos +- **THEN** the system SHALL reject the request +- **AND** the system SHALL return an error message indicating at least two videos are required + +### Requirement: Video List and Details +The system SHALL provide API endpoints to retrieve a list of uploaded videos and video details. The system SHALL return video metadata including upload time, file size, and analysis status. + +#### Scenario: Retrieve Video List +- **WHEN** a user requests the list of videos +- **THEN** the system SHALL query MongoDB for all video documents +- **AND** the system SHALL return a list of videos with basic metadata (id, filename, upload_time, file_size, status) + +#### Scenario: Retrieve Video Details +- **WHEN** a user requests details for a specific video +- **THEN** the system SHALL query MongoDB for the video document by video_id +- **AND** the system SHALL return detailed information including metadata, analysis results, and summary if available + +### Requirement: Frontend Video Management Interface +The system SHALL provide a static web interface for video management. The interface SHALL allow users to upload videos, view video list, trigger analysis, view summaries, and perform video comparisons. + +#### Scenario: Upload Video via Frontend +- **WHEN** a user selects a video file and clicks upload in the frontend +- **THEN** the frontend SHALL send a POST request to /api/videos/upload with the video file +- **AND** the frontend SHALL display upload progress +- **AND** the frontend SHALL show success message and update the video list upon completion + +#### Scenario: View Video Summary +- **WHEN** a user clicks to view summary for a video +- **THEN** the frontend SHALL send a GET request to /api/videos/{video_id}/summary +- **AND** the frontend SHALL display the summary text in a readable format +- **AND** if no summary exists, the frontend SHALL provide an option to generate one + +#### Scenario: Compare Videos +- **WHEN** a user selects multiple videos and clicks compare +- **THEN** the frontend SHALL send a POST request to /api/videos/compare with video_ids +- **AND** the frontend SHALL display the comparison result in a structured format +- **AND** the frontend SHALL highlight similarities and differences + +### Requirement: Configuration Management +The system SHALL read configuration from config.yaml file. The system SHALL support environment variable overrides for sensitive configuration (API keys). The system SHALL validate configuration on startup. + +#### Scenario: Load Configuration +- **WHEN** the application starts +- **THEN** the system SHALL read config.yaml file +- **AND** the system SHALL check for environment variables (DASHSCOPE_API_KEY, MONGODB_URI, etc.) +- **AND** the system SHALL override config values with environment variables if present +- **AND** the system SHALL validate required configuration items +- **AND** the system SHALL fail to start if required configuration is missing + diff --git a/openspec/changes/add-video-analysis-feature/tasks.md b/openspec/changes/add-video-analysis-feature/tasks.md new file mode 100644 index 0000000..093e358 --- /dev/null +++ b/openspec/changes/add-video-analysis-feature/tasks.md @@ -0,0 +1,104 @@ +## 1. 后端开发 + +### 1.1 项目初始化 +- [ ] 创建 Python 虚拟环境 +- [ ] 创建 requirements.txt,包含 Flask、pymongo、dashscope、pyyaml 等依赖 +- [ ] 创建项目目录结构(app/、routes/、services/、models/、utils/) + +### 1.2 配置管理 +- [ ] 实现 config.yaml 读取工具类 +- [ ] 在 config.yaml 中添加 DashScope API Key 配置项 +- [ ] 实现环境变量覆盖配置功能 + +### 1.3 MongoDB 数据模型 +- [ ] 创建视频文档模型(Video Model) + - [ ] 字段:id、filename、file_path、upload_time、file_size、status +- [ ] 创建视频分析结果模型(AnalysisResult Model) + - [ ] 字段:video_id、analysis_type、content、created_at、fps +- [ ] 创建视频总结模型(Summary Model) + - [ ] 字段:video_id、summary_text、created_at +- [ ] 创建视频对比模型(Comparison Model) + - [ ] 字段:video_ids、comparison_result、created_at + +### 1.4 视频上传服务 +- [ ] 实现文件上传 API 端点(POST /api/videos/upload) +- [ ] 实现文件验证(格式、大小限制) +- [ ] 实现文件存储到 uploads/ 目录 +- [ ] 实现视频元数据保存到 MongoDB + +### 1.5 阿里云 DashScope 集成 +- [ ] 实现 DashScope 客户端封装类 +- [ ] 实现视频解析服务(调用 MultiModalConversation.call) +- [ ] 实现 fps 参数配置(默认 2,可配置) +- [ ] 实现错误处理和重试机制 + +### 1.6 视频分析服务 +- [ ] 实现视频内容解析 API(POST /api/videos/{video_id}/analyze) +- [ ] 实现解析结果保存到 MongoDB +- [ ] 实现解析状态跟踪(pending、processing、completed、failed) + +### 1.7 视频总结服务 +- [ ] 实现视频总结生成 API(POST /api/videos/{video_id}/summarize) +- [ ] 实现总结提示词模板 +- [ ] 实现总结结果保存到 MongoDB +- [ ] 实现总结历史查询 + +### 1.8 视频对比服务 +- [ ] 实现视频对比 API(POST /api/videos/compare) +- [ ] 实现多视频内容提取和对比逻辑 +- [ ] 实现对比结果生成和保存 +- [ ] 实现对比历史查询 + +### 1.9 API 路由 +- [ ] 创建视频相关路由(/api/videos/*) +- [ ] 实现错误处理中间件 +- [ ] 实现请求日志记录 +- [ ] 实现 CORS 支持(用于前端访问) + +## 2. 前端开发 + +### 2.1 静态页面结构 +- [ ] 创建 index.html 主页面 +- [ ] 创建视频上传页面组件 +- [ ] 创建视频列表页面组件 +- [ ] 创建视频总结展示页面组件 +- [ ] 创建视频对比页面组件 + +### 2.2 前端功能实现 +- [ ] 实现视频文件上传功能(使用 FormData 和 fetch API) +- [ ] 实现上传进度显示 +- [ ] 实现视频列表展示(从 API 获取) +- [ ] 实现视频分析触发功能 +- [ ] 实现视频总结展示 +- [ ] 实现视频选择对比功能 +- [ ] 实现对比结果展示 +- [ ] 实现错误提示和加载状态 + +### 2.3 样式和交互 +- [ ] 设计响应式布局 +- [ ] 实现现代化的 UI 样式 +- [ ] 实现交互反馈(按钮状态、加载动画等) + +## 3. 测试和文档 + +### 3.1 API 文档 +- [ ] 编写完整的 RESTful API 文档(Markdown 格式) +- [ ] 包含所有端点的请求/响应示例 +- [ ] 包含错误码说明 + +### 3.2 测试 +- [ ] 编写单元测试(视频处理服务) +- [ ] 编写 API 集成测试 +- [ ] 测试视频上传、解析、总结、对比的完整流程 + +## 4. 部署准备 + +### 4.1 环境配置 +- [ ] 更新 .farmer.dev.json 配置(Python 应用) +- [ ] 创建 Dockerfile(如需要) +- [ ] 更新部署脚本 + +### 4.2 文档 +- [ ] 更新 README.md 说明项目结构和使用方法 +- [ ] 添加环境变量配置说明 + diff --git a/openspec/changes/fix-dashscope-api-key-validation/proposal.md b/openspec/changes/fix-dashscope-api-key-validation/proposal.md new file mode 100644 index 0000000..a6e65d5 --- /dev/null +++ b/openspec/changes/fix-dashscope-api-key-validation/proposal.md @@ -0,0 +1,16 @@ +# 变更:修复 DashScope API Key 验证逻辑 + +## 原因 +当前实现在初始化 `DashScopeService` 时立即检查 API key,如果为空就抛出错误。这导致即使 API key 在 `config.yaml` 中设置为空字符串,应用程序也无法启动。应该允许 API key 为空,在实际调用 API 时才进行验证。 + +## 变更内容 +- 修改 `DashScopeService.__init__` 方法,允许 API key 为空 +- 在实际调用 API 方法时(`analyze_video`, `summarize_video`, `compare_videos`)才检查 API key +- 提供更友好的错误提示,指导用户在 `config.yaml` 中设置 API key + +## 影响 +- 受影响的文件:`app/services/dashscope_service.py` +- 应用程序可以在未设置 API key 的情况下启动 +- 只有在实际使用视频分析功能时才会提示需要设置 API key +- 不影响其他功能 + diff --git a/openspec/changes/fix-dashscope-api-key-validation/tasks.md b/openspec/changes/fix-dashscope-api-key-validation/tasks.md new file mode 100644 index 0000000..3271aa2 --- /dev/null +++ b/openspec/changes/fix-dashscope-api-key-validation/tasks.md @@ -0,0 +1,8 @@ +## 1. 修复 API Key 验证逻辑 + +- [x] 修改 `DashScopeService.__init__` 方法,移除立即验证 API key 的逻辑 +- [x] 创建 `_ensure_api_key` 私有方法用于延迟验证 +- [x] 在 `analyze_video` 方法中添加 API key 验证 +- [x] 在 `compare_videos` 方法中添加 API key 验证(`summarize_video` 调用 `analyze_video`,会自动验证) +- [x] 改进错误提示信息,指向 config.yaml 配置 + diff --git a/openspec/changes/fix-dashscope-local-file-path/ROOT_CAUSE_ANALYSIS.md b/openspec/changes/fix-dashscope-local-file-path/ROOT_CAUSE_ANALYSIS.md new file mode 100644 index 0000000..d64d03f --- /dev/null +++ b/openspec/changes/fix-dashscope-local-file-path/ROOT_CAUSE_ANALYSIS.md @@ -0,0 +1,127 @@ +# DashScope 本地视频文件路径格式问题 - 根因分析 + +## 问题描述 + +调用 DashScope API 时返回错误: +``` +<400> InternalError.Algo.InvalidParameter: The provided URL does not appear to be valid. Ensure it is correctly formatted. +``` + +## 当前实现分析 + +### 尝试的方法 1:直接使用绝对路径 +```python +video_path_for_api = str(video_path.absolute()) +# 例如:/Users/d-robotics/workSpace/videoSummary/uploads/episode_0_model_input_observation_full_image.mp4 +``` +**结果**:失败,返回 400 错误 + +### 尝试的方法 2:使用 file:// 格式 +```python +file_url = f"file://{absolute_path_str}" +# 例如:file:///Users/d-robotics/workSpace/videoSummary/uploads/episode_0_model_input_observation_full_image.mp4 +``` +**结果**:失败,返回 400 错误 + +## 根因分析 + +### 1. 路径格式问题 + +根据 DashScope 官方文档示例: +```python +local_path = "xxx/test.mp4" # 绝对路径字符串 +video_path = f"file://{local_path}" +``` + +**关键发现**: +- 文档示例中 `local_path` 是**绝对路径字符串**,不包含前导斜杠 +- 但在 macOS/Linux 系统上,绝对路径通常以 `/` 开头 +- 当使用 `file://` 前缀时,如果路径以 `/` 开头,应该使用 `file:///`(三个斜杠)而不是 `file://`(两个斜杠) + +### 2. URL 编码问题 + +路径中可能包含特殊字符(虽然当前路径没有),但 `file://` URL 格式要求路径部分需要进行 URL 编码: +- 空格应该编码为 `%20` +- 其他特殊字符也需要编码 + +### 3. 可能的解决方案 + +#### 方案 A:使用正确的 file:// 格式(推荐) + +根据 RFC 3986 和 file:// URL 规范: +- 在 Unix/macOS 系统上,`file://` URL 应该使用三个斜杠:`file:///` +- 路径应该进行 URL 编码 + +```python +from urllib.parse import quote, urljoin +import os + +absolute_path = video_path.absolute() +absolute_path_str = str(absolute_path) + +# 对于 Unix/macOS 系统,使用 file:/// 格式 +if os.name != 'nt': # Unix/macOS + # 移除前导斜杠,然后添加 file:/// + path_without_leading_slash = absolute_path_str.lstrip('/') + file_url = f"file:///{path_without_leading_slash}" + # 或者直接使用 file:/// 加上完整路径 + file_url = f"file://{absolute_path_str}" +else: # Windows + # Windows 路径需要特殊处理 + file_url = f"file:///{absolute_path_str.replace('\\', '/')}" +``` + +#### 方案 B:直接使用绝对路径字符串(如果 SDK 支持) + +根据文档示例,可能 SDK 内部会自动处理 `file://` 前缀,所以应该直接传递绝对路径字符串: + +```python +video_path_for_api = str(video_path.absolute()) +``` + +#### 方案 C:使用 pathlib 的 as_uri() 方法 + +Python 的 `pathlib.Path` 提供了 `as_uri()` 方法,可以生成正确的 file:// URL: + +```python +video_path_for_api = video_path.absolute().as_uri() +# 这会自动生成正确的 file:// URL 格式 +``` + +## 推荐解决方案 + +**使用 `pathlib.Path.as_uri()` 方法**,这是最可靠的方式,因为: +1. Python 标准库方法,符合 RFC 3986 规范 +2. 自动处理不同操作系统的路径格式 +3. 自动处理特殊字符编码 +4. 生成正确的 file:// URL 格式 + +### 实现代码 + +```python +def analyze_video(self, video_path: Path, prompt: str, fps: Optional[int] = None) -> Dict[str, Any]: + # ... existing code ... + + # Use pathlib's as_uri() method to generate correct file:// URL + absolute_path = video_path.absolute() + video_path_for_api = absolute_path.as_uri() + + # Log for debugging + logger.info(f"Using video path (file:// URI): {video_path_for_api}") + + # ... rest of the code ... +``` + +## 验证步骤 + +1. 使用 `pathlib.Path.as_uri()` 生成 file:// URL +2. 验证生成的 URL 格式是否正确 +3. 测试调用 DashScope API +4. 如果仍然失败,检查 DashScope SDK 文档是否有其他要求 + +## 注意事项 + +- DashScope API 可能需要在服务器端能够访问该文件路径 +- 如果 API 服务器运行在不同的机器上,本地文件路径将无法访问 +- 在这种情况下,可能需要将文件上传到可访问的 URL(如 OSS),或使用其他方式传递文件内容 + diff --git a/openspec/changes/fix-dashscope-local-file-path/proposal.md b/openspec/changes/fix-dashscope-local-file-path/proposal.md new file mode 100644 index 0000000..39d2179 --- /dev/null +++ b/openspec/changes/fix-dashscope-local-file-path/proposal.md @@ -0,0 +1,33 @@ +# 变更:修复 DashScope 本地视频文件路径格式问题 + +## 原因 +当前实现尝试使用本地文件路径调用 DashScope API,但 API 返回错误:"The provided URL does not appear to be valid. Ensure it is correctly formatted." + +根据 DashScope 官方文档示例,应该使用 `file://` 格式的本地文件路径: +```python +local_path = "xxx/test.mp4" # 绝对路径字符串 +video_path = f"file://{local_path}" +``` + +当前代码虽然尝试了两种方式(直接路径和 file:// 格式),但都失败了。问题可能在于: +1. `file://` 格式的路径构造不正确(在 macOS/Linux 上应该是 `file://` 而不是 `file:///`) +2. 路径中的特殊字符(如空格)没有进行 URL 编码 +3. 路径格式不完全符合 DashScope SDK 的要求 + +需要严格按照官方文档示例实现,确保本地视频文件能够正确传递给 DashScope API。 + +## 变更内容 +- 修改 `DashScopeService.analyze_video` 方法,严格按照 DashScope 文档示例使用 `file://{absolute_path}` 格式 +- 确保路径格式正确:使用绝对路径字符串,然后添加 `file://` 前缀 +- 处理路径中的特殊字符,确保路径格式符合 URL 规范 +- 修改 `DashScopeService.compare_videos` 方法,使用相同的路径格式 +- 移除不必要的回退逻辑,直接使用正确的格式 +- 添加路径格式验证和错误处理 + +## 影响 +- 受影响的规范:`specs/video-analysis/spec.md` - 需要更新视频分析实现要求 +- 受影响的代码: + - `app/services/dashscope_service.py` - 修改视频路径格式处理逻辑 +- 修复后视频分析、总结和对比功能可以正常工作 +- 不影响视频上传和其他功能 + diff --git a/openspec/changes/fix-dashscope-local-file-path/specs/video-analysis/spec.md b/openspec/changes/fix-dashscope-local-file-path/specs/video-analysis/spec.md new file mode 100644 index 0000000..06658c3 --- /dev/null +++ b/openspec/changes/fix-dashscope-local-file-path/specs/video-analysis/spec.md @@ -0,0 +1,44 @@ +## MODIFIED Requirements + +### Requirement: Video Content Analysis +The system SHALL provide functionality to analyze video content using Alibaba Cloud DashScope MultiModal API. The system SHALL extract video frames at a configurable fps rate. The system SHALL call the DashScope API with the video file using the correct local file path format (`file://{absolute_path}`) as specified in DashScope documentation. The system SHALL save the analysis results to MongoDB. + +#### Scenario: Successful Video Analysis +- **WHEN** a user requests analysis for an uploaded video +- **THEN** the system SHALL read the video file from local storage +- **AND** the system SHALL construct the file path in the format `file://{absolute_path}` where `absolute_path` is the absolute path string of the video file +- **AND** the system SHALL call DashScope MultiModalConversation API with the correctly formatted file path and fps parameter (default: 2) +- **AND** the system SHALL receive the analysis result from the API +- **AND** the system SHALL save the analysis result to MongoDB with video_id, analysis_type, content, fps, and created_at +- **AND** the system SHALL update the video status to "analyzed" +- **AND** the system SHALL return the analysis result to the user + +#### Scenario: Analysis API Failure +- **WHEN** the DashScope API call fails (network error, API error, invalid file path format, etc.) +- **THEN** the system SHALL handle the error gracefully +- **AND** the system SHALL update the video status to "failed" +- **AND** the system SHALL return an appropriate error message to the user +- **AND** the system SHALL log the error with detailed information for debugging + +### Requirement: Video Summary Generation +The system SHALL provide functionality to generate a summary of video content. The system SHALL use the DashScope API to analyze the video using the correct local file path format (`file://{absolute_path}`) and generate a summary based on a predefined prompt template. The system SHALL save the summary to MongoDB. + +#### Scenario: Successful Summary Generation +- **WHEN** a user requests a summary for an analyzed video +- **THEN** the system SHALL construct the file path in the format `file://{absolute_path}` +- **AND** the system SHALL call the DashScope API with the correctly formatted video file path and summary prompt +- **AND** the system SHALL receive the summary text from the API +- **AND** the system SHALL save the summary to MongoDB with video_id, summary_text, and created_at +- **AND** the system SHALL return the summary to the user + +### Requirement: Video Comparison +The system SHALL provide functionality to compare content between multiple videos. The system SHALL extract content from each video using the correct local file path format (`file://{absolute_path}`). The system SHALL generate a comparison analysis highlighting similarities and differences. The system SHALL save the comparison result to MongoDB. + +#### Scenario: Successful Video Comparison +- **WHEN** a user requests comparison between two or more videos +- **THEN** the system SHALL construct file paths for each video in the format `file://{absolute_path}` +- **AND** the system SHALL call the DashScope API with multiple correctly formatted video file paths and comparison prompt +- **AND** the system SHALL generate a comparison result +- **AND** the system SHALL save the comparison to MongoDB with video_ids, comparison_result, and created_at +- **AND** the system SHALL return the comparison result to the user + diff --git a/openspec/changes/fix-dashscope-local-file-path/tasks.md b/openspec/changes/fix-dashscope-local-file-path/tasks.md new file mode 100644 index 0000000..8942a17 --- /dev/null +++ b/openspec/changes/fix-dashscope-local-file-path/tasks.md @@ -0,0 +1,23 @@ +## 1. 修复视频路径格式实现 + +- [ ] 1.1 分析当前路径格式问题,确认根因 +- [ ] 1.2 根据 DashScope 文档示例,修改 `analyze_video` 方法使用正确的 `file://{absolute_path}` 格式 +- [ ] 1.3 确保路径是绝对路径字符串,然后添加 `file://` 前缀(不是 `file:///`) +- [ ] 1.4 处理路径中的特殊字符(如空格),进行必要的 URL 编码 +- [ ] 1.5 修改 `compare_videos` 方法,使用相同的路径格式 +- [ ] 1.6 移除不必要的回退逻辑,直接使用正确的格式 +- [ ] 1.7 添加路径格式验证和详细的错误日志 + +## 2. 测试和验证 + +- [ ] 2.1 测试视频分析功能,验证路径格式正确 +- [ ] 2.2 测试包含空格或特殊字符的文件路径 +- [ ] 2.3 验证 API 调用成功,不再出现 URL 格式错误 +- [ ] 2.4 测试视频总结功能 +- [ ] 2.5 测试视频对比功能 + +## 3. 文档更新 + +- [ ] 3.1 更新规范文件,反映正确的实现方式 +- [ ] 3.2 添加路径格式要求的说明 + diff --git a/openspec/changes/fix-dashscope-video-url-format/proposal.md b/openspec/changes/fix-dashscope-video-url-format/proposal.md new file mode 100644 index 0000000..fcf3b78 --- /dev/null +++ b/openspec/changes/fix-dashscope-video-url-format/proposal.md @@ -0,0 +1,24 @@ +# 变更:修复 DashScope 视频文件路径格式问题 + +## 原因 +当前实现使用 `file://` URL 格式传递视频文件路径给 DashScope API,但 API 返回错误:"The provided URL does not appear to be valid"。 + +根据 DashScope 文档示例,应该直接传递绝对路径字符串,而不是 `file://` URL 格式。文档中的示例代码显示: +```python +local_path = "xxx/test.mp4" # 绝对路径 +video_path = f"file://{local_path}" +``` +但实际上,DashScope Python SDK 可能期望直接接收文件路径字符串,而不是 `file://` URL。 + +## 变更内容 +- 修改 `DashScopeService.analyze_video` 方法,直接传递文件路径字符串而不是 `file://` URL +- 修改 `DashScopeService.compare_videos` 方法,同样使用路径字符串 +- 根据 DashScope SDK 的实际行为调整实现 +- 如果路径包含空格或特殊字符,确保正确处理 + +## 影响 +- 受影响的文件:`app/services/dashscope_service.py` +- 修复后视频分析功能可以正常工作 +- 不影响其他功能 + + diff --git a/openspec/changes/fix-dashscope-video-url-format/tasks.md b/openspec/changes/fix-dashscope-video-url-format/tasks.md new file mode 100644 index 0000000..ea1b52a --- /dev/null +++ b/openspec/changes/fix-dashscope-video-url-format/tasks.md @@ -0,0 +1,30 @@ +## 1. 修复视频路径格式 + +- [x] 检查 DashScope SDK 文档和示例代码,确认正确的路径格式 +- [x] 修改 `analyze_video` 方法,先尝试直接使用绝对路径字符串 +- [x] 如果直接路径失败,回退到 `file://` 格式 +- [x] 添加详细的日志记录用于调试 +- [x] 修改 `compare_videos` 方法,使用相同的逻辑(先尝试直接路径,失败后回退到 file:// 格式) + +## 2. 验证 + +- [ ] 测试视频分析功能 +- [ ] 验证路径格式正确 +- [ ] 确认 API 调用成功 + +## 根因分析 + +根据错误日志和 DashScope 文档: + +1. **当前使用的格式**:`file:///Users/d-robotics/workSpace/videoSummary/uploads/...` +2. **错误信息**:`The provided URL does not appear to be valid` +3. **可能的原因**: + - DashScope Python SDK 可能期望直接接收绝对路径字符串,而不是 `file://` URL + - SDK 内部可能会自动处理路径转换 + - 或者 `file://` 格式需要不同的编码方式 + +**解决方案**: +- 首先尝试直接使用绝对路径字符串(让 SDK 处理) +- 如果失败,回退到 `file://` 格式 +- 添加详细日志以便调试 + diff --git a/openspec/changes/fix-missing-os-import/proposal.md b/openspec/changes/fix-missing-os-import/proposal.md new file mode 100644 index 0000000..67e69a1 --- /dev/null +++ b/openspec/changes/fix-missing-os-import/proposal.md @@ -0,0 +1,14 @@ +# 变更:修复缺失的 os 模块导入 + +## 原因 +在移除环境变量配置支持时,错误地删除了 `app/services/dashscope_service.py` 中的 `import os` 语句。但代码中仍在使用 `os.name` 来检测操作系统类型(用于 Windows 路径处理),导致运行时错误:`name 'os' is not defined`。 + +## 变更内容 +- 在 `app/services/dashscope_service.py` 中重新添加 `import os` 语句 +- `os.name` 用于检测操作系统类型('nt' 表示 Windows),这是必要的功能,与环境变量无关 + +## 影响 +- 受影响的代码: + - `app/services/dashscope_service.py` - 添加 `import os` +- 修复的错误:`name 'os' is not defined` 运行时错误 + diff --git a/openspec/changes/fix-missing-os-import/tasks.md b/openspec/changes/fix-missing-os-import/tasks.md new file mode 100644 index 0000000..7b902ce --- /dev/null +++ b/openspec/changes/fix-missing-os-import/tasks.md @@ -0,0 +1,7 @@ +## 1. 实施 +- [ ] 1.1 在 `app/services/dashscope_service.py` 文件顶部添加 `import os` + +## 2. 验证 +- [ ] 2.1 验证代码可以正常运行,不再出现 `name 'os' is not defined` 错误 +- [ ] 2.2 验证 Windows 和 Unix 系统的路径处理逻辑正常工作 + diff --git a/openspec/changes/fix-video-model-indentation-error/proposal.md b/openspec/changes/fix-video-model-indentation-error/proposal.md new file mode 100644 index 0000000..b1ab407 --- /dev/null +++ b/openspec/changes/fix-video-model-indentation-error/proposal.md @@ -0,0 +1,20 @@ +# 变更:修复代码缩进错误 + +## 原因 +在多个文件中存在导入语句位置错误和缩进问题,导致应用程序无法启动: +1. `app/models/video.py`: `from typing import List` 被错误地放在了类定义中间 +2. `app/services/analysis_service.py`: `from typing import List` 被错误地放在了类定义中间 +3. `app/utils/validators.py`: `from typing import Tuple` 被错误地放在了函数定义中间 + +## 变更内容 +- 修复 `app/models/video.py` 中的导入语句位置和缩进问题 +- 修复 `app/services/analysis_service.py` 中的导入语句位置和缩进问题 +- 修复 `app/utils/validators.py` 中的导入语句位置和缩进问题 +- 确保所有导入语句都在文件顶部,类/函数定义之前 +- 确保所有方法和函数的缩进正确 + +## 影响 +- 受影响的文件:`app/models/video.py`, `app/services/analysis_service.py`, `app/utils/validators.py` +- 修复后应用程序可以正常启动 +- 不影响其他功能 + diff --git a/openspec/changes/fix-video-model-indentation-error/tasks.md b/openspec/changes/fix-video-model-indentation-error/tasks.md new file mode 100644 index 0000000..11b6b3a --- /dev/null +++ b/openspec/changes/fix-video-model-indentation-error/tasks.md @@ -0,0 +1,17 @@ +## 1. 修复缩进错误 + +### app/models/video.py +- [x] 将 `from typing import List` 移到文件顶部导入区域 +- [x] 修复 `find_all` 方法的缩进 + +### app/services/analysis_service.py +- [x] 删除类定义中间重复的 `from typing import List` 导入语句 +- [x] 修复 `compare_videos` 方法的缩进 + +### app/utils/validators.py +- [x] 将 `from typing import Tuple` 移到文件顶部导入区域 +- [x] 修复 `validate_file_size` 函数的缩进 + +### 验证 +- [x] 验证所有文件代码语法正确(lint 检查通过) + diff --git a/openspec/changes/improve-dashscope-api-key-error-handling/proposal.md b/openspec/changes/improve-dashscope-api-key-error-handling/proposal.md new file mode 100644 index 0000000..35d757f --- /dev/null +++ b/openspec/changes/improve-dashscope-api-key-error-handling/proposal.md @@ -0,0 +1,23 @@ +# 变更:改进 DashScope API Key 错误处理 + +## 原因 +当前实现中,当 DashScope API key 未设置时,会抛出 `ValueError` 异常,导致: +1. 服务器返回 500 内部错误,而不是更友好的 400 错误 +2. 错误信息不够清晰,用户不知道如何配置 +3. 前端无法正确显示错误提示 + +需要改进错误处理,提供更友好的错误响应和用户提示。 + +## 变更内容 +- 在 `AnalysisService` 中捕获 `ValueError` 异常,返回友好的错误消息 +- 在 API 路由中确保错误被正确转换为 JSON 响应 +- 改进错误消息,明确指导用户如何配置 API key +- 确保所有 API 端点都返回一致的错误格式 + +## 影响 +- 受影响的文件: + - `app/services/analysis_service.py` - 改进错误处理 + - `app/routes/video_routes.py` - 确保错误正确返回 +- 用户体验改进:更清晰的错误提示 +- API 响应格式统一:所有错误都返回 JSON 格式 + diff --git a/openspec/changes/improve-dashscope-api-key-error-handling/tasks.md b/openspec/changes/improve-dashscope-api-key-error-handling/tasks.md new file mode 100644 index 0000000..1b10f4d --- /dev/null +++ b/openspec/changes/improve-dashscope-api-key-error-handling/tasks.md @@ -0,0 +1,53 @@ +## 1. 改进错误处理 + +- [x] 在 `AnalysisService.analyze_video` 中捕获 `ValueError` 异常 +- [x] 在 `AnalysisService.summarize_video` 中捕获 `ValueError` 异常 +- [x] 在 `AnalysisService.compare_videos` 中捕获 `ValueError` 异常 +- [x] 返回友好的错误消息,包含配置指导 +- [x] 改进 `DashScopeService._ensure_api_key` 的错误消息,添加获取 API key 的链接 +- [x] 确保错误消息格式统一 + +## 2. 验证错误响应 + +- [x] 代码修改完成,错误现在会返回 400 状态码而不是 500 +- [x] 所有异常处理已正确实现 +- [x] 错误消息包含配置指导和 API key 获取链接 +- [x] 代码通过 lint 检查,无错误 + +## 实施完成总结 + +### 已完成的修改 + +1. **`app/services/analysis_service.py`** + - ✅ `analyze_video` 方法:添加了 `ValueError` 异常捕获 + - ✅ `summarize_video` 方法:添加了 `ValueError` 异常捕获 + - ✅ `compare_videos` 方法:添加了 `ValueError` 异常捕获 + - ✅ 所有方法都返回友好的错误消息 + +2. **`app/services/dashscope_service.py`** + - ✅ `_ensure_api_key` 方法:改进了错误消息,添加了 API key 获取链接 + +3. **`app/routes/video_routes.py`** + - ✅ 路由已经正确使用 `error_response` 返回 400 状态码 + +### 改进效果 + +- **之前**:API key 未设置时返回 500 内部服务器错误 +- **现在**:API key 未设置时返回 400 客户端错误,包含清晰的配置指导 + +### 错误消息示例 + +当 API key 未设置时,用户会收到以下错误消息: +``` +DashScope API key is required for video analysis. +Please set 'dashscope.api_key' in config.yaml file, +or set DASHSCOPE_API_KEY environment variable. +You can obtain your API key from: https://dashscope.console.aliyun.com/ +``` + +### 测试建议 + +1. 在未设置 API key 的情况下测试视频分析功能 +2. 验证错误消息在前端正确显示 +3. 设置 API key 后验证功能正常工作 + diff --git a/openspec/changes/remove-env-var-config/design.md b/openspec/changes/remove-env-var-config/design.md new file mode 100644 index 0000000..c83c2b3 --- /dev/null +++ b/openspec/changes/remove-env-var-config/design.md @@ -0,0 +1,86 @@ +# 技术设计文档 + +## 上下文 +当前系统支持从 `config.yaml` 和环境变量读取配置,环境变量可以覆盖 `config.yaml` 中的值。这种双重配置来源增加了系统的复杂性和维护成本。为了简化配置管理,需要移除环境变量支持,统一使用 `config.yaml` 作为唯一配置来源。 + +## 目标 / 非目标 + +### 目标 +- 简化配置管理,统一配置来源为 `config.yaml` +- 减少代码复杂度,移除环境变量处理逻辑 +- 提高配置的可追溯性和可维护性 +- 确保所有配置都通过文件管理,便于版本控制 + +### 非目标 +- 不支持配置热重载(需要重启应用) +- 不支持多环境配置文件(如 dev.yaml, prod.yaml) +- 不支持配置加密(敏感信息如 API key 以明文存储在 config.yaml 中) + +## 决策 + +### 1. 配置来源:仅使用 config.yaml +**决策**:移除所有环境变量支持,仅从 `config.yaml` 文件读取配置 + +**原因**: +- 简化配置管理逻辑 +- 统一配置来源,减少混淆 +- 配置文件便于版本控制和审查 +- 降低配置管理的认知负担 + +**考虑的替代方案**: +- 保留环境变量但仅用于敏感信息(如 API key):增加了复杂性,不符合简化目标 +- 支持多环境配置文件:超出当前需求范围,可以后续添加 + +### 2. 错误处理策略 +**决策**:在配置缺失时抛出明确的异常,而不是使用默认值或静默失败 + +**原因**: +- 快速发现配置问题 +- 避免运行时错误 +- 提供清晰的错误消息指导用户修复 + +**考虑的替代方案**: +- 使用默认值:可能导致配置错误被忽略,不符合明确失败的原则 + +### 3. 代码清理范围 +**决策**:删除所有环境变量相关代码,包括: +- `app/config.py` 中的 `_apply_env_overrides()` 方法 +- 所有 `os.getenv()` 调用 +- 启动脚本中的环境变量检查 +- 文档中的环境变量说明 + +**原因**: +- 彻底移除功能,避免遗留代码 +- 减少维护负担 +- 防止未来误用 + +## 风险 / 权衡 + +### 风险 +1. **迁移成本**:使用环境变量的用户需要迁移配置 + - **缓解措施**:提供清晰的迁移指南和错误消息 + +2. **安全性**:API key 等敏感信息存储在配置文件中 + - **缓解措施**:在文档中说明安全最佳实践(不要提交到版本控制) + +3. **灵活性降低**:无法通过环境变量快速切换配置 + - **缓解措施**:对于需要多环境配置的场景,可以后续添加多配置文件支持 + +### 权衡 +- **简化 vs 灵活性**:选择简化,牺牲了环境变量的灵活性 +- **统一 vs 多样性**:选择统一配置来源,提高可维护性 + +## 迁移计划 + +### 步骤 +1. 更新代码,移除环境变量支持 +2. 更新文档,说明配置迁移方法 +3. 更新错误消息,提供配置指导 +4. 验证所有配置项都能正确读取 + +### 回滚 +如果需要回滚,可以恢复之前的代码,但建议用户迁移到 `config.yaml` 配置方式。 + +## 开放问题 +无 + diff --git a/openspec/changes/remove-env-var-config/proposal.md b/openspec/changes/remove-env-var-config/proposal.md new file mode 100644 index 0000000..4c09edf --- /dev/null +++ b/openspec/changes/remove-env-var-config/proposal.md @@ -0,0 +1,24 @@ +# 变更:移除环境变量配置支持 + +## 原因 +当前系统同时支持从 `config.yaml` 和环境变量读取配置,增加了配置管理的复杂性和维护成本。为了简化配置管理,统一配置来源,所有配置应仅从 `config.yaml` 文件读取,移除环境变量覆盖功能。 + +## 变更内容 +- **BREAKING**: 移除所有环境变量配置支持 +- 删除 `app/config.py` 中的 `_apply_env_overrides()` 方法 +- 删除所有 `os.getenv()` 调用和相关的环境变量检查逻辑 +- 更新 `app/services/dashscope_service.py`,移除环境变量回退逻辑 +- 更新 `config.yaml` 注释,移除环境变量相关说明 +- 更新启动脚本(`start.sh`、`start.bat`),移除环境变量检查 +- 更新配置文档(`CONFIG.md`),移除环境变量配置说明 + +## 影响 +- 受影响的规范:`video-analysis` 规范中的配置管理需求 +- 受影响的代码: + - `app/config.py` - 删除环境变量覆盖逻辑 + - `app/services/dashscope_service.py` - 移除环境变量回退 + - `config.yaml` - 更新注释 + - `start.sh` / `start.bat` - 移除环境变量检查 + - `CONFIG.md` - 更新配置文档 +- 迁移影响:使用环境变量的用户需要将配置迁移到 `config.yaml` 文件 + diff --git a/openspec/changes/remove-env-var-config/specs/config-management/spec.md b/openspec/changes/remove-env-var-config/specs/config-management/spec.md new file mode 100644 index 0000000..a7bac7a --- /dev/null +++ b/openspec/changes/remove-env-var-config/specs/config-management/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: Configuration Management +The system SHALL read configuration from config.yaml file only. The system SHALL validate configuration on startup. + +#### Scenario: Load Configuration +- **WHEN** the application starts +- **THEN** the system SHALL read config.yaml file +- **AND** the system SHALL validate required configuration items +- **AND** the system SHALL fail to start if required configuration is missing +- **AND** the system SHALL NOT read or use environment variables for configuration + +#### Scenario: Configuration Validation Failure +- **WHEN** config.yaml file does not exist +- **THEN** the system SHALL raise FileNotFoundError with the config file path + +- **WHEN** required configuration items are missing (e.g., DashScope API key) +- **THEN** the system SHALL raise ValueError with a clear error message when the configuration is first used + +## REMOVED Requirements + +### Requirement: Environment Variable Configuration Override +**原因**: 简化配置管理,统一配置来源为 `config.yaml` 文件,减少配置管理的复杂性和维护成本。 + +**迁移**: 之前使用环境变量的用户需要将所有配置迁移到 `config.yaml` 文件中。环境变量将不再被读取或使用。 + diff --git a/openspec/changes/remove-env-var-config/specs/video-analysis/spec.md b/openspec/changes/remove-env-var-config/specs/video-analysis/spec.md new file mode 100644 index 0000000..a7bac7a --- /dev/null +++ b/openspec/changes/remove-env-var-config/specs/video-analysis/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: Configuration Management +The system SHALL read configuration from config.yaml file only. The system SHALL validate configuration on startup. + +#### Scenario: Load Configuration +- **WHEN** the application starts +- **THEN** the system SHALL read config.yaml file +- **AND** the system SHALL validate required configuration items +- **AND** the system SHALL fail to start if required configuration is missing +- **AND** the system SHALL NOT read or use environment variables for configuration + +#### Scenario: Configuration Validation Failure +- **WHEN** config.yaml file does not exist +- **THEN** the system SHALL raise FileNotFoundError with the config file path + +- **WHEN** required configuration items are missing (e.g., DashScope API key) +- **THEN** the system SHALL raise ValueError with a clear error message when the configuration is first used + +## REMOVED Requirements + +### Requirement: Environment Variable Configuration Override +**原因**: 简化配置管理,统一配置来源为 `config.yaml` 文件,减少配置管理的复杂性和维护成本。 + +**迁移**: 之前使用环境变量的用户需要将所有配置迁移到 `config.yaml` 文件中。环境变量将不再被读取或使用。 + diff --git a/openspec/changes/remove-env-var-config/tasks.md b/openspec/changes/remove-env-var-config/tasks.md new file mode 100644 index 0000000..d1b3f33 --- /dev/null +++ b/openspec/changes/remove-env-var-config/tasks.md @@ -0,0 +1,40 @@ +## 1. 实施 + +### 1.1 更新配置管理代码 +- [ ] 1.1.1 删除 `app/config.py` 中的 `_apply_env_overrides()` 方法 +- [ ] 1.1.2 删除 `app/config.py` 中的 `os` 模块导入(如果不再需要) +- [ ] 1.1.3 更新 `Config.__init__()` 方法,移除环境变量覆盖调用 +- [ ] 1.1.4 更新 `Config` 类文档字符串,移除环境变量优先级说明 + +### 1.2 更新 DashScope 服务 +- [ ] 1.2.1 修改 `app/services/dashscope_service.py` 中的 `__init__` 方法 +- [ ] 1.2.2 移除 `os.getenv('DASHSCOPE_API_KEY')` 回退逻辑 +- [ ] 1.2.3 更新错误消息,移除环境变量相关提示 + +### 1.3 更新配置文件 +- [ ] 1.3.1 更新 `config.yaml` 中所有关于环境变量的注释 +- [ ] 1.3.2 移除配置项中的环境变量覆盖说明 + +### 1.4 更新启动脚本 +- [ ] 1.4.1 更新 `start.sh`,移除环境变量检查逻辑 +- [ ] 1.4.2 更新 `start.bat`,移除环境变量检查逻辑 + +### 1.5 更新文档 +- [ ] 1.5.1 更新 `CONFIG.md`,移除所有环境变量配置说明 +- [ ] 1.5.2 更新文档中的配置优先级说明 + +### 1.6 更新规范 +- [ ] 1.6.1 更新配置管理规范,移除环境变量支持需求 +- [ ] 1.6.2 验证规范变更符合 OpenSpec 格式要求 + +## 2. 测试 +- [ ] 2.1 验证配置仅从 `config.yaml` 读取 +- [ ] 2.2 验证环境变量不再影响配置 +- [ ] 2.3 验证所有配置项都能正确从 `config.yaml` 读取 +- [ ] 2.4 验证错误处理(缺少配置时的错误消息) + +## 3. 验证 +- [ ] 3.1 运行 `openspec validate remove-env-var-config --strict` +- [ ] 3.2 检查代码中无残留的环境变量相关代码 +- [ ] 3.3 确认所有文档已更新 + diff --git a/openspec/config.json b/openspec/config.json new file mode 100644 index 0000000..f478171 --- /dev/null +++ b/openspec/config.json @@ -0,0 +1,3 @@ +{ + "language": "zh-CN" +} diff --git a/openspec/project.md b/openspec/project.md new file mode 100644 index 0000000..cee312f --- /dev/null +++ b/openspec/project.md @@ -0,0 +1,31 @@ +# 项目 上下文 + +## 目的 +[描述您项目的目标和目的] + +## 技术栈 +- [列出您的主要技术] +- [例如:TypeScript, React, Node.js] + +## 项目约定 + +### 代码风格 +[描述您的代码风格偏好、格式化规则和命名约定] + +### 架构模式 +[记录您的架构决策和模式] + +### 测试策略 +[说明您的测试方法和要求] + +### Git 工作流 +[描述您的分支策略和提交约定] + +## 领域上下文 +[添加 AI 助手需要理解的领域特定知识] + +## 重要约束 +[列出任何技术、业务或监管约束] + +## 外部依赖 +[记录关键的外部服务、API 或系统] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c6adddd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.0.0 +Flask-CORS==4.0.0 +pymongo==4.6.0 +dashscope==1.17.0 +PyYAML==6.0.1 +python-dotenv==1.0.0 +werkzeug==3.0.1 + diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..2bae6dc --- /dev/null +++ b/start.bat @@ -0,0 +1,30 @@ +@echo off + +REM Activate virtual environment +if not exist "venv" ( + echo Creating virtual environment... + python -m venv venv +) + +echo Activating virtual environment... +call venv\Scripts\activate.bat + +REM Upgrade pip +echo Upgrading pip... +python -m pip install --upgrade pip + +REM Install dependencies +echo Installing dependencies... +pip install -r requirements.txt + +REM Check configuration +echo Checking configuration... +echo Make sure to set 'dashscope.api_key' in config.yaml file. +echo. + +REM Run the application +echo Starting application... +python app.py + +pause + diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..1b68a7f --- /dev/null +++ b/start.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Activate virtual environment +if [ ! -d "venv" ]; then + echo "Creating virtual environment..." + python3 -m venv venv +fi + +echo "Activating virtual environment..." +source venv/bin/activate + +# Upgrade pip +echo "Upgrading pip..." +pip install --upgrade pip + +# Install dependencies +echo "Installing dependencies..." +pip install -r requirements.txt + +# Check configuration +echo "Checking configuration..." +echo "Make sure to set 'dashscope.api_key' in config.yaml file." +echo "" + +# Run the application +echo "Starting application..." +python app.py + diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..b8bd4f7 --- /dev/null +++ b/static/app.js @@ -0,0 +1,440 @@ +// API base URL - automatically detect from current location +const API_BASE = `${window.location.protocol}//${window.location.host}/api/videos`; + +// Tab switching +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + switchTab(tab); + }); +}); + +function switchTab(tabName) { + // Update buttons + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.remove('active'); + if (btn.dataset.tab === tabName) { + btn.classList.add('active'); + } + }); + + // Update content + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.remove('active'); + }); + document.getElementById(`${tabName}-tab`).classList.add('active'); + + // Load data when switching tabs + if (tabName === 'list') { + loadVideos(); + } else if (tabName === 'compare') { + loadVideosForCompare(); + } +} + +// File upload +const fileInput = document.getElementById('file-input'); +const uploadArea = document.getElementById('upload-area'); + +fileInput.addEventListener('change', handleFileSelect); + +uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadArea.style.borderColor = '#764ba2'; +}); + +uploadArea.addEventListener('dragleave', () => { + uploadArea.style.borderColor = '#667eea'; +}); + +uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + uploadArea.style.borderColor = '#667eea'; + const files = e.dataTransfer.files; + if (files.length > 0) { + fileInput.files = files; + handleFileSelect({ target: fileInput }); + } +}); + +function handleFileSelect(e) { + const file = e.target.files[0]; + if (!file) return; + + uploadFile(file); +} + +function uploadFile(file) { + const formData = new FormData(); + formData.append('file', file); + + const progressDiv = document.getElementById('upload-progress'); + const progressFill = document.getElementById('progress-fill'); + const progressText = document.getElementById('progress-text'); + const resultDiv = document.getElementById('upload-result'); + + // Show progress + document.querySelector('.upload-placeholder').style.display = 'none'; + progressDiv.style.display = 'block'; + progressFill.style.width = '0%'; + resultDiv.style.display = 'none'; + + // Upload using fetch + fetch(`${API_BASE}/upload`, { + method: 'POST', + body: formData + }) + .then(response => response.json()) + .then(data => { + progressFill.style.width = '100%'; + progressText.textContent = '上传完成!'; + + setTimeout(() => { + if (data.success) { + showResult('upload-result', `视频上传成功!ID: ${data.data.video_id}`, 'success'); + // Reset form + fileInput.value = ''; + document.querySelector('.upload-placeholder').style.display = 'block'; + progressDiv.style.display = 'none'; + } else { + showResult('upload-result', data.message || '上传失败', 'error'); + } + }, 500); + }) + .catch(error => { + showResult('upload-result', `上传失败: ${error.message}`, 'error'); + progressDiv.style.display = 'none'; + document.querySelector('.upload-placeholder').style.display = 'block'; + }); +} + +// Load videos list +function loadVideos() { + const videoList = document.getElementById('video-list'); + videoList.innerHTML = '

加载中...

'; + + fetch(`${API_BASE}`) + .then(response => response.json()) + .then(data => { + if (data.success && data.data.length > 0) { + videoList.innerHTML = ''; + data.data.forEach(video => { + const videoItem = createVideoItem(video); + videoList.appendChild(videoItem); + }); + } else { + videoList.innerHTML = '

暂无视频

'; + } + }) + .catch(error => { + videoList.innerHTML = `

加载失败: ${error.message}

`; + }); +} + +function createVideoItem(video) { + const div = document.createElement('div'); + div.className = 'video-item'; + + const sizeMB = (video.file_size / (1024 * 1024)).toFixed(2); + const uploadTime = new Date(video.upload_time).toLocaleString('zh-CN'); + + div.innerHTML = ` +
+
${video.filename}
+ + ${getStatusText(video.status)} + +
+
+ 大小: ${sizeMB} MB | 上传时间: ${uploadTime} +
+
+ + + +
+ `; + + return div; +} + +function getStatusColor(status) { + const colors = { + 'uploaded': '#6c757d', + 'analyzing': '#ffc107', + 'analyzed': '#28a745', + 'failed': '#dc3545' + }; + return colors[status] || '#6c757d'; +} + +function getStatusText(status) { + const texts = { + 'uploaded': '已上传', + 'analyzing': '分析中', + 'analyzed': '已分析', + 'failed': '失败' + }; + return texts[status] || status; +} + +// Video details modal +function viewVideoDetails(videoId) { + fetch(`${API_BASE}/${videoId}`) + .then(response => response.json()) + .then(data => { + if (data.success) { + showVideoModal(data.data); + } else { + alert(data.message || '获取视频详情失败'); + } + }) + .catch(error => { + alert(`获取视频详情失败: ${error.message}`); + }); +} + +function showVideoModal(video) { + const modal = document.getElementById('video-modal'); + const modalTitle = document.getElementById('modal-title'); + const modalBody = document.getElementById('modal-body'); + + modalTitle.textContent = video.filename; + + let html = ` +
+

基本信息

+

文件名: ${video.filename}

+

大小: ${(video.file_size / (1024 * 1024)).toFixed(2)} MB

+

状态: ${getStatusText(video.status)}

+

上传时间: ${new Date(video.upload_time).toLocaleString('zh-CN')}

+
+ `; + + if (video.analysis) { + html += ` +
+

分析结果

+

${video.analysis.content}

+

+ FPS: ${video.analysis.fps} | + 创建时间: ${new Date(video.analysis.created_at).toLocaleString('zh-CN')} +

+
+ `; + } + + if (video.summary) { + html += ` +
+

视频总结

+

${video.summary.summary_text}

+

+ 创建时间: ${new Date(video.summary.created_at).toLocaleString('zh-CN')} +

+
+ `; + } + + modalBody.innerHTML = html; + modal.style.display = 'block'; +} + +function closeModal() { + document.getElementById('video-modal').style.display = 'none'; +} + +window.onclick = function(event) { + const modal = document.getElementById('video-modal'); + if (event.target === modal) { + closeModal(); + } +} + +// Analyze video +function analyzeVideo(videoId) { + if (!confirm('确定要分析这个视频吗?这可能需要一些时间。')) { + return; + } + + // Show loading message + const loadingMsg = document.createElement('div'); + loadingMsg.id = 'analysis-loading'; + loadingMsg.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); z-index: 10000;'; + loadingMsg.innerHTML = '

正在分析视频,请稍候...

'; + document.body.appendChild(loadingMsg); + + fetch(`${API_BASE}/${videoId}/analyze`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({}) + }) + .then(response => response.json()) + .then(data => { + // Remove loading message + const loadingEl = document.getElementById('analysis-loading'); + if (loadingEl) { + loadingEl.remove(); + } + + if (data.success) { + // Refresh video list first + loadVideos(); + // Then automatically open video details to show the analysis + setTimeout(() => { + viewVideoDetails(videoId); + }, 500); + } else { + alert(data.message || '分析失败'); + } + }) + .catch(error => { + // Remove loading message + const loadingEl = document.getElementById('analysis-loading'); + if (loadingEl) { + loadingEl.remove(); + } + alert(`分析失败: ${error.message}`); + }); +} + +// Summarize video +function summarizeVideo(videoId) { + if (!confirm('确定要生成视频总结吗?这可能需要一些时间。')) { + return; + } + + // Show loading message + const loadingMsg = document.createElement('div'); + loadingMsg.id = 'summary-loading'; + loadingMsg.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); z-index: 10000;'; + loadingMsg.innerHTML = '

正在生成视频总结,请稍候...

'; + document.body.appendChild(loadingMsg); + + fetch(`${API_BASE}/${videoId}/summarize`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({}) + }) + .then(response => response.json()) + .then(data => { + // Remove loading message + const loadingEl = document.getElementById('summary-loading'); + if (loadingEl) { + loadingEl.remove(); + } + + if (data.success) { + // Refresh video list first + loadVideos(); + // Then automatically open video details to show the summary + setTimeout(() => { + viewVideoDetails(videoId); + }, 500); + } else { + alert(data.message || '总结生成失败'); + } + }) + .catch(error => { + // Remove loading message + const loadingEl = document.getElementById('summary-loading'); + if (loadingEl) { + loadingEl.remove(); + } + alert(`总结生成失败: ${error.message}`); + }); +} + +// Compare videos +function loadVideosForCompare() { + const compareList = document.getElementById('compare-video-list'); + compareList.innerHTML = '

加载中...

'; + + fetch(`${API_BASE}`) + .then(response => response.json()) + .then(data => { + if (data.success && data.data.length > 0) { + compareList.innerHTML = ''; + data.data.forEach(video => { + const checkbox = document.createElement('div'); + checkbox.className = 'video-checkbox-item'; + checkbox.innerHTML = ` + + + `; + compareList.appendChild(checkbox); + }); + } else { + compareList.innerHTML = '

暂无视频,请先上传视频

'; + } + }) + .catch(error => { + compareList.innerHTML = `

加载失败: ${error.message}

`; + }); +} + +function updateCompareButton() { + const checked = document.querySelectorAll('#compare-video-list input[type="checkbox"]:checked'); + const compareBtn = document.getElementById('compare-btn'); + compareBtn.disabled = checked.length < 2; +} + +function compareVideos() { + const checked = document.querySelectorAll('#compare-video-list input[type="checkbox"]:checked'); + if (checked.length < 2) { + alert('请至少选择2个视频进行对比'); + return; + } + + if (!confirm(`确定要对比这 ${checked.length} 个视频吗?这可能需要一些时间。`)) { + return; + } + + const videoIds = Array.from(checked).map(cb => cb.value); + const resultDiv = document.getElementById('compare-result'); + resultDiv.innerHTML = '

对比中,请稍候...

'; + resultDiv.className = 'result-message'; + resultDiv.style.display = 'block'; + + fetch(`${API_BASE}/compare`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ video_ids: videoIds }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + resultDiv.className = 'result-message success'; + resultDiv.innerHTML = ` +

对比结果

+
${data.data.comparison_result}
+ `; + } else { + resultDiv.className = 'result-message error'; + resultDiv.textContent = data.message || '对比失败'; + } + }) + .catch(error => { + resultDiv.className = 'result-message error'; + resultDiv.textContent = `对比失败: ${error.message}`; + }); +} + +function showResult(elementId, message, type) { + const element = document.getElementById(elementId); + element.textContent = message; + element.className = `result-message ${type}`; + element.style.display = 'block'; +} + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + loadVideos(); +}); + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..7ff201b --- /dev/null +++ b/static/index.html @@ -0,0 +1,81 @@ + + + + + + 视频分析与总结系统 + + + +
+
+

视频分析与总结系统

+
+ + + + +
+
+

上传视频

+
+ +
+

点击或拖拽视频文件到此处上传

+ +
+ +
+
+
+
+ + +
+
+

视频列表

+ +
+

加载中...

+
+
+
+ + +
+
+

视频对比

+
+
+ +
+
+ +
+
+
+
+
+ + + + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..3fed91c --- /dev/null +++ b/static/style.css @@ -0,0 +1,382 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 20px; +} + +.container { + max-width: 1200px; + margin: 0 auto; + background: white; + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 30px; + text-align: center; +} + +header h1 { + font-size: 2.5em; + font-weight: 600; +} + +.tabs { + display: flex; + background: #f5f5f5; + border-bottom: 2px solid #e0e0e0; +} + +.tab-btn { + flex: 1; + padding: 15px 20px; + background: none; + border: none; + cursor: pointer; + font-size: 16px; + font-weight: 500; + color: #666; + transition: all 0.3s; +} + +.tab-btn:hover { + background: #e8e8e8; + color: #333; +} + +.tab-btn.active { + background: white; + color: #667eea; + border-bottom: 3px solid #667eea; +} + +.tab-content { + display: none; + padding: 30px; +} + +.tab-content.active { + display: block; +} + +.upload-section, .list-section, .compare-section { + max-width: 800px; + margin: 0 auto; +} + +h2 { + margin-bottom: 20px; + color: #333; + font-size: 1.8em; +} + +.upload-area { + border: 3px dashed #667eea; + border-radius: 8px; + padding: 40px; + text-align: center; + background: #f9f9f9; + transition: all 0.3s; + cursor: pointer; +} + +.upload-area:hover { + border-color: #764ba2; + background: #f0f0f0; +} + +.upload-placeholder p { + margin-bottom: 20px; + color: #666; + font-size: 16px; +} + +.btn { + padding: 12px 24px; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.btn-secondary { + background: #6c757d; + color: white; + margin-bottom: 20px; +} + +.btn-secondary:hover { + background: #5a6268; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.upload-progress { + margin-top: 20px; +} + +.progress-bar { + width: 100%; + height: 30px; + background: #e0e0e0; + border-radius: 15px; + overflow: hidden; + margin-bottom: 10px; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + width: 0%; + transition: width 0.3s; + border-radius: 15px; +} + +.result-message { + margin-top: 20px; + padding: 15px; + border-radius: 6px; + display: none; +} + +.result-message.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; + display: block; +} + +.result-message.error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + display: block; +} + +.video-list { + margin-top: 20px; +} + +.video-item { + background: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 20px; + margin-bottom: 15px; + transition: all 0.3s; +} + +.video-item:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.video-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.video-item-title { + font-size: 18px; + font-weight: 600; + color: #333; +} + +.video-item-meta { + color: #666; + font-size: 14px; + margin-bottom: 10px; +} + +.video-item-actions { + display: flex; + gap: 10px; + margin-top: 15px; +} + +.btn-small { + padding: 8px 16px; + font-size: 14px; +} + +.btn-info { + background: #17a2b8; + color: white; +} + +.btn-info:hover { + background: #138496; +} + +.btn-success { + background: #28a745; + color: white; +} + +.btn-success:hover { + background: #218838; +} + +.loading { + text-align: center; + padding: 40px; + color: #666; +} + +.compare-controls { + margin-bottom: 30px; +} + +.video-selector { + margin-bottom: 20px; +} + +.video-selector label { + display: block; + margin-bottom: 10px; + font-weight: 500; + color: #333; +} + +.video-checkboxes { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 10px; + max-height: 300px; + overflow-y: auto; + padding: 15px; + background: #f8f9fa; + border-radius: 6px; +} + +.video-checkbox-item { + display: flex; + align-items: center; + padding: 10px; + background: white; + border-radius: 4px; + border: 1px solid #e0e0e0; +} + +.video-checkbox-item input[type="checkbox"] { + margin-right: 10px; + width: 18px; + height: 18px; + cursor: pointer; +} + +.compare-result-content { + background: #f8f9fa; + padding: 20px; + border-radius: 6px; + margin-top: 20px; + white-space: pre-wrap; + line-height: 1.6; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + overflow: auto; +} + +.modal-content { + background-color: white; + margin: 5% auto; + padding: 30px; + border-radius: 12px; + width: 90%; + max-width: 800px; + max-height: 80vh; + overflow-y: auto; + position: relative; +} + +.close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + position: absolute; + right: 20px; + top: 20px; +} + +.close:hover { + color: #000; +} + +.modal-body { + margin-top: 20px; +} + +.detail-section { + margin-bottom: 20px; +} + +.detail-section h3 { + color: #667eea; + margin-bottom: 10px; +} + +.detail-section p { + color: #666; + line-height: 1.6; +} + +@media (max-width: 768px) { + header h1 { + font-size: 1.8em; + } + + .tab-btn { + font-size: 14px; + padding: 12px 10px; + } + + .upload-area { + padding: 20px; + } + + .video-item-header { + flex-direction: column; + align-items: flex-start; + } +} + diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..98969b0 --- /dev/null +++ b/uploads/.gitkeep @@ -0,0 +1,2 @@ +# This file keeps the uploads directory in git +