Skip to content
Open
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
3 changes: 2 additions & 1 deletion invokeai/frontend/webv2/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3458,10 +3458,11 @@
"referenceExtendCapFullDescription": "All 3 video reference slots are in use, so there is no room for the continuity reference this clip needs. Remove a video reference, then drop the clip again.",
"referenceExtendHelp": "The generated video is appended to this clip at its End Frame. A linked video reference samples up to the last ~5 seconds before that cutpoint for continuity — never more than the generated clip's own length, since the model discards the overrun at the seam. Its trim re-derives whenever the cutpoints or the frame count change.",
"referenceImageCapRace": "All {{max}} image reference slots filled while this one was loading, so it was not added.",
"referenceImageCost": "{{width}}×{{height}} · {{rows}} rows per step",
"referenceVideoCapRace": "All {{max}} video reference slots filled while this one was loading, so it was not added.",
"referenceFromInitialVideo": "Initial video",
"references": "References",
"referencesHelp": "Up to 3 videos and 9 images condition the generation, in order — reordering references changes the result. A video reference can contribute its image track, its soundtrack, or both.",
"referencesHelp": "Up to 3 videos and 9 images condition the generation, in order — reordering references changes the result. A video reference can contribute its image track, its soundtrack, or both. Every reference adds rows to each denoising step, so the first image reference starts at maximum detail and later ones match the generation size.",
"removeReference": "Remove reference",
"staleReferences": "References are set but the selected model cannot use them."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
MINIMAX_H3_NUM_FRAMES_CHOICES,
MINIMAX_H3_NUM_FRAMES_DEFAULT,
resolveMiniMaxH3Canvas,
resolveMiniMaxH3ReferenceImage,
scaleAndSnapWanDimensions,
snapMiniMaxH3NumFrames,
snapWanNumFrames,
Expand Down Expand Up @@ -150,6 +151,50 @@ describe('MiniMax H3 frame counts', () => {
});
});

describe('resolveMiniMaxH3ReferenceImage', () => {
// Cross-checked against the backend's own `resolve_reference_image_short_edge` +
// `normalize_reference_image` (the graph encodes exactly these sizes).
const LANDSCAPE_AREA = 1344 * 768;
const SQUARE_AREA = 768 * 768;

it.each([
// source, detail, target area, normalized size, rows
[1920, 1080, 'max', LANDSCAPE_AREA, 3648, 2048, 7296],
[1920, 1080, 'match', LANDSCAPE_AREA, 1344, 768, 1008],
[4032, 3024, 'max', LANDSCAPE_AREA, 2720, 2048, 5440],
[4032, 3024, 'match', LANDSCAPE_AREA, 1184, 896, 1036],
[3024, 4032, 'match', LANDSCAPE_AREA, 896, 1184, 1036],
[1024, 1024, 'max', LANDSCAPE_AREA, 2048, 2048, 4096],
[1920, 1080, 'match', SQUARE_AREA, 1024, 576, 576],
[1024, 1024, 'match', SQUARE_AREA, 768, 768, 576],
] as const)(
'%sx%s at %s detail normalizes to %sx%s',
(width, height, detail, targetArea, expectedWidth, expectedHeight, expectedRows) => {
expect(resolveMiniMaxH3ReferenceImage(width, height, detail, targetArea)).toEqual({
dimensions: { height: expectedHeight, width: expectedWidth },
rows: expectedRows,
});
}
);

it('never scales a match-detail reference above the 2048 rule', () => {
const huge = resolveMiniMaxH3ReferenceImage(4000, 4000, 'match', 4096 * 4096);

expect(huge?.dimensions).toEqual({ height: 2048, width: 2048 });
});

it('sizes a max-detail reference without a target area', () => {
expect(resolveMiniMaxH3ReferenceImage(1920, 1080, 'max', null)?.rows).toBe(7296);
});

it('returns null when match detail has no area to match, or the source is degenerate', () => {
expect(resolveMiniMaxH3ReferenceImage(1920, 1080, 'match', null)).toBeNull();
expect(resolveMiniMaxH3ReferenceImage(1920, 1080, 'match', 0)).toBeNull();
expect(resolveMiniMaxH3ReferenceImage(0, 1080, 'max', LANDSCAPE_AREA)).toBeNull();
expect(resolveMiniMaxH3ReferenceImage(Number.NaN, 1080, 'max', LANDSCAPE_AREA)).toBeNull();
});
});

describe('getVideoDurationSeconds', () => {
it('matches the backend n / fps labeling and guards degenerate inputs', () => {
expect(getVideoDurationSeconds(124, 24)).toBeCloseTo(5.17, 2);
Expand Down
66 changes: 65 additions & 1 deletion invokeai/frontend/webv2/src/features/video/core/dimensions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { MiniMaxH3TargetResolution, VideoAspectRatioId, WanTargetResolution } from './types';
import type {
MiniMaxH3TargetResolution,
VideoAspectRatioId,
VideoReferenceImageDetail,
WanTargetResolution,
} from './types';

/**
* Client-side ports of the backend's video canvas math, so the panel can show
Expand All @@ -8,6 +13,8 @@ import type { MiniMaxH3TargetResolution, VideoAspectRatioId, WanTargetResolution
* ("nearest" rounding — the node default; the other modes are workflow-only).
* - MiniMax H3: `resolve_canvas_size` in `invokeai/backend/minimax_h3/packing.py`
* and `resolve_lowres_canvas_size` in `invokeai/backend/minimax_h3/presets.py`.
* - Ref2VA image references: `resolve_reference_image_short_edge` and
* `normalize_reference_image` in `invokeai/backend/minimax_h3/reference_conditioning.py`.
*
* Where the backend raises, these return null: the panel falls back to defaults
* and reports the problem through `getVideoValidationReasons` instead of throwing.
Expand Down Expand Up @@ -147,6 +154,63 @@ export const resolveMiniMaxH3Canvas = (
};
};

/** Upstream's reference-image rule: a constant short edge, whatever the generation size is. */
export const MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE = 2048;

/**
* Pixels per packed row: H3 encodes at a 16x spatial compression and the transformer packs
* 2x2 latent patches, so a 32x32 pixel block is one row.
*/
export const MINIMAX_H3_ROW_PIXELS = 32 * 32;

/**
* A reference image's normalized size and the rows it contributes.
*
* Those rows join the packed sequence and are re-attended at EVERY denoising step, with
* attention quadratic in the sequence length — which is the whole difference between the
* two detail settings. `'max'` pins the short edge to 2048 no matter how small the
* generation is; `'match'` scales the reference to the generation's pixel area (never
* above the 2048 rule), typically an order of magnitude fewer rows.
*
* Null when the inputs are degenerate, or when `'match'` has no target area to match —
* the panel then shows nothing rather than a wrong number.
*/
export const resolveMiniMaxH3ReferenceImage = (
width: number,
height: number,
detail: VideoReferenceImageDetail,
targetArea: number | null
): { dimensions: VideoDimensions; rows: number } | null => {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}

let shortEdge = MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE;

if (detail === 'match') {
if (targetArea === null || !Number.isFinite(targetArea) || targetArea <= 0) {
return null;
}
// The backend rounds with Python's banker's rounding here, so `roundHalfToEven` is
// what keeps this estimate equal to the size the graph actually encodes.
const matched = Math.max(
MINIMAX_H3_CANVAS_MULTIPLE,
roundHalfToEven(Math.min(width, height) * Math.sqrt(targetArea / (width * height)))
);

shortEdge = Math.min(MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE, matched);
}

const scale = shortEdge / Math.min(width, height);
const dimensions = {
height: snapToMultiple(height * scale, MINIMAX_H3_CANVAS_MULTIPLE),
width: snapToMultiple(width * scale, MINIMAX_H3_CANVAS_MULTIPLE),
};

// Both axes are multiples of 32, so this is exact.
return { dimensions, rows: (dimensions.width * dimensions.height) / MINIMAX_H3_ROW_PIXELS };
};

/** The width/height parts of a preset ratio, for feeding the canvas resolvers. */
export const getVideoAspectRatioParts = (id: VideoAspectRatioId): VideoDimensions => {
const [width = 1, height = 1] = id.split(':').map(Number);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
cloneVideoWidgetValues,
createVideoSourceClip,
deriveReferenceExtendClip,
getDefaultReferenceImageDetail,
isVideoSettings,
isVideoSourceClip,
normalizeVideoSettings,
Expand Down Expand Up @@ -260,6 +261,29 @@ describe('createVideoSourceClip', () => {
});
});

describe('getDefaultReferenceImageDetail', () => {
const imageReference = {
detail: 'max',
image: { height: 1080, image_name: 'ref.png', width: 1920 },
kind: 'image',
} as const;
const videoReference = {
clip: SOURCE_VIDEO,
conditioning: 'video_audio',
kind: 'video',
} as const;

it('starts the first image reference at maximum detail', () => {
expect(getDefaultReferenceImageDetail([])).toBe('max');
expect(getDefaultReferenceImageDetail([videoReference])).toBe('max');
});

it('matches the generation size once an image reference is placed', () => {
expect(getDefaultReferenceImageDetail([imageReference])).toBe('match');
expect(getDefaultReferenceImageDetail([videoReference, imageReference])).toBe('match');
});
});

describe('clearDeletedVideoMedia', () => {
const withMedia = createSettings({
firstFrameImage: FIRST_FRAME,
Expand Down
17 changes: 17 additions & 0 deletions invokeai/frontend/webv2/src/features/video/core/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
MiniMaxH3TargetResolution,
VideoAspectRatioId,
VideoGenerationMode,
VideoReferenceImageDetail,
VideoReferenceItem,
VideoSettings,
VideoSourceClip,
Expand Down Expand Up @@ -470,6 +471,22 @@ export const createVideoSourceClip = (item: {
};
};

/**
* The detail a newly added image reference starts on.
*
* The FIRST image reference keeps upstream's rule, a 2048px short edge: it is usually the
* subject the generation is about, and that is where the extra detail earns its cost.
* Later ones match the generation size instead. Reference rows are re-attended at every
* denoising step and attention is quadratic in the sequence, so a second and third 2048px
* reference are what turn a modest surcharge into a doubling — while the marginal value of
* conditioning a supporting reference at seven times the output's pixel density is small.
*
* Only the starting value: every card's selector still offers both, and the card shows the
* size and row count each choice produces.
*/
export const getDefaultReferenceImageDetail = (references: VideoReferenceItem[]): VideoReferenceImageDetail =>
references.some((entry) => entry.kind === 'image') ? 'match' : 'max';

/** The minimum frames a trim must keep — video_concat's crossfade consumes a 2-frame tail. */
export const MIN_VIDEO_TRIM_FRAMES = 2;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { Badge, Box, createListCollection, HStack, Image, Input, Spinner, Stack,
import { useDndContext, useDndMonitor, useDroppable } from '@dnd-kit/core';
import { galleryImages, galleryItems, galleryTransfers } from '@features/gallery';
import { galleryImageUrls, galleryVideoUrls, isGalleryItemDragData } from '@features/gallery/utility';
import { createVideoSourceClip } from '@features/video/core/settings';
import { resolveMiniMaxH3ReferenceImage } from '@features/video/core/dimensions';
import { createVideoSourceClip, getDefaultReferenceImageDetail } from '@features/video/core/settings';
import {
assertAccountScopeCurrent,
captureAccountScope,
Expand Down Expand Up @@ -74,6 +75,7 @@ const ReferenceCard = memo(function ReferenceCard({
onRemove,
onUpdate,
reference,
targetArea,
}: {
collections: ReferenceCollections;
disabled: boolean;
Expand All @@ -84,6 +86,8 @@ const ReferenceCard = memo(function ReferenceCard({
onRemove: (index: number) => void;
onUpdate: (index: number, reference: VideoReferenceItem) => void;
reference: VideoReferenceItem;
/** The generation's pixel area, which is what 'match' detail scales an image to. */
targetArea: number | null;
}) {
const { t } = useTranslation();
const name = reference.kind === 'video' ? reference.clip.video_name : reference.image.image_name;
Expand Down Expand Up @@ -128,6 +132,16 @@ const ReferenceCard = memo(function ReferenceCard({
},
[index, onUpdate, reference]
);
// What this reference will actually cost, at the size the graph will encode it: the two
// detail settings differ by an order of magnitude in rows, and nothing else in the panel
// says so before the generation is queued.
const imageCost = useMemo(
() =>
reference.kind === 'image'
? resolveMiniMaxH3ReferenceImage(reference.image.width, reference.image.height, reference.detail, targetArea)
: null,
[reference, targetArea]
);
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 @@ -160,6 +174,15 @@ const ReferenceCard = memo(function ReferenceCard({
value={selectValue}
onValueChange={handleSelect}
/>
{imageCost ? (
<Text color="fg.muted" fontSize="2xs" fontVariantNumeric="tabular-nums">
{t('widgets.video.referenceImageCost', {
height: imageCost.dimensions.height,
rows: imageCost.rows.toLocaleString(),
width: imageCost.dimensions.width,
})}
</Text>
) : null}
{/* 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. */}
Expand Down Expand Up @@ -246,6 +269,7 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({
maxVideos,
onChange,
references,
targetArea,
}: {
disabled?: boolean;
maxImages: number;
Expand All @@ -258,6 +282,8 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({
*/
onChange: (update: (current: VideoReferenceItem[]) => VideoReferenceItem[]) => void;
references: VideoReferenceItem[];
/** The generation's pixel area, which sizes a 'match'-detail image reference. */
targetArea: number | null;
}) {
const { t } = useTranslation();
const { getUploadBoardId, reportError, touchGalleryImages } = useVideoUiActions();
Expand Down Expand Up @@ -338,7 +364,10 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({
return [
...current,
{
detail: 'max',
// Read off the LIVE list, beside the cap re-check: which default applies
// depends on whether an image reference is already placed, and another
// writer can have placed one during the resolve above.
detail: getDefaultReferenceImageDetail(current),
image: { height: resolved.height, image_name: resolved.imageName, width: resolved.width },
kind: 'image',
},
Expand Down Expand Up @@ -533,6 +562,7 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({
canMoveDown={index < references.length - 1 && index + 1 !== anchorIndex}
canMoveUp={index > 0 && index !== anchorIndex}
reference={reference}
targetArea={targetArea}
onMove={moveReference}
onRemove={removeReference}
onUpdate={updateReference}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ export const VideoWidgetView = () => {
maxImages={policy.references?.maxImages ?? 9}
maxVideos={policy.references?.maxVideos ?? 3}
references={values.references}
targetArea={dimensions ? dimensions.width * dimensions.height : null}
onChange={setReferences}
/>
</Stack>
Expand Down
Loading