Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions invokeai/frontend/webv2/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3523,6 +3523,8 @@
"lastFrameExtendHelp": "Optional destination: the extension ends on this image.",
"lastFrameHelp": "Optional: the video ends on this image (interpolates from the first frame).",
"removeClip": "Remove video",
"sampleLength": "Sample Length (Frames)",
"sampleLengthWithSeconds": "Sample Length · {{seconds}}s",
"trim": "Trim",
"trimEnd": "End Frame",
"trimEndShort": "End",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { VideoReferenceItem, VideoSettings } from './types';

import { MINIMAX_H3_NUM_FRAMES_CHOICES } from './dimensions';
import {
resizeReferenceSampleWindow,
slideReferenceSampleWindow,
applyReferenceExtendSourceVideo,
applyReferenceExtendNumFrames,
canPlaceReferenceExtendAnchor,
Expand Down Expand Up @@ -869,3 +871,85 @@ describe('reference-extend linkage', () => {
expect(applyReferenceExtendNumFrames(unlinked, 90)).toBe(unlinked);
});
});

describe('reference sample window', () => {
const clip = (startFrame: number, endFrame: number, numFrames = 300) => ({
endFrame,
fps: 24,
height: 480,
numFrames,
startFrame,
video_name: 'clip.mp4',
width: 640,
});

describe('slideReferenceSampleWindow', () => {
it('slides an ordinary window at constant length', () => {
const next = slideReferenceSampleWindow(clip(0, 199), 50, false);
expect([next.startFrame, next.endFrame]).toEqual([50, 249]);
});

it('stops at the clip end instead of shrinking (no overshoot ratchet)', () => {
// Drag far past the wall, then back to 0: the length must survive the round trip.
const overshot = slideReferenceSampleWindow(clip(0, 199), 299, false);
expect([overshot.startFrame, overshot.endFrame]).toEqual([100, 299]);
const back = slideReferenceSampleWindow(overshot, 0, false);
expect([back.startFrame, back.endFrame]).toEqual([0, 199]);
});

it('keeps the extend anchor end pinned to the cutpoint', () => {
// The anchor's seam continuity depends on frames adjacent to its end frame.
const next = slideReferenceSampleWindow(clip(180, 298), 200, true);
expect([next.startFrame, next.endFrame]).toEqual([200, 298]);
const backAndForth = slideReferenceSampleWindow(slideReferenceSampleWindow(next, 250, true), 200, true);
expect([backAndForth.startFrame, backAndForth.endFrame]).toEqual([200, 298]);
});

it('clamps the anchor start to its pinned end', () => {
const next = slideReferenceSampleWindow(clip(180, 298), 500, true);
expect([next.startFrame, next.endFrame]).toEqual([298, 298]);
});

it('self-heals a corrupt persisted trim into bounds', () => {
// end < start and end beyond the clip must both come back as a valid window.
const inverted = slideReferenceSampleWindow(clip(10, 5, 20), 0, false);
expect(inverted.startFrame).toBeGreaterThanOrEqual(0);
expect(inverted.endFrame).toBeGreaterThanOrEqual(inverted.startFrame);
expect(inverted.endFrame).toBeLessThanOrEqual(19);
const oversized = slideReferenceSampleWindow(clip(0, 999, 20), 5, false);
expect([oversized.startFrame, oversized.endFrame]).toEqual([0, 19]);
});

it('handles a single-frame clip', () => {
const next = slideReferenceSampleWindow(clip(0, 0, 1), 5, false);
expect([next.startFrame, next.endFrame]).toEqual([0, 0]);
});
});

describe('resizeReferenceSampleWindow', () => {
it('grows an ordinary window forward from its start', () => {
const next = resizeReferenceSampleWindow(clip(50, 60), 100, false);
expect([next.startFrame, next.endFrame]).toEqual([50, 149]);
});

it('clamps the length to the clip end', () => {
const next = resizeReferenceSampleWindow(clip(250, 260), 100, false);
expect([next.startFrame, next.endFrame]).toEqual([250, 299]);
});

it('grows the extend anchor backward from its pinned end', () => {
const next = resizeReferenceSampleWindow(clip(280, 298), 100, true);
expect([next.startFrame, next.endFrame]).toEqual([199, 298]);
});

it('clamps the anchor lead-in at the clip start', () => {
const next = resizeReferenceSampleWindow(clip(280, 298), 1000, true);
expect([next.startFrame, next.endFrame]).toEqual([0, 298]);
});

it('never produces a window shorter than one frame', () => {
const next = resizeReferenceSampleWindow(clip(50, 199), -5, false);
expect([next.startFrame, next.endFrame]).toEqual([50, 50]);
});
});
});
67 changes: 67 additions & 0 deletions invokeai/frontend/webv2/src/features/video/core/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,73 @@ export const isVideoSourceClip = (value: unknown): value is VideoSourceClip =>
export const VIDEO_REFERENCE_MAX_VIDEOS = 3;
export const VIDEO_REFERENCE_MAX_IMAGES = 9;

/**
* Default sample length (in frames) for a newly added video reference. Reference rows cost
* VRAM in every denoise step — the packed sequence grows with reference length — so the
* useful sample is a short window that captures the wanted visual/audio features, not the
* whole clip. 200 frames ≈ 8s at the models' native 24 fps.
*/
export const DEFAULT_REFERENCE_SAMPLE_FRAMES = 200;

/**
* Move a reference clip's sample window to a new start frame.
*
* Ordinary references slide at CONSTANT length, stopping at the clip's end rather than
* shrinking — a transient overshoot during a drag must not ratchet the sample down. The
* reference-extend anchor (`pinEnd`) instead keeps its end frame pinned to the Initial
* Video cutpoint (seam continuity depends on the frames adjacent to it; see
* deriveReferenceExtendClip), so moving its start only adjusts the lead-in length.
*
* Self-healing by construction: the returned window always satisfies
* 0 <= start <= end <= numFrames - 1, even from a corrupt persisted trim.
*/
export const slideReferenceSampleWindow = (
clip: VideoSourceClip,
rawStart: number,
pinEnd: boolean
): VideoSourceClip => {
const maxFrame = Math.max(0, clip.numFrames - 1);

if (pinEnd) {
const endFrame = Math.min(Math.max(0, clip.endFrame), maxFrame);
const startFrame = Math.min(Math.max(0, Math.round(rawStart)), endFrame);

return { ...clip, endFrame, startFrame };
}

const sampleFrames = Math.min(Math.max(1, clip.endFrame - clip.startFrame + 1), maxFrame + 1);
const startFrame = Math.min(Math.max(0, Math.round(rawStart)), maxFrame - (sampleFrames - 1));

return { ...clip, endFrame: startFrame + sampleFrames - 1, startFrame };
};

/**
* Resize a reference clip's sample window to a new length in frames.
*
* Ordinary references grow from the start frame (the end moves, clamped to the clip); the
* reference-extend anchor (`pinEnd`) grows backward from its pinned end (the start moves),
* since its end must stay on the Initial Video cutpoint. Same self-healing bounds as
* slideReferenceSampleWindow.
*/
export const resizeReferenceSampleWindow = (
clip: VideoSourceClip,
rawSampleFrames: number,
pinEnd: boolean
): VideoSourceClip => {
const maxFrame = Math.max(0, clip.numFrames - 1);
const sampleFrames = Math.min(Math.max(1, Math.round(rawSampleFrames)), maxFrame + 1);

if (pinEnd) {
const endFrame = Math.min(Math.max(0, clip.endFrame), maxFrame);

return { ...clip, endFrame, startFrame: Math.max(0, endFrame - (sampleFrames - 1)) };
}

const startFrame = Math.min(Math.max(0, clip.startFrame), maxFrame);

return { ...clip, endFrame: Math.min(startFrame + sampleFrames - 1, maxFrame), startFrame };
};

const VIDEO_REFERENCE_CONDITIONINGS = ['video_audio', 'video', 'audio'] as const;
const VIDEO_REFERENCE_IMAGE_DETAILS = ['max', 'match'] as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { useDndContext, useDndMonitor, useDroppable } from '@dnd-kit/core';
import { galleryItems, galleryTransfers, toGalleryItemKey } from '@features/gallery';
import { GalleryPickerPopover } from '@features/gallery/picker';
import { galleryImageUrls, galleryVideoUrls, isGalleryItemDragData } from '@features/gallery/utility';
import { createVideoSourceClip } from '@features/video/core/settings';
import {
createVideoSourceClip,
DEFAULT_REFERENCE_SAMPLE_FRAMES,
resizeReferenceSampleWindow,
slideReferenceSampleWindow,
} from '@features/video/core/settings';
import {
assertAccountScopeCurrent,
captureAccountScope,
Expand All @@ -22,7 +27,7 @@ import {
import { Button, IconButton } from '@platform/ui/Button';
import { DropTargetOverlay } from '@platform/ui/DropTargetOverlay';
import { DropZone } from '@platform/ui/DropZone';
import { Field } from '@platform/ui/Field';
import { Field, FieldLabel } from '@platform/ui/Field';
import { MiddleTruncate } from '@platform/ui/MiddleTruncate';
import { Select } from '@platform/ui/Select';
import { SliderNumberField } from '@platform/ui/SliderNumberField';
Expand Down Expand Up @@ -109,28 +114,41 @@ const ReferenceCard = memo(function ReferenceCard({
},
[index, onUpdate, reference]
);
// The trim is presented as a sliding sample window — start frame plus length — because
// what the user is choosing is "how much" (every reference frame costs denoise VRAM) and
// "from where". Storage stays startFrame/endFrame (the request contract); the window
// math (constant-length slide that stops at the clip's end, and the extend anchor's
// pinned-to-the-cutpoint end) lives in core/settings.
const handleStartFrame = useCallback(
(startFrame: number) => {
(rawStart: number) => {
if (reference.kind === 'video') {
onUpdate(index, {
...reference,
clip: { ...reference.clip, endFrame: Math.max(startFrame, reference.clip.endFrame), startFrame },
clip: slideReferenceSampleWindow(reference.clip, rawStart, reference.fromSourceVideo === true),
});
}
},
[index, onUpdate, reference]
);
const handleEndFrame = useCallback(
(endFrame: number) => {
const handleSampleFrames = useCallback(
(rawSampleFrames: number) => {
if (reference.kind === 'video') {
onUpdate(index, {
...reference,
clip: { ...reference.clip, endFrame, startFrame: Math.min(reference.clip.startFrame, endFrame) },
clip: resizeReferenceSampleWindow(reference.clip, rawSampleFrames, reference.fromSourceVideo === true),
});
}
},
[index, onUpdate, reference]
);
// The window's length, and the seconds it represents — the label carries the seconds
// because the control is how a user hits a target sample duration (reference frames cost
// denoise VRAM every step), while its unit has to stay frames to match the trim contract.
const sampleFrames = reference.kind === 'video' ? reference.clip.endFrame - reference.clip.startFrame + 1 : 0;
const sampleSeconds =
reference.kind === 'video' && Number.isFinite(reference.clip.fps) && reference.clip.fps > 0
? (sampleFrames / reference.clip.fps).toFixed(1)
: null;
const handleMoveUp = useCallback(() => onMove(index, -1), [index, onMove]);
const handleMoveDown = useCallback(() => onMove(index, 1), [index, onMove]);
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
Expand Down Expand Up @@ -163,9 +181,12 @@ const ReferenceCard = memo(function ReferenceCard({
value={selectValue}
onValueChange={handleSelect}
/>
{/* One row per trim bound: the bound's live frame at left, its slider at
right. The seeking thumbs replace the static gallery poster for video
references — the start-frame thumb is the card's visual identity. */}
{/* One row per window edge: the live frame at left, its control at right. The
seeking thumbs replace the static gallery poster for video references — the
start-frame thumb is the card's visual identity. The second row's SLIDER is
the sample length (the quantity that costs VRAM); its THUMB still shows the
resulting end frame, badged with that frame number since the number field
beside it shows the length, not the frame. */}
{reference.kind === 'video' ? (
<Stack gap="1">
<HStack gap="2">
Expand All @@ -175,36 +196,50 @@ const ReferenceCard = memo(function ReferenceCard({
label={t('widgets.video.trimStartShort')}
src={galleryVideoUrls.full(name)}
/>
<Box flex="1" minW="0">
<Stack flex="1" gap="0.5" minW="0">
<FieldLabel>{t('widgets.video.trimStart')}</FieldLabel>
<SliderNumberField
ariaLabel={t('widgets.video.trimStart')}
disabled={disabled}
max={Math.max(0, reference.clip.numFrames - 1)}
min={0}
showStepper
step={1}
value={reference.clip.startFrame}
onChange={handleStartFrame}
/>
</Box>
</Stack>
</HStack>
<HStack gap="2">
<TrimBoundThumb
fps={reference.clip.fps}
frame={reference.clip.endFrame}
label={t('widgets.video.trimEndShort')}
label={`${t('widgets.video.trimEndShort')} · ${reference.clip.endFrame}`}
src={galleryVideoUrls.full(name)}
/>
<Box flex="1" minW="0">
<Stack flex="1" gap="0.5" minW="0">
<FieldLabel>
{sampleSeconds === null
? t('widgets.video.sampleLength')
: t('widgets.video.sampleLengthWithSeconds', { seconds: sampleSeconds })}
</FieldLabel>
<SliderNumberField
ariaLabel={t('widgets.video.trimEnd')}
ariaLabel={t('widgets.video.sampleLength')}
disabled={disabled}
max={Math.max(0, reference.clip.numFrames - 1)}
min={0}
// The anchor grows backward from its pinned end, so its ceiling is the
// available lead-in; ordinary windows grow forward from their start.
max={
reference.fromSourceVideo === true
? Math.max(1, reference.clip.endFrame + 1)
: Math.max(1, reference.clip.numFrames - reference.clip.startFrame)
}
min={1}
showStepper
step={1}
value={reference.clip.endFrame}
onChange={handleEndFrame}
value={sampleFrames}
onChange={handleSampleFrames}
/>
</Box>
</Stack>
</HStack>
</Stack>
) : null}
Expand Down Expand Up @@ -388,9 +423,15 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({
return [
...current,
{
// References are truncated to the generated duration, not joined: default to
// the whole clip rather than the extend-mode 2-frame-tail trim.
clip: { ...clip, endFrame: Math.max(0, clip.numFrames - 1), startFrame: 0 },
// Default to a short sample window from the clip's start, not the whole
// clip: reference frames cost denoise VRAM every step, and a few seconds
// captures the wanted features. (Not the extend-mode 2-frame-tail trim
// either -- references are truncated to the generated duration, not joined.)
clip: {
...clip,
endFrame: Math.max(0, Math.min(DEFAULT_REFERENCE_SAMPLE_FRAMES, clip.numFrames) - 1),
startFrame: 0,
},
conditioning: 'video_audio',
kind: 'video',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ export const VideoSourceClipField = memo(
disabled={disabled}
max={maxFrameIndex}
min={0}
showStepper
step={1}
value={sourceVideo.startFrame}
onChange={setStartFrame}
Expand All @@ -343,6 +344,7 @@ export const VideoSourceClipField = memo(
disabled={disabled}
max={maxFrameIndex}
min={0}
showStepper
step={1}
value={sourceVideo.endFrame}
onChange={setEndFrame}
Expand Down
Loading