Preserve frame position order for auto insertion

- Allow system frame position percentages to keep two decimal places without reordering saved values.

- Stop frontend and backend settings normalization from sorting framePositions on load or save.

- Capture automatic video frames in timeline order while retaining each configured position index.

- Insert automatically selected frames into report placeholders according to the configured percentage order.

- Add frame position utilities and unit coverage for two-decimal rounding, clamping, order preservation, and timeline capture planning.

- Update README, AGENTS, feature, requirement, report editor, system settings, progress, and testing docs for the new frame ordering behavior.
This commit is contained in:
2026-05-02 05:10:39 +08:00
parent 3774657ef5
commit 2cabe7e4fd
13 changed files with 106 additions and 55 deletions

View File

@@ -352,6 +352,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS
- 模板列表合并工具,防止新增模板被旧 `localStorage.templates` 覆盖
- 模板导入导出工具,覆盖 HTML 模板包生成、字段库元数据回导和旧 JSON 导入兼容
- AI 区域扫描工具,覆盖从报告正文 HTML 识别 `.ai-region`
- 抽帧百分比工具,覆盖两位小数保序和按时间顺序截图计划
- 后端用户 DTO 和部门模板授权映射
- 后端系统设置 schema 校验
- 后端 AI 入参和讯飞语音代理帧处理

View File

@@ -202,6 +202,7 @@ cp .env.example .env.local
- 默认报告模板结构和字段配置。
- 模板 HTML 导出包字段库元数据。
- AI 区域扫描和报告编辑器加载后同步。
- 抽帧百分比两位小数、保序和按时间截图计划。
- 打印导出入口。
- 后端权限策略、AI 入参和语音代理帧处理。

View File

@@ -39,7 +39,7 @@
| 部门模板授权 | 真实集成 | 后端提供 `/api/departments``/api/departments/:id/template-permissions`,超级管理员可通过管理员模板权限更新部门授权。 |
| 电子签名 | 真实集成 | 用户管理页上传后压缩为 Data URL再调用 `/api/users/:id/signature` 写入后端文件资源;报告中有对应绑定字段时通过受控文件 URL 展示。只有开发/显式回退模式下 API 不可用才保留本地签名回退。 |
| 视频上传 | 真实集成 | 使用浏览器 File API 和对象 URL即时预览并优先通过 `/api/files``kind = VIDEO` 写入后端文件资源。 |
| 自动抽帧 | 真实集成 | 使用 `<video>` + `<canvas>` 按百分比截取 JPEG关键帧优先通过 `/api/files``kind = FRAME` 写入后端文件资源。 |
| 自动抽帧 | 真实集成 | 使用 `<video>` + `<canvas>` 按百分比截取 JPEG关键帧优先通过 `/api/files``kind = FRAME` 写入后端文件资源;百分比支持两位小数并保留配置顺序,实际截图按时间顺序执行,自动插入按配置顺序执行。 |
| 关键帧插入 | 真实集成 | 关键帧可点击插入或拖入图片占位符;上传成功后编辑器会把插入图片从 Data URL 替换为受控文件 URL。 |
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`AI 可编辑区域插入后会立即同步到目标下拉栏;需要有效 Provider 配置、模型和网络。 |
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |

View File

@@ -52,7 +52,7 @@
## 视频与抽帧
用户上传视频后,系统使用浏览器对象 URL 进行即时预览,同时优先通过 `POST /api/files``kind = VIDEO` 上传后端文件资源。抽帧逻辑按系统设置中的 `framePositions` 百分比定位视频时间,绘制到 canvas 后转为 JPEG并优先以 `kind = FRAME` 上传后端文件资源。
用户上传视频后,系统使用浏览器对象 URL 进行即时预览,同时优先通过 `POST /api/files``kind = VIDEO` 上传后端文件资源。抽帧逻辑按系统设置中的 `framePositions` 百分比定位视频时间,绘制到 canvas 后转为 JPEG并优先以 `kind = FRAME` 上传后端文件资源。实际截图会按视频时间从早到晚执行和展示;自动插入报告图片占位符时按系统设置中的百分比配置顺序插入。
支持:

View File

@@ -19,13 +19,13 @@
配置字段包括:
- `frameCount`:抽取帧数。
- `framePositions`:每帧对应的视频进度百分比。
- `framePositions`:每帧对应的视频进度百分比,支持保留两位小数,并保留管理员配置顺序
- `frameMode`:调整帧数时使用整体均匀抽取或保持当前抽帧。
- `autoInsertFrames`:是否自动插入关键帧。
- `autoInsertDelay`:自动插入延迟。
- `autoInsertFrameIndices`:哪些抽帧序号参与自动插入。
保存时会对 `framePositions` 排序,并把 `frameCount` 同步为位置数组长度。
保存时会对 `framePositions` 排序,只会把每个百分比规范到 0-100 区间和两位小数,并把 `frameCount` 同步为位置数组长度。报告编辑器自动截帧时会按时间顺序执行实际截图;自动插入图片时按 `framePositions` 的配置顺序和 `autoInsertFrameIndices` 插入。
## AI 接口配置

View File

@@ -90,3 +90,4 @@
| 2026-05-02 | 移除前端用户可见 JSON 导出入口,保留模板历史 JSON 导入兼容。 |
| 2026-05-02 | 模板 HTML 导出包补充模板字段和字段管理设置,导入时恢复字段库元数据。 |
| 2026-05-02 | 修复报告编辑器加载已有 AI 区域后下拉栏初始显示“无可用 AI 区域”的问题。 |
| 2026-05-02 | 调整抽帧百分比为两位小数保序保存;自动截图按时间顺序执行,自动插入按配置顺序执行。 |

View File

@@ -60,7 +60,7 @@
### 系统设置
- 超级管理员可配置抽帧数量、抽帧百分比、抽帧计算模式和自动插入策略。
- 超级管理员可配置抽帧数量、两位小数抽帧百分比、抽帧计算模式和自动插入策略;百分比配置顺序用于自动插入顺序
- 超级管理员可配置 AI 服务商、接口地址、API Key 和模型名。
- 超级管理员可配置讯飞语音听写参数。
- 所有角色可设置默认报告模板。

View File

@@ -80,6 +80,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
| 模板权限和个人模板 | 已覆盖 | `permissions.test.ts` |
| 本地存储读写与容错 | 已覆盖 | `storage.test.ts` |
| 系统设置混淆兼容 | 已覆盖 | `storage.test.ts` |
| 抽帧百分比工具 | 已覆盖 | `framePositions.test.ts`,覆盖两位小数、保序和按时间生成截图计划。 |
| 默认报告模板结构 | 已覆盖 | `defaultContent.test.ts` |
| 默认字段和 AI Provider | 已覆盖 | `defaultContent.test.ts` |
| AI 区域扫描工具 | 已覆盖 | `aiRegions.test.ts`,覆盖从编辑器 HTML 识别 `.ai-region` 和标题。 |

View File

@@ -272,8 +272,7 @@ export class SettingsService {
...(input.aiProviders || {}),
};
const framePositions = [...(input.framePositions || DEFAULT_SETTINGS.framePositions)]
.map((value) => Math.round(value * 10) / 10)
.sort((a, b) => a - b);
.map((value) => Math.round(value * 100) / 100);
return {
...DEFAULT_SETTINGS,

View File

@@ -24,6 +24,7 @@ import { listFiles, uploadFileResource } from '../api/files';
import { isLocalFallbackEnabled } from '../config/runtime';
import { diffChars } from 'diff';
import { areAiRegionOptionsEqual, getAiRegionOptions, type AiRegionOption } from '../utils/aiRegions';
import { buildFrameCaptureJobs, DEFAULT_FRAME_POSITIONS, normalizeFramePositions } from '../utils/framePositions';
type AudioWindow = Window & typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
@@ -992,12 +993,39 @@ export default function ReportEditor() {
saveDraftToStorage();
};
const insertFrameIntoNextPlaceholder = (frame: CapturedFrame) => {
if (!editorRef.current) return false;
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
if (!emptyPlaceholder) return false;
emptyPlaceholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${frame.dataUrl}" style="max-width:${emptyPlaceholder.style.maxWidth || emptyPlaceholder.style.width || '200px'};max-height:${emptyPlaceholder.style.maxHeight || emptyPlaceholder.style.height || '200px'};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
emptyPlaceholder.classList.add('has-image');
emptyPlaceholder.style.border = 'none';
emptyPlaceholder.style.background = 'transparent';
emptyPlaceholder.style.width = 'auto';
emptyPlaceholder.style.height = 'auto';
emptyPlaceholder.style.lineHeight = 'normal';
emptyPlaceholder.style.maxWidth = emptyPlaceholder.style.maxWidth || emptyPlaceholder.style.width || '200px';
emptyPlaceholder.style.maxHeight = emptyPlaceholder.style.maxHeight || emptyPlaceholder.style.height || '200px';
emptyPlaceholder.style.textAlign = 'left';
emptyPlaceholder.style.verticalAlign = 'top';
emptyPlaceholder.style.justifyContent = 'flex-start';
emptyPlaceholder.style.alignItems = 'flex-start';
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
return true;
};
const autoCaptureFrames = async () => {
if (!videoRef.current || currentVideoIndex === -1) return;
const video = videoRef.current;
const settings = storage.get<SystemSettings>('systemSettings', {} as SystemSettings);
const positions = settings.framePositions || [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6];
const positions = normalizeFramePositions(settings.framePositions, DEFAULT_FRAME_POSITIONS);
const dur = video.duration || 1;
const captureJobs = buildFrameCaptureJobs(positions, dur);
const capturedByPositionIndex = new Map<number, CapturedFrame>();
const canvas = canvasRef.current;
if (!canvas) return;
@@ -1008,9 +1036,8 @@ export default function ReportEditor() {
if (wasPlaying) video.pause();
let accumulatedFrames = [...capturedFrames];
for (let i = 0; i < positions.length; i++) {
const pos = positions[i];
const time = (pos / 100) * dur;
for (const job of captureJobs) {
const time = job.time;
video.currentTime = time;
await new Promise<void>(resolve => {
const onSeeked = () => {
@@ -1033,42 +1060,25 @@ export default function ReportEditor() {
dataUrl: canvas.toDataURL('image/jpeg', 0.6),
isManual: false
};
capturedByPositionIndex.set(job.index, newFrame);
accumulatedFrames = [...accumulatedFrames, newFrame].sort((a, b) => a.time - b.time);
flushSync(() => {
setCapturedFrames(accumulatedFrames);
});
stateRef.current = { ...stateRef.current, capturedFrames: accumulatedFrames };
startFrameUpload(newFrame.id, newFrame.dataUrl);
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.includes(i)) {
}
if (settings.autoInsertFrames && settings.autoInsertFrameIndices?.length) {
settings.autoInsertFrameIndices.forEach((positionIndex, insertOrderIndex) => {
const frame = capturedByPositionIndex.get(positionIndex);
if (!frame) return;
const baseDelay = (settings.autoInsertDelay || 0) * 1000;
const insertOrderIndex = settings.autoInsertFrameIndices.indexOf(i);
const actualDelay = baseDelay > 0 ? baseDelay * (insertOrderIndex + 1) : 0;
setTimeout(() => {
if (!editorRef.current) return;
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
if (emptyPlaceholder) {
emptyPlaceholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${newFrame.dataUrl}" style="max-width:${emptyPlaceholder.style.maxWidth || emptyPlaceholder.style.width || '200px'};max-height:${emptyPlaceholder.style.maxHeight || emptyPlaceholder.style.height || '200px'};display:block;object-fit:contain;object-position:left top;" draggable="false">
`;
emptyPlaceholder.classList.add('has-image');
emptyPlaceholder.style.border = 'none';
emptyPlaceholder.style.background = 'transparent';
emptyPlaceholder.style.width = 'auto';
emptyPlaceholder.style.height = 'auto';
emptyPlaceholder.style.lineHeight = 'normal';
emptyPlaceholder.style.maxWidth = emptyPlaceholder.style.maxWidth || emptyPlaceholder.style.width || '200px';
emptyPlaceholder.style.maxHeight = emptyPlaceholder.style.maxHeight || emptyPlaceholder.style.height || '200px';
emptyPlaceholder.style.textAlign = 'left';
emptyPlaceholder.style.verticalAlign = 'top';
emptyPlaceholder.style.justifyContent = 'flex-start';
emptyPlaceholder.style.alignItems = 'flex-start';
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
insertFrameIntoNextPlaceholder(frame);
}, actualDelay);
}
});
}
if (settings.autoInsertFrames && editorRef.current) {
contentRef.current = editorRef.current.innerHTML;

View File

@@ -8,6 +8,7 @@ import { listTemplates } from '../api/templates';
import { getSystemSettings, resetSystemSettings, updateSystemSettings } from '../api/settings';
import { listAiModels } from '../api/ai';
import { isLocalFallbackEnabled } from '../config/runtime';
import { DEFAULT_FRAME_POSITIONS, normalizeFramePositions, roundFramePosition } from '../utils/framePositions';
const normalizeSettings = (
input: Partial<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>,
@@ -17,9 +18,7 @@ const normalizeSettings = (
...DEFAULT_AI_PROVIDERS,
...(input.aiProviders || {}),
};
const framePositions = Array.isArray(input.framePositions) && input.framePositions.length > 0
? [...input.framePositions].sort((a, b) => a - b)
: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6];
const framePositions = normalizeFramePositions(input.framePositions, DEFAULT_FRAME_POSITIONS);
return {
frameCount: framePositions.length,
@@ -40,7 +39,7 @@ export default function SystemSettings() {
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [settings, setSettings] = useState<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>({
frameCount: 12,
framePositions: [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85.0, 96.3, 98.6],
framePositions: DEFAULT_FRAME_POSITIONS,
defaultTemplate: '',
frameMode: 'keep',
activeAiProvider: 'kimi',
@@ -138,33 +137,31 @@ export default function SystemSettings() {
});
}, [navigate]);
const round1 = (n: number) => Math.round(n * 10) / 10;
const computeFramePositions = (count: number, mode: 'uniform' | 'keep', currentPositions: number[]) => {
if (mode === 'uniform') {
const positions: number[] = [];
for (let i = 1; i <= count; i++) {
positions.push(round1((100 / (count + 1)) * i));
positions.push(roundFramePosition((100 / (count + 1)) * i));
}
return positions;
}
const sorted = [...currentPositions].sort((a, b) => a - b);
if (count <= sorted.length) {
return sorted.slice(0, count);
const next = normalizeFramePositions(currentPositions);
if (count <= next.length) {
return next.slice(0, count);
}
const need = count - sorted.length;
const last = sorted[sorted.length - 1] || 0;
const need = count - next.length;
const last = next[next.length - 1] || 0;
const range = 100 - last;
for (let i = 1; i <= need; i++) {
sorted.push(round1(last + (range / (need + 1)) * i));
next.push(roundFramePosition(last + (range / (need + 1)) * i));
}
return sorted;
return next;
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
const sortedPositions = [...settings.framePositions].sort((a, b) => a - b);
const finalSettings = { ...settings, framePositions: sortedPositions, frameCount: sortedPositions.length };
const framePositions = normalizeFramePositions(settings.framePositions);
const finalSettings = { ...settings, framePositions, frameCount: framePositions.length };
try {
const savedSettings = await updateSystemSettings(finalSettings);
const normalized = normalizeSettings(savedSettings, templates);
@@ -325,11 +322,11 @@ export default function SystemSettings() {
type="number"
min="0"
max="100"
step="0.1"
value={pos}
step="0.01"
value={Number.isFinite(pos) ? pos.toFixed(2) : '0.00'}
onChange={(e) => {
const newPos = [...settings.framePositions];
newPos[idx] = Math.min(100, Math.max(0, parseFloat(e.target.value) || 0));
newPos[idx] = roundFramePosition(parseFloat(e.target.value) || 0);
setSettings({ ...settings, framePositions: newPos });
}}
className="input-minimal w-full pr-6 text-center"

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { buildFrameCaptureJobs, normalizeFramePositions, roundFramePosition } from './framePositions';
describe('frame position utilities', () => {
it('rounds frame positions to two decimals without reordering them', () => {
expect(normalizeFramePositions([17.899, 9.304, 85])).toEqual([17.9, 9.3, 85]);
});
it('clamps invalid percentage values', () => {
expect(roundFramePosition(-1.234)).toBe(0);
expect(roundFramePosition(101.234)).toBe(100);
});
it('captures frames in timeline order while retaining original position indexes', () => {
expect(buildFrameCaptureJobs([17.9, 9.3], 100)).toEqual([
{ index: 1, position: 9.3, time: 9.3 },
{ index: 0, position: 17.9, time: 17.9 },
]);
});
});

View File

@@ -0,0 +1,21 @@
export const DEFAULT_FRAME_POSITIONS = [7.9, 9.3, 46.2, 49.1, 63.9, 64.8, 68.8, 73.7, 80.2, 85, 96.3, 98.6];
export const roundFramePosition = (value: number) =>
Math.round(Math.min(100, Math.max(0, value)) * 100) / 100;
export const normalizeFramePositions = (
positions: number[] | undefined,
fallback = DEFAULT_FRAME_POSITIONS,
) => {
const source = Array.isArray(positions) && positions.length > 0 ? positions : fallback;
return source.map(roundFramePosition);
};
export const buildFrameCaptureJobs = (positions: number[], duration: number) =>
positions
.map((position, index) => ({
index,
position,
time: (position / 100) * duration,
}))
.sort((a, b) => a.time - b.time);