split out video aspect/ratio into its own components

This commit is contained in:
Mary Hipp
2025-08-22 12:36:12 -04:00
committed by psychedelicious
parent b52a885e74
commit 2b688ed855
10 changed files with 307 additions and 105 deletions

View File

@@ -504,7 +504,7 @@ export const RUNWAY_ASPECT_RATIOS: Record<RunwayAspectRatio, Dimensions> = {
export const zVeo3Resolution = z.enum(['720p', '1080p']);
export type Veo3Resolution = z.infer<typeof zVeo3Resolution>;
export const isVeo3Resolution = (v: unknown): v is Veo3Resolution => zVeo3Resolution.safeParse(v).success;
export const VEO3_RESOLUTIONS: Record<Veo3Resolution, Dimensions> = {
export const RESOLUTION_MAP: Record<Veo3Resolution | RunwayResolution, Dimensions> = {
'720p': { width: 1280, height: 720 },
'1080p': { width: 1920, height: 1080 },
};
@@ -512,9 +512,6 @@ export const VEO3_RESOLUTIONS: Record<Veo3Resolution, Dimensions> = {
export const zRunwayResolution = z.enum(['720p']);
export type RunwayResolution = z.infer<typeof zRunwayResolution>;
export const isRunwayResolution = (v: unknown): v is RunwayResolution => zRunwayResolution.safeParse(v).success;
export const RUNWAY_RESOLUTIONS: Record<RunwayResolution, Dimensions> = {
'720p': { width: 1280, height: 720 },
};
const zAspectRatioConfig = z.object({
id: zAspectRatioID,

View File

@@ -37,6 +37,7 @@ export const DimensionsAspectRatioSelect = memo(() => {
const isGemini2_5 = useAppSelector(selectIsGemini2_5);
const isVeo3 = useAppSelector(selectIsVeo3);
const isRunway = useAppSelector(selectIsRunway);
const options = useMemo(() => {
// Imagen3 and ChatGPT4o have different aspect ratio options, and do not support freeform sizes
if (isImagen3 || isImagen4) {

View File

@@ -1,4 +1,5 @@
import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
isRunwayDurationID,
@@ -7,10 +8,8 @@ import {
VEO3_DURATIONS,
} from 'features/controlLayers/store/types';
import { selectVideoDuration, selectVideoModel, videoDurationChanged } from 'features/parameters/store/videoSlice';
import type { ChangeEventHandler } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
export const ParamDuration = () => {
const videoDuration = useAppSelector(selectVideoDuration);
@@ -34,9 +33,9 @@ export const ParamDuration = () => {
}
}, [model]);
const onChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
(e) => {
const duration = e.target.value;
const onChange = useCallback<ComboboxOnChange>(
(v) => {
const duration = v?.value;
if (!isVeo3DurationID(duration) && !isRunwayDurationID(duration)) {
return;
}
@@ -46,25 +45,12 @@ export const ParamDuration = () => {
[dispatch]
);
const value = useMemo(() => options.find((o) => o.value === videoDuration)?.value, [videoDuration, options]);
const value = useMemo(() => options.find((o) => o.value === videoDuration), [videoDuration, options]);
return (
<FormControl>
<FormLabel>{t('parameters.duration')}</FormLabel>
<Select
size="sm"
value={value}
onChange={onChange}
cursor="pointer"
iconSize="0.75rem"
icon={<PiCaretDownBold />}
>
{options.map((duration) => (
<option key={duration.value} value={duration.value}>
{duration.label}
</option>
))}
</Select>
<Combobox value={value} options={options} onChange={onChange} />
</FormControl>
);
};

View File

@@ -1,17 +1,15 @@
import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { heightChanged, widthChanged } from 'features/controlLayers/store/paramsSlice';
import {
isRunwayResolution,
isVeo3Resolution,
VEO3_RESOLUTIONS,
zRunwayResolution,
zVeo3Resolution,
} from 'features/controlLayers/store/types';
import { selectVideoModel, selectVideoResolution, videoResolutionChanged } from 'features/parameters/store/videoSlice';
import type { ChangeEventHandler } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
export const ParamResolution = () => {
const videoResolution = useAppSelector(selectVideoResolution);
@@ -21,47 +19,32 @@ export const ParamResolution = () => {
const options = useMemo(() => {
if (model?.base === 'veo3') {
return zVeo3Resolution.options;
return zVeo3Resolution.options.map((o) => ({ label: o, value: o }));
} else if (model?.base === 'runway') {
return zRunwayResolution.options;
return zRunwayResolution.options.map((o) => ({ label: o, value: o }));
} else {
return [];
}
}, [model]);
const onChange = useCallback<ChangeEventHandler<HTMLSelectElement>>(
(e) => {
const resolution = e.target.value;
if (!isVeo3Resolution(resolution)) {
const onChange = useCallback<ComboboxOnChange>(
(v) => {
const resolution = v?.value;
if (!isVeo3Resolution(resolution) && !isRunwayResolution(resolution)) {
return;
}
dispatch(videoResolutionChanged(resolution));
dispatch(widthChanged({ width: VEO3_RESOLUTIONS[resolution].width, updateAspectRatio: true, clamp: true }));
dispatch(heightChanged({ height: VEO3_RESOLUTIONS[resolution].height, updateAspectRatio: true, clamp: true }));
},
[dispatch]
);
const value = useMemo(() => options.find((o) => o === videoResolution), [videoResolution, options]);
const value = useMemo(() => options.find((o) => o.value === videoResolution), [videoResolution, options]);
return (
<FormControl>
<FormLabel>{t('parameters.resolution')}</FormLabel>
<Select
size="sm"
value={value}
onChange={onChange}
cursor="pointer"
iconSize="0.75rem"
icon={<PiCaretDownBold />}
>
{options.map((resolution) => (
<option key={resolution} value={resolution}>
{resolution}
</option>
))}
</Select>
<Combobox value={value} options={options} onChange={onChange} />
</FormControl>
);
};

View File

@@ -0,0 +1,22 @@
import { Flex } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { ParamResolution } from './ParamResolution';
import { VideoDimensionsAspectRatioSelect } from './VideoDimensionsAspectRatioSelect';
import { VideoDimensionsPreview } from './VideoDimensionsPreview';
export const VideoDimensions = memo(() => {
return (
<Flex gap={4} alignItems="center">
<Flex gap={4} flexDirection="column" width="full">
<ParamResolution />
<VideoDimensionsAspectRatioSelect />
</Flex>
<Flex w="108px" h="108px" flexShrink={0} flexGrow={0} alignItems="center" justifyContent="center" py={4}>
<VideoDimensionsPreview />
</Flex>
</Flex>
);
});
VideoDimensions.displayName = 'VideoDimensions';

View File

@@ -0,0 +1,59 @@
import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import {
isAspectRatioID,
zAspectRatioID,
zRunwayAspectRatioID,
zVeo3AspectRatioID,
} from 'features/controlLayers/store/types';
import {
selectIsRunway,
selectIsVeo3,
selectVideoAspectRatio,
videoAspectRatioChanged,
} from 'features/parameters/store/videoSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const VideoDimensionsAspectRatioSelect = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const id = useAppSelector(selectVideoAspectRatio);
const isVeo3 = useAppSelector(selectIsVeo3);
const isRunway = useAppSelector(selectIsRunway);
const options = useMemo(() => {
if (isVeo3) {
return zVeo3AspectRatioID.options.map((o) => ({ label: o, value: o }));
}
if (isRunway) {
return zRunwayAspectRatioID.options.map((o) => ({ label: o, value: o }));
}
// All other models
return zAspectRatioID.options.map((o) => ({ label: o, value: o }));
}, [isVeo3, isRunway]);
const onChange = useCallback<ComboboxOnChange>(
(v) => {
if (!isAspectRatioID(v?.value)) {
return;
}
dispatch(videoAspectRatioChanged(v.value));
},
[dispatch]
);
const value = useMemo(() => options.find((o) => o.value === id), [id, options]);
return (
<FormControl>
<InformationalPopover feature="paramAspect">
<FormLabel>{t('parameters.aspect')}</FormLabel>
</InformationalPopover>
<Combobox value={value} options={options} onChange={onChange} />
</FormControl>
);
});
VideoDimensionsAspectRatioSelect.displayName = 'VideoDimensionsAspectRatioSelect';

View File

@@ -0,0 +1,88 @@
import { Flex, Grid, GridItem, Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { ASPECT_RATIO_MAP } from 'features/controlLayers/store/types';
import { useCurrentVideoDimensions } from 'features/parameters/hooks/useCurrentVideoDimensions';
import { selectVideoAspectRatio } from 'features/parameters/store/videoSlice';
import { memo, useMemo } from 'react';
import { useMeasure } from 'react-use';
export const VideoDimensionsPreview = memo(() => {
const aspectRatio = useAppSelector(selectVideoAspectRatio);
const [ref, dims] = useMeasure<HTMLDivElement>();
const currentVideoDimensions = useCurrentVideoDimensions();
const previewBoxSize = useMemo(() => {
if (!dims || aspectRatio === 'Free') {
return { width: 0, height: 0 };
}
const aspectRatioValue = ASPECT_RATIO_MAP[aspectRatio]?.ratio ?? 1;
let width = currentVideoDimensions.width;
let height = currentVideoDimensions.height;
if (currentVideoDimensions.width > currentVideoDimensions.height) {
width = dims.width;
height = width / aspectRatioValue;
} else {
height = dims.height;
width = height * aspectRatioValue;
}
return { width, height };
}, [dims, currentVideoDimensions, aspectRatio]);
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center" ref={ref}>
<Flex
position="relative"
borderRadius="base"
borderColor="base.600"
borderWidth="3px"
width={`${previewBoxSize.width}px`}
height={`${previewBoxSize.height}px`}
alignItems="center"
justifyContent="center"
>
<Grid
borderRadius="base"
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
gridTemplateColumns="1fr 1fr 1fr"
gridTemplateRows="1fr 1fr 1fr"
gap="1px"
bg="base.700"
>
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
<GridItem bg="base.800" />
</Grid>
<Flex
position="absolute"
top="50%"
right="50%"
bottom="50%"
left="50%"
alignItems="center"
justifyContent="center"
>
<Text color="base.200" fontSize="xs">
{currentVideoDimensions.width}x{currentVideoDimensions.height}
</Text>
</Flex>
</Flex>
</Flex>
);
});
VideoDimensionsPreview.displayName = 'VideoDimensionsPreview';

View File

@@ -0,0 +1,54 @@
import { useAppSelector } from 'app/store/storeHooks';
import type { AspectRatioID } from 'features/controlLayers/store/types';
import { ASPECT_RATIO_MAP, RESOLUTION_MAP } from 'features/controlLayers/store/types';
import { selectVideoAspectRatio, selectVideoResolution } from 'features/parameters/store/videoSlice';
import { useMemo } from 'react';
export const useCurrentVideoDimensions = () => {
const videoAspectRatio = useAppSelector(selectVideoAspectRatio);
const videoResolution = useAppSelector(selectVideoResolution);
const currentVideoDimensions = useMemo(() => {
// Default fallback dimensions
const fallback = { width: 1280, height: 720 };
if (!videoAspectRatio || !videoResolution) {
return fallback;
}
// Get base resolution dimensions from the resolution tables
let baseWidth: number;
let baseHeight: number;
const resolutionDims = RESOLUTION_MAP[videoResolution];
baseWidth = resolutionDims.width;
baseHeight = resolutionDims.height;
// Get the aspect ratio value from the map
const aspectRatioData = ASPECT_RATIO_MAP[videoAspectRatio as Exclude<AspectRatioID, 'Free'>];
if (!aspectRatioData) {
return { width: baseWidth, height: baseHeight };
}
const targetRatio = aspectRatioData.ratio;
// Calculate dimensions that maintain the aspect ratio while respecting the resolution
// We use the resolution as a constraint on the total pixel count
const totalPixels = baseWidth * baseHeight;
// Calculate dimensions that match the aspect ratio and approximate the target pixel count
// width * height = totalPixels
// width / height = targetRatio
// Therefore: width = sqrt(totalPixels * targetRatio) and height = sqrt(totalPixels / targetRatio)
const calculatedWidth = Math.round(Math.sqrt(totalPixels * targetRatio));
const calculatedHeight = Math.round(Math.sqrt(totalPixels / targetRatio));
// Ensure dimensions are even numbers (common requirement for video encoding)
const width = calculatedWidth % 2 === 0 ? calculatedWidth : calculatedWidth + 1;
const height = calculatedHeight % 2 === 0 ? calculatedHeight : calculatedHeight + 1;
return { width, height };
}, [videoAspectRatio, videoResolution]);
return currentVideoDimensions;
};

View File

@@ -3,10 +3,25 @@ import { createSelector, createSlice } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import type { SliceConfig } from 'app/store/types';
import { isPlainObject } from 'es-toolkit';
import type { ImageWithDims, RunwayDuration, Veo3Duration, Veo3Resolution } from 'features/controlLayers/store/types';
import type {
AspectRatioID,
ImageWithDims,
RunwayDuration,
RunwayResolution,
Veo3Duration,
Veo3Resolution,
} from 'features/controlLayers/store/types';
import {
isRunwayAspectRatioID,
isRunwayDurationID,
isRunwayResolution,
isVeo3AspectRatioID,
isVeo3DurationID,
isVeo3Resolution,
zAspectRatioID,
zImageWithDims,
zRunwayDurationID,
zRunwayResolution,
zVeo3DurationID,
zVeo3Resolution,
} from 'features/controlLayers/store/types';
@@ -21,20 +36,24 @@ const zVideoState = z.object({
startingFrameImage: zImageWithDims.nullable(),
generatedVideo: zVideoField.nullable(),
videoModel: zModelIdentifierField.nullable(),
videoResolution: zVeo3Resolution.nullable(),
videoDuration: zVeo3DurationID.or(zRunwayDurationID).nullable(),
videoResolution: zVeo3Resolution.or(zRunwayResolution),
videoDuration: zVeo3DurationID.or(zRunwayDurationID),
videoAspectRatio: zAspectRatioID,
});
export type VideoState = z.infer<typeof zVideoState>;
const getInitialState = (): VideoState => ({
_version: 1,
startingFrameImage: null,
generatedVideo: null,
videoModel: null,
videoResolution: '720p',
videoDuration: '8',
});
const getInitialState = (): VideoState => {
return {
_version: 1,
startingFrameImage: null,
generatedVideo: null,
videoModel: null,
videoResolution: '720p',
videoDuration: '8',
videoAspectRatio: '16:9',
};
};
const slice = createSlice({
name: 'video',
@@ -52,15 +71,41 @@ const slice = createSlice({
videoModelChanged: (state, action: PayloadAction<Veo3ModelConfig | RunwayModelConfig | null>) => {
const parsedModel = zModelIdentifierField.parse(action.payload);
state.videoModel = parsedModel;
if (parsedModel?.base === 'veo3') {
if (!state.videoResolution || !isVeo3Resolution(state.videoResolution)) {
state.videoResolution = '720p';
}
if (!state.videoDuration || !isVeo3DurationID(state.videoDuration)) {
state.videoDuration = '8';
}
if (!state.videoAspectRatio || !isVeo3AspectRatioID(state.videoAspectRatio)) {
state.videoAspectRatio = '16:9';
}
} else if (parsedModel?.base === 'runway') {
if (!state.videoResolution || !isRunwayResolution(state.videoResolution)) {
state.videoResolution = '720p';
}
if (!state.videoDuration || !isRunwayDurationID(state.videoDuration)) {
state.videoDuration = '5';
}
if (!state.videoAspectRatio || !isRunwayAspectRatioID(state.videoAspectRatio)) {
state.videoAspectRatio = '16:9';
}
}
},
videoResolutionChanged: (state, action: PayloadAction<Veo3Resolution | null>) => {
videoResolutionChanged: (state, action: PayloadAction<Veo3Resolution | RunwayResolution>) => {
state.videoResolution = action.payload;
},
videoDurationChanged: (state, action: PayloadAction<Veo3Duration | RunwayDuration | null>) => {
videoDurationChanged: (state, action: PayloadAction<Veo3Duration | RunwayDuration>) => {
state.videoDuration = action.payload;
},
videoAspectRatioChanged: (state, action: PayloadAction<AspectRatioID>) => {
state.videoAspectRatio = action.payload;
},
},
});
@@ -70,6 +115,7 @@ export const {
videoModelChanged,
videoResolutionChanged,
videoDurationChanged,
videoAspectRatioChanged,
} = slice.actions;
export const videoSliceConfig: SliceConfig<typeof slice> = {
@@ -96,6 +142,6 @@ export const selectVideoModel = createVideoSelector((video) => video.videoModel)
export const selectVideoModelKey = createVideoSelector((video) => video.videoModel?.key);
export const selectVideoResolution = createVideoSelector((video) => video.videoResolution);
export const selectVideoDuration = createVideoSelector((video) => video.videoDuration);
export const selectVideoAspectRatio = createVideoSelector((video) => video.videoAspectRatio);
export const selectIsVeo3 = createVideoSelector((video) => video.videoModel?.base === 'veo3');
export const selectIsRunway = createVideoSelector((video) => video.videoModel?.base === 'runway');

View File

@@ -1,19 +1,9 @@
import { Flex, StandaloneAccordion } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
aspectRatioIdChanged,
aspectRatioLockToggled,
heightChanged,
widthChanged,
} from 'features/controlLayers/store/paramsSlice';
import { RUNWAY_ASPECT_RATIOS, VEO3_RESOLUTIONS } from 'features/controlLayers/store/types';
import { Dimensions } from 'features/parameters/components/Dimensions/Dimensions';
import { ParamSeed } from 'features/parameters/components/Seed/ParamSeed';
import { ParamDuration } from 'features/parameters/components/Video/ParamDuration';
import { ParamResolution } from 'features/parameters/components/Video/ParamResolution';
import { selectVideoModel, videoResolutionChanged } from 'features/parameters/store/videoSlice';
import { VideoDimensions } from 'features/parameters/components/Video/VideoDimensions';
import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle';
import { memo, useEffect } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { StartingFrameImage } from './StartingFrameImage';
@@ -25,29 +15,6 @@ export const VideoSettingsAccordion = memo(() => {
id: 'video-settings',
defaultIsOpen: true,
});
const videoModel = useAppSelector(selectVideoModel);
const dispatch = useAppDispatch();
useEffect(() => {
// hack to get the default aspect ratio etc for models outside paramsSlice
if (videoModel?.base === 'runway') {
dispatch(aspectRatioIdChanged({ id: '16:9' }));
const { width, height } = RUNWAY_ASPECT_RATIOS['16:9'];
dispatch(widthChanged({ width, clamp: true }));
dispatch(heightChanged({ height, clamp: true }));
dispatch(aspectRatioLockToggled());
}
if (videoModel?.base === 'veo3') {
dispatch(aspectRatioIdChanged({ id: '16:9' }));
dispatch(videoResolutionChanged('720p'));
const { width, height } = VEO3_RESOLUTIONS['720p'];
dispatch(widthChanged({ width, clamp: true }));
dispatch(heightChanged({ height, clamp: true }));
dispatch(aspectRatioLockToggled());
}
}, [dispatch, videoModel]);
return (
<StandaloneAccordion
@@ -57,16 +24,15 @@ export const VideoSettingsAccordion = memo(() => {
onToggle={onToggleAccordion}
>
<Flex p={4} w="full" h="full" flexDir="column" data-testid="upscale-settings-accordion">
<Flex gap={4} flexDirection="column" width="full">
<Flex gap={1} flexDirection="column" width="full">
<Flex gap={4}>
<StartingFrameImage />
<Flex gap={4} flexDirection="column" width="full">
<VideoModelPicker />
<ParamDuration />
<ParamResolution />
</Flex>
</Flex>
<Dimensions />
<VideoDimensions />
<ParamSeed />
</Flex>
</Flex>