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