2026-04-16-20-46-50 - 压缩关键帧分辨率并增加存储错误日志,修复 LocalStorage 超限导致的关键帧丢失

This commit is contained in:
2026-04-16 20:49:07 +08:00
parent 39ef4d0b19
commit 632fc8175e
6 changed files with 259 additions and 11 deletions

View File

@@ -456,18 +456,20 @@ export default function ReportEditor() {
if (!videoRef.current || !canvasRef.current || currentVideoIndex === -1) return;
const video = videoRef.current;
const canvas = canvasRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const MAX_WIDTH = 800;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
const ctx = canvas.getContext('2d');
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
const newFrame: CapturedFrame = {
id: Date.now(),
videoIndex: currentVideoIndex,
videoName: videos[currentVideoIndex].name,
time: video.currentTime,
timeFormatted: formatTime(video.currentTime),
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
dataUrl: canvas.toDataURL('image/jpeg', 0.6),
isManual: true
};
const nextFrames = [...capturedFrames, newFrame].sort((a, b) => a.time - b.time);
@@ -503,8 +505,10 @@ export default function ReportEditor() {
};
video.addEventListener('seeked', onSeeked);
});
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const MAX_WIDTH = 800;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const newFrame: CapturedFrame = {
id: Date.now() + Math.random(),
@@ -512,7 +516,7 @@ export default function ReportEditor() {
videoName: videos[currentVideoIndex].name,
time,
timeFormatted: formatTime(time),
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
dataUrl: canvas.toDataURL('image/jpeg', 0.6),
isManual: false
};
accumulatedFrames = [...accumulatedFrames, newFrame].sort((a, b) => a.time - b.time);

View File

@@ -11,8 +11,8 @@ export const storage = {
set<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// ignore quota exceeded
} catch (e) {
console.error('Storage save failed (possibly quota exceeded):', e);
}
},
@@ -32,8 +32,8 @@ export const storage = {
setSession<T>(key: string, value: T): void {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
// ignore
} catch (e) {
console.error('Session storage save failed:', e);
}
},

View File

@@ -0,0 +1,105 @@
# 实现方案 — 2026-04-16-20-46-50
## 根因分析
### 1. LocalStorage 容量超限QuotaExceededError
浏览器对单个域名的 `localStorage` 通常有 **5MB** 的严格容量限制。
当前代码在抽帧时使用了视频的原始分辨率:
```tsx
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
```
如果上传的是 1080p 甚至 4K 视频:
- 单张 0.9 质量 JPEG Base64 图片可能达到 **300KB ~ 1MB**
- 自动提取 12 张关键帧 + 手动截图若干张,总数据量可能达到 **5MB ~ 10MB**
- 直接超过 `localStorage` 的 5MB 上限。
### 2. 静默失败
`src/utils/storage.ts` 中的 `set` 方法:
```typescript
set<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// ignore quota exceeded
}
}
```
当数据量超过 5MB 时,`localStorage.setItem` 抛出 `QuotaExceededError`,但被 `catch` 静默吞掉。
**实际发生的过程:**
1. 用户上传视频 → 此时 `videos` 数组中的 `url``blob:http://...`(短字符串),数据量很小,**保存成功**
2. 系统开始自动抽帧,生成巨大的 Base64 `dataUrl` 数组;
3. 调用 `saveDraftToStorage()` 尝试保存时,`localStorage.setItem` 触发超限报错;
4. 异常被 `catch` 忽略,**draft 没有被更新**(或更新失败);
5. 当用户离开页面再返回时localStorage 中读到的 draft 仍然停留在"仅有视频、没有关键帧"的状态。
这就是为什么:编辑器内容保留了,视频保留了,但**关键帧全部消失**。
## 修改方向
### 方向一:压缩关键帧分辨率与质量(快速修复,推荐优先执行)
关键帧只是用于插入报告的缩略图,通常不需要 4K 原画质。可以:
1. 设定最大宽度(如 800px等比缩放 Canvas
2. 将 JPEG 导出质量从 `0.9` 降到 `0.5 ~ 0.6`
3. 这样单张图片体积可从 500KB 压缩到 30KB~80KB十几张关键帧总计不到 1MB远低于 5MB 限制。
**修改点:**
- `captureFrame()`(手动截图)
- `autoCaptureFrames()`(自动抽帧)
### 方向二:增加存储超限的可见性
`storage.ts` 中不再静默吞掉异常,而是至少输出 `console.error`,甚至可以在 UI 层捕获后提示用户:
"报告数据过大,请降低视频截图质量或删除部分图片。"
### 方向三:迁移到 IndexedDB长期根治
`localStorage` 的 5MB 上限对于包含大量 Base64 图片的医疗报告系统来说迟早会不够用。长期方案是:
- 引入 `localforage``idb-keyval` 等轻量库;
-`storage.ts` 改造为基于 IndexedDB 的异步存储方案(容量可达数百 MB
**注意:** 方向三涉及全项目的 `storage.get/set` 调用点从同步改为异步,改动面较大,适合作为后续迭代项目。
## 建议的实施方案
**本次优先执行方向一 + 方向二**,以最快速度解决关键帧丢失问题,并让用户感知到存储异常:
1.`captureFrame``autoCaptureFrames` 中增加 Canvas 等比缩放逻辑:
```tsx
const MAX_WIDTH = 800;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
```
2. 在 `storage.ts` 中增加超限日志:
```tsx
} catch (e) {
console.error('Storage save failed (possibly quota exceeded):', e);
}
```
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| 压缩后图片清晰度下降 | 低 | 800px 宽度 + 0.6 质量对于报告插入足够清晰 |
| 仍有个别超长视频压缩后接近 5MB | 极低 | 配合方向二的日志提示,便于后续继续优化 |
## 回滚策略
仅调整 Canvas 缩放参数和 JPEG 质量,不涉及数据结构和接口变更。如有异常可直接 revert。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -0,0 +1,60 @@
# 测试方案 — 2026-04-16-20-46-50
## 测试目标
验证压缩关键帧分辨率/质量后LocalStorage 不再超限,路由切换后自动/手动关键帧能够正常保留;同时验证存储超限时能在控制台看到错误日志。
## 测试环境
- 浏览器Chrome / Edge推荐开启 DevTools 观察 Console 和 Application > Local Storage
- 测试文件:准备一个 1080p 或更高分辨率的 MP4 视频文件(时长 60 秒以上,确保能提取多张关键帧)
## 测试用例设计
### 用例 1自动关键帧摘取后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor`,上传高清视频 | 视频正常加载 |
| 1.2 | 点击「自动关键帧摘取」 | 右侧迅速生成 10+ 张关键帧缩略图 |
| 1.3 | 打开 DevTools > Console | 无 `QuotaExceededError` 报错 |
| 1.4 | 打开 DevTools > Application > Local Storage | `reportEditorDraft_{username}` 存在且体积明显小于 5MB |
| 1.5 | 跳转到 `/report-manage`,再返回 `/report-editor` | **所有自动关键帧缩略图完整保留** |
### 用例 2手动截图后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 播放视频,在多个时间点点击「手动截图」 | 生成 5 张以上手动截图 |
| 2.2 | 切换路由后再返回 | **所有手动截图完整保留** |
### 用例 3自动+手动混合场景
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 上传视频,自动摘取 12 张关键帧 | 自动帧正常显示 |
| 3.2 | 再手动截取 5 张 | 手动帧正常显示 |
| 3.3 | 切换路由后再返回 | **自动帧和手动帧全部保留** |
### 用例 4图片清晰度验证
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 拖拽一张压缩后的关键帧到 `image-placeholder` | placeholder 中图片清晰可见,无严重马赛克 |
| 4.2 | 打印预览或放大查看 | 图片质量满足报告使用需求 |
## 验收标准
- [ ] 高清视频自动摘取 10+ 张关键帧后LocalStorage 不超限;
- [ ] 路由切换后,自动关键帧 100% 保留;
- [ ] 路由切换后,手动截图 100% 保留;
- [ ] 压缩后的图片清晰度仍满足报告使用;
- [ ] `storage.ts` 中存储失败时能在 Console 看到错误日志。
## 测试方式
手工浏览器验证,结合 DevTools 观察 LocalStorage 容量和 Console 日志。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View File

@@ -234,3 +234,53 @@ if ((settings.autoInsertDelay || 0) > 0) {
- 在异步循环中,如果某个操作不需要依赖前一步的完成结果,**绝对不要使用 `await` 阻塞主循环**,应改用 `setTimeout``Promise.all` 实现并行/异步解耦。
- 当多个定时任务需要按顺序执行时,可以通过索引计算累积延迟(`delay * (index + 1)`),实现简单的"队列式"顺序触发,而不需要阻塞主流程。
-`setTimeout` 等异步回调中操作 DOM 时,应在回调触发时"实时查询"目标元素,而不是在循环中提前捕获元素引用,以防 DOM 在延迟期间已被用户修改。
---
## 记录 11关键帧在路由切换后丢失——压缩 Canvas 分辨率并增加存储错误日志
**A. 具体问题**
报告编辑器内容和视频列表在路由切换后能正常保留,但视频分析面板中的自动摘取关键帧和手动截图全部丢失。
**B. 产生问题原因**
1. **LocalStorage 5MB 容量限制**:当前抽帧逻辑使用视频原始分辨率 + JPEG 质量 0.9
```tsx
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
```
对于 1080p/4K 视频,单张 Base64 图片可达 300KB~1MB十几张关键帧即可超过 5MB。
2. **静默失败**`storage.ts` 中的 `set` 方法捕获了 `QuotaExceededError` 但没有任何日志:
```typescript
} catch {
// ignore quota exceeded
}
```
当 `saveDraftToStorage()` 尝试保存大量关键帧时,`localStorage.setItem` 抛出异常draft 无法更新,但用户和开发者都感知不到错误。最终返回 `/report-editor` 时,只能读取到"有视频、无关键帧"的旧 draft。
**C. 解决问题方案**
1. **压缩关键帧分辨率与质量**
- 在 `captureFrame()`(手动截图)和 `autoCaptureFrames()`(自动抽帧)中,增加 Canvas 等比缩放:
```tsx
const MAX_WIDTH = 800;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
```
- 将 JPEG 导出质量从 `0.9` 降到 `0.6`。
- 这样单张图片体积可从 500KB 降至 30KB~80KB有效避免 LocalStorage 超限。
2. **增加存储错误可见性**
- 在 `storage.ts` 的 `set` 和 `setSession` 中,将静默 `catch` 改为输出 `console.error`
```typescript
} catch (e) {
console.error('Storage save failed (possibly quota exceeded):', e);
}
```
**D. 后续如何避免问题**
- 任何将 Base64 图片持久化到 `localStorage` 的场景,都必须**预估数据体积**并对图片进行适当的分辨率/质量压缩。
- 存储层的异常捕获**绝不应静默吞掉**,至少要输出日志,必要时还应弹出用户提示。
- 对于需要存储大量图片的医疗/图文报告系统,应将 `localStorage` 逐步迁移到 `IndexedDB`,从根本上解除 5MB 容量瓶颈。
- 在开发测试阶段,应使用高分辨率视频和大批量关键帧进行压力测试,提前暴露存储容量问题。

View File

@@ -0,0 +1,29 @@
# 需求分析 — 2026-04-16-20-46-50
## 原始需求摘要
`/report-editor` 页面操作后,切换到 `/report-manage` 再返回 `/report-editor` 时:
- 报告编辑器内容(`class="editor-content-wrapper print-wrapper"`)已能正常保留 ✅;
- 视频列表也能正常保留 ✅;
- **但视频分析中的自动摘取关键帧和手动截图全部丢失** ❌。
## 需求拆解
### 功能点
- 定位关键帧在路由切换后丢失的根因;
- 给出可行的修改方向,确保关键帧数据能够持久化并恢复。
### 非功能点
- 保持现有 UI 和交互不变;
- 尽量减少对存储架构的侵入。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 中 | 抽帧时的 Canvas 尺寸/质量调整 |
| `src/utils/storage.ts` | 低 | 增加超限日志或错误提示 |
## 待确认问题
无。根因已高度明确,等待用户确认修改方向。