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:
@@ -352,6 +352,7 @@ PostgreSQL 数据模型。当前覆盖 `Tenant`、`Department`、`User`、`UserS
|
||||
- 模板列表合并工具,防止新增模板被旧 `localStorage.templates` 覆盖
|
||||
- 模板导入导出工具,覆盖 HTML 模板包生成、字段库元数据回导和旧 JSON 导入兼容
|
||||
- AI 区域扫描工具,覆盖从报告正文 HTML 识别 `.ai-region`
|
||||
- 抽帧百分比工具,覆盖两位小数保序和按时间顺序截图计划
|
||||
- 后端用户 DTO 和部门模板授权映射
|
||||
- 后端系统设置 schema 校验
|
||||
- 后端 AI 入参和讯飞语音代理帧处理
|
||||
|
||||
@@ -202,6 +202,7 @@ cp .env.example .env.local
|
||||
- 默认报告模板结构和字段配置。
|
||||
- 模板 HTML 导出包字段库元数据。
|
||||
- AI 区域扫描和报告编辑器加载后同步。
|
||||
- 抽帧百分比两位小数、保序和按时间截图计划。
|
||||
- 打印导出入口。
|
||||
- 后端权限策略、AI 入参和语音代理帧处理。
|
||||
|
||||
|
||||
@@ -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 区域。 |
|
||||
|
||||
@@ -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` 上传后端文件资源。实际截图会按视频时间从早到晚执行和展示;自动插入报告图片占位符时按系统设置中的百分比配置顺序插入。
|
||||
|
||||
支持:
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
配置字段包括:
|
||||
|
||||
- `frameCount`:抽取帧数。
|
||||
- `framePositions`:每帧对应的视频进度百分比。
|
||||
- `framePositions`:每帧对应的视频进度百分比,支持保留两位小数,并保留管理员配置顺序。
|
||||
- `frameMode`:调整帧数时使用整体均匀抽取或保持当前抽帧。
|
||||
- `autoInsertFrames`:是否自动插入关键帧。
|
||||
- `autoInsertDelay`:自动插入延迟。
|
||||
- `autoInsertFrameIndices`:哪些抽帧序号参与自动插入。
|
||||
|
||||
保存时会对 `framePositions` 排序,并把 `frameCount` 同步为位置数组长度。
|
||||
保存时不会对 `framePositions` 排序,只会把每个百分比规范到 0-100 区间和两位小数,并把 `frameCount` 同步为位置数组长度。报告编辑器自动截帧时会按时间顺序执行实际截图;自动插入图片时按 `framePositions` 的配置顺序和 `autoInsertFrameIndices` 插入。
|
||||
|
||||
## AI 接口配置
|
||||
|
||||
|
||||
@@ -90,3 +90,4 @@
|
||||
| 2026-05-02 | 移除前端用户可见 JSON 导出入口,保留模板历史 JSON 导入兼容。 |
|
||||
| 2026-05-02 | 模板 HTML 导出包补充模板字段和字段管理设置,导入时恢复字段库元数据。 |
|
||||
| 2026-05-02 | 修复报告编辑器加载已有 AI 区域后下拉栏初始显示“无可用 AI 区域”的问题。 |
|
||||
| 2026-05-02 | 调整抽帧百分比为两位小数保序保存;自动截图按时间顺序执行,自动插入按配置顺序执行。 |
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
### 系统设置
|
||||
|
||||
- 超级管理员可配置抽帧数量、抽帧百分比、抽帧计算模式和自动插入策略。
|
||||
- 超级管理员可配置抽帧数量、两位小数抽帧百分比、抽帧计算模式和自动插入策略;百分比配置顺序用于自动插入顺序。
|
||||
- 超级管理员可配置 AI 服务商、接口地址、API Key 和模型名。
|
||||
- 超级管理员可配置讯飞语音听写参数。
|
||||
- 所有角色可设置默认报告模板。
|
||||
|
||||
@@ -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` 和标题。 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
20
src/utils/framePositions.test.ts
Normal file
20
src/utils/framePositions.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
21
src/utils/framePositions.ts
Normal file
21
src/utils/framePositions.ts
Normal 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);
|
||||
Reference in New Issue
Block a user