feat: refine video playground settings

This commit is contained in:
wangbo 2026-05-11 22:52:53 +08:00
parent 0049b246c1
commit da1e19d0a9
4 changed files with 396 additions and 85 deletions

View File

@ -552,12 +552,16 @@ export async function createImageEditTask(
export async function createVideoGenerationTask(
token: string,
input: {
audio?: boolean;
model: string;
prompt: string;
aspect_ratio?: string;
count?: number;
duration?: number;
duration_seconds?: number;
height?: number;
n?: number;
output_audio?: boolean;
resolution?: string;
runMode?: string;
simulation?: boolean;

View File

@ -130,8 +130,10 @@ export function PlaygroundPage(props: {
const mediaCapabilities = useMemo(
() => props.mode === 'chat'
? undefined
: deriveMediaModelCapabilities(activeModelOption?.models, props.mode, videoMode),
[activeModelOption, props.mode, videoMode],
: activeModelOption
? deriveMediaModelCapabilities(activeModelOption.models, props.mode, videoMode, mediaSettings.resolution)
: undefined,
[activeModelOption, mediaSettings.resolution, props.mode, videoMode],
);
useEffect(() => {
@ -230,7 +232,7 @@ export function PlaygroundPage(props: {
const requestPayload = {
model: selectedModel,
prompt: trimmedPrompt,
...mediaRequestPayload(runSettings),
...mediaRequestPayload(runSettings, runMode),
};
const response = runMode === 'video'
? await createVideoGenerationTask(credential, requestPayload)
@ -1117,8 +1119,10 @@ function mediaSettingsFromStorage(value: unknown): MediaGenerationSettings {
aspectRatio: stringFromUnknown(record.aspectRatio) || fallback.aspectRatio,
countPreset: countPresetFromStorage(record.countPreset, fallback.countPreset),
customCount: numberFromUnknown(record.customCount, fallback.customCount, 1, 20),
durationSeconds: numberFromUnknown(record.durationSeconds ?? record.duration_seconds ?? record.duration, fallback.durationSeconds, 1, 3600),
height: numberFromUnknown(record.height, fallback.height, 128, 8192),
outputMode: record.outputMode === 'group' ? 'group' : 'single',
outputAudio: booleanFromUnknown(record.outputAudio ?? record.output_audio ?? record.audio, fallback.outputAudio),
resolution: stringFromUnknown(record.resolution) || fallback.resolution,
width: numberFromUnknown(record.width, fallback.width, 128, 8192),
};
@ -1159,6 +1163,12 @@ function numberFromUnknown(value: unknown, fallback: number, min: number, max: n
return Math.min(max, Math.max(min, Math.round(number)));
}
function booleanFromUnknown(value: unknown, fallback: boolean) {
if (value === true || value === 'true') return true;
if (value === false || value === 'false') return false;
return fallback;
}
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}

View File

@ -1,5 +1,7 @@
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
import type { GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import Segmented from 'antd/es/segmented';
import Slider from 'antd/es/slider';
import {
Download,
Edit3,
@ -25,8 +27,10 @@ export interface MediaGenerationSettings {
aspectRatio: string;
countPreset: MediaCountPreset;
customCount: number;
durationSeconds: number;
height: number;
outputMode: MediaOutputMode;
outputAudio: boolean;
resolution: MediaResolution;
width: number;
}
@ -57,8 +61,12 @@ interface ResolutionOption {
export interface MediaModelCapabilities {
aspectRatios: string[];
durationOptions: number[];
durationRange: [number, number];
durationStep: number;
maxCount: number;
resolutions: MediaResolution[];
supportsAudio: boolean;
supportsGroup: boolean;
}
@ -83,7 +91,7 @@ const aspectRatioOptions: AspectRatioOption[] = [
const resolutionOptions: ResolutionOption[] = [
{ value: '1K', label: '标准 1K', modes: ['image'] },
{ value: '2K', label: '高清 2K', modes: ['image'] },
{ value: '4K', label: '超清 4K', modes: ['image', 'video'] },
{ value: '4K', label: '超清 4K', modes: ['image'] },
{ value: '480p', label: '标清 480p', modes: ['video'] },
{ value: '720p', label: '高清 720p', modes: ['video'] },
{ value: '1080p', label: '全高清 1080p', modes: ['video'] },
@ -103,8 +111,10 @@ export function defaultMediaGenerationSettings(): MediaGenerationSettings {
aspectRatio: '1:1',
countPreset: 1,
customCount: 6,
durationSeconds: 5,
height: 2048,
outputMode: 'single',
outputAudio: true,
resolution: '2K',
width: 2048,
};
@ -116,7 +126,18 @@ export function mediaOutputCount(settings: MediaGenerationSettings) {
return clampNumber(raw, 1, 20);
}
export function mediaRequestPayload(settings: MediaGenerationSettings) {
export function mediaRequestPayload(settings: MediaGenerationSettings, mode: Exclude<PlaygroundMode, 'chat'>) {
if (mode === 'video') {
return {
aspect_ratio: settings.aspectRatio === 'auto' ? undefined : settings.aspectRatio,
audio: settings.outputAudio,
duration: settings.durationSeconds,
duration_seconds: settings.durationSeconds,
output_audio: settings.outputAudio,
resolution: settings.resolution,
};
}
const count = mediaOutputCount(settings);
const size = `${settings.width}x${settings.height}`;
const highQuality = settings.resolution === '4K' || settings.resolution === '2160p';
@ -136,14 +157,24 @@ export function deriveMediaModelCapabilities(
models: PlatformModel[] | PlatformModel | undefined,
mode: Exclude<PlaygroundMode, 'chat'>,
contextKey?: string,
resolution?: string,
): MediaModelCapabilities {
const modelList = (Array.isArray(models) ? models : models ? [models] : []).filter(Boolean);
if (!modelList.length) return defaultMediaModelCapabilities(mode);
const derived = modelList.map((model) => deriveSingleMediaModelCapabilities(model, mode, contextKey));
const derived = modelList.map((model) => deriveSingleMediaModelCapabilities(model, mode, contextKey, resolution));
if (derived.length === 1) return derived[0];
const durationOptions = intersectNumberValues(
derived.map((item) => durationValuesForCapabilities(item)),
durationValuesForCapabilities(defaultMediaModelCapabilities(mode)),
);
return {
aspectRatios: intersectOptionValues(derived.map((item) => item.aspectRatios), aspectRatioOptions.map((item) => item.value)),
durationOptions,
durationRange: durationRangeFromValues(durationOptions),
durationStep: 1,
maxCount: Math.max(1, Math.min(...derived.map((item) => item.maxCount))),
resolutions: intersectOptionValues(derived.map((item) => item.resolutions), resolutionOptionsForMode(mode).map((item) => item.value)),
supportsAudio: derived.every((item) => item.supportsAudio),
supportsGroup: derived.every((item) => item.supportsGroup),
};
}
@ -155,6 +186,7 @@ export function normalizeMediaSettingsForCapabilities(
) {
const aspectOptions = filterAspectRatioOptions(capabilities);
const resolutionItems = filterResolutionOptions(capabilities, mode);
const durationOptions = durationValuesForCapabilities(capabilities);
const maxCount = Math.max(1, Math.min(capabilities.maxCount, 20));
const supportsGroup = capabilities.supportsGroup && maxCount > 1;
const next: MediaGenerationSettings = {
@ -163,6 +195,15 @@ export function normalizeMediaSettingsForCapabilities(
resolution: resolutionItems.some((item) => item.value === settings.resolution) ? settings.resolution : resolutionItems[0]?.value ?? settings.resolution,
};
if (mode === 'video') {
next.countPreset = 1;
next.customCount = 1;
next.durationSeconds = closestDurationValue(settings.durationSeconds, durationOptions);
next.outputAudio = capabilities.supportsAudio ? settings.outputAudio : false;
next.outputMode = 'single';
return mediaSettingsEqual(settings, next) ? settings : next;
}
if (!supportsGroup) {
next.countPreset = 1;
next.customCount = 1;
@ -191,13 +232,21 @@ export function MediaSettingsPopover(props: {
}) {
const capabilities = props.capabilities ?? defaultMediaModelCapabilities(props.mode);
const aspectOptions = filterAspectRatioOptions(capabilities);
const resolutionItems = resolutionOptionsForMode(props.mode);
const enabledResolutions = new Set(filterResolutionOptions(capabilities, props.mode).map((item) => item.value));
const resolutionItems = filterResolutionOptions(capabilities, props.mode);
const isImageMode = props.mode === 'image';
const isVideoMode = props.mode === 'video';
const durationOptions = durationValuesForCapabilities(capabilities);
const selectedDuration = closestDurationValue(props.settings.durationSeconds, durationOptions);
const durationUsesExplicitOptions = capabilities.durationOptions.length > 0;
const durationMarks = durationSliderMarks(durationOptions, durationUsesExplicitOptions);
const durationMin = durationOptions[0] ?? props.settings.durationSeconds;
const durationMax = durationOptions[durationOptions.length - 1] ?? props.settings.durationSeconds;
const durationStep = durationUsesExplicitOptions ? null : capabilities.durationStep;
const maxCount = Math.max(1, Math.min(capabilities.maxCount, 20));
const supportsGroup = capabilities.supportsGroup && maxCount > 1;
const countOptions = countPresetOptions.filter((item) => item.value === 'custom' ? maxCount > 4 : item.value <= Math.min(4, maxCount));
const count = mediaOutputCount(props.settings);
const unit = props.mode === 'video' ? '条' : '张';
const count = isImageMode ? mediaOutputCount(props.settings) : 1;
const unit = '张';
function patch(next: Partial<MediaGenerationSettings>) {
props.onChange(normalizeMediaSettingsForCapabilities({ ...props.settings, ...next }, capabilities, props.mode));
@ -247,8 +296,7 @@ export function MediaSettingsPopover(props: {
type="button"
key={item.value}
data-active={props.settings.resolution === item.value}
disabled={!enabledResolutions.has(item.value)}
onClick={() => enabledResolutions.has(item.value) && patch({ resolution: item.value })}
onClick={() => patch({ resolution: item.value })}
>
{item.label}
{item.value === '4K' && <Sparkles size={15} />}
@ -257,85 +305,130 @@ export function MediaSettingsPopover(props: {
</div>
</section>
<section className="mediaSettingsSection">
<span className="mediaSettingsLabel"></span>
<div className="mediaSizeRow">
<label>
<span>W</span>
<Input
inputMode="numeric"
min={128}
size="sm"
type="number"
value={props.settings.width}
onChange={(event) => patch({ width: clampNumber(Number(event.target.value), 128, 8192) })}
{isVideoMode && (
<>
<section className="mediaSettingsSection">
<span className="mediaSettingsLabel"></span>
<Segmented
block
className="mediaAudioSegment"
options={[
{ label: '开启', value: 'on', disabled: !capabilities.supportsAudio },
{ label: '关闭', value: 'off' },
]}
value={props.settings.outputAudio && capabilities.supportsAudio ? 'on' : 'off'}
onChange={(value) => patch({ outputAudio: value === 'on' })}
/>
</label>
<Link2 size={18} />
<label>
<span>H</span>
<Input
inputMode="numeric"
min={128}
size="sm"
type="number"
value={props.settings.height}
onChange={(event) => patch({ height: clampNumber(Number(event.target.value), 128, 8192) })}
/>
</label>
<strong>PX</strong>
</div>
</section>
{!capabilities.supportsAudio && (
<p className="mediaUnsupportedNote"></p>
)}
</section>
<section className="mediaSettingsSection">
<span className="mediaSettingsLabel"></span>
<div className="mediaOutputModeSegment">
<button type="button" data-active={props.settings.outputMode === 'single'} onClick={() => patch({ countPreset: 1, outputMode: 'single' })}>
<ImageIcon size={15} />
</button>
<button type="button" disabled={!supportsGroup} data-active={props.settings.outputMode === 'group'} onClick={() => patch({ countPreset: 2, outputMode: 'group' })}>
<Images size={15} />
{supportsGroup ? '组图' : '不支持组图'}
</button>
</div>
{supportsGroup ? (
<>
<div className="mediaCountGrid">
{countOptions.map((item) => (
<button
type="button"
key={String(item.value)}
data-active={props.settings.countPreset === item.value}
onClick={() => selectCountPreset(item.value)}
>
{item.value === 'custom' ? item.label : `${item.label}${unit}`}
</button>
))}
<section className="mediaSettingsSection">
<div className="mediaSettingsTitleRow">
<span className="mediaSettingsLabel"></span>
<strong>{selectedDuration}s</strong>
</div>
{props.settings.countPreset === 'custom' && props.settings.outputMode === 'group' && (
<label className="mediaCustomCount">
<span></span>
<Slider
className="mediaDurationSlider"
disabled={durationOptions.length <= 1}
marks={durationMarks}
max={durationMax}
min={durationMin}
step={durationStep}
tooltip={{ formatter: (value) => `${value ?? selectedDuration}s` }}
value={selectedDuration}
onChange={(value) => {
if (typeof value === 'number') patch({ durationSeconds: value });
}}
/>
</section>
</>
)}
{isImageMode && (
<>
<section className="mediaSettingsSection">
<span className="mediaSettingsLabel"></span>
<div className="mediaSizeRow">
<label>
<span>W</span>
<Input
inputMode="numeric"
min={2}
max={maxCount}
min={128}
size="sm"
type="number"
value={props.settings.customCount}
onChange={(event) => patch({ customCount: clampNumber(Number(event.target.value), 2, maxCount) })}
value={props.settings.width}
onChange={(event) => patch({ width: clampNumber(Number(event.target.value), 128, 8192) })}
/>
</label>
<Link2 size={18} />
<label>
<span>H</span>
<Input
inputMode="numeric"
min={128}
size="sm"
type="number"
value={props.settings.height}
onChange={(event) => patch({ height: clampNumber(Number(event.target.value), 128, 8192) })}
/>
</label>
<strong>PX</strong>
</div>
</section>
<section className="mediaSettingsSection">
<span className="mediaSettingsLabel"></span>
<div className="mediaOutputModeSegment">
<button type="button" data-active={props.settings.outputMode === 'single'} onClick={() => patch({ countPreset: 1, outputMode: 'single' })}>
<ImageIcon size={15} />
</button>
<button type="button" disabled={!supportsGroup} data-active={props.settings.outputMode === 'group'} onClick={() => patch({ countPreset: 2, outputMode: 'group' })}>
<Images size={15} />
{supportsGroup ? '组图' : '不支持组图'}
</button>
</div>
{supportsGroup ? (
<>
<div className="mediaCountGrid">
{countOptions.map((item) => (
<button
type="button"
key={String(item.value)}
data-active={props.settings.countPreset === item.value}
onClick={() => selectCountPreset(item.value)}
>
{item.value === 'custom' ? item.label : `${item.label}${unit}`}
</button>
))}
</div>
{props.settings.countPreset === 'custom' && props.settings.outputMode === 'group' && (
<label className="mediaCustomCount">
<span></span>
<Input
inputMode="numeric"
min={2}
max={maxCount}
size="sm"
type="number"
value={props.settings.customCount}
onChange={(event) => patch({ customCount: clampNumber(Number(event.target.value), 2, maxCount) })}
/>
</label>
)}
</>
) : (
<p className="mediaUnsupportedNote"></p>
)}
</>
) : (
<p className="mediaUnsupportedNote"></p>
)}
<p className="mediaSettingsHint">
<Sparkles size={14} />
{count} / {unit}
</p>
</section>
<p className="mediaSettingsHint">
<Sparkles size={14} />
{count} / {unit}
</p>
</section>
</>
)}
</PopoverContent>
</Popover>
);
@ -515,11 +608,11 @@ function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode,
}
function mediaSettingsSummary(settings: MediaGenerationSettings, mode: Exclude<PlaygroundMode, 'chat'>) {
const count = mediaOutputCount(settings);
const unit = mode === 'video' ? '条' : '张';
const resolutionLabel = resolutionOptionsForMode(mode).find((item) => item.value === settings.resolution)?.label ?? settings.resolution;
if (mode === 'video') return `${settings.aspectRatio} | ${resolutionLabel} | ${settings.durationSeconds}s | ${settings.outputAudio ? '有声音' : '无声音'}`;
const count = mediaOutputCount(settings);
const modeLabel = settings.outputMode === 'single' ? '单图' : '组图';
return `${settings.aspectRatio} | ${resolutionLabel} | ${settings.width}x${settings.height} | ${modeLabel} ${count}${unit}`;
return `${settings.aspectRatio} | ${resolutionLabel} | ${settings.width}x${settings.height} | ${modeLabel} ${count}`;
}
function mediaStatusText(run: MediaGenerationRun) {
@ -568,10 +661,12 @@ function deriveSingleMediaModelCapabilities(
model: PlatformModel,
mode: Exclude<PlaygroundMode, 'chat'>,
contextKey?: string,
resolution?: string,
): MediaModelCapabilities {
const source = mergeCapabilityRecords(model.capabilities, model.capabilityOverride);
const typeKeys = capabilityTypeKeys(model, source, mode, contextKey);
const defaultCapabilities = defaultMediaModelCapabilities(mode);
const durationScopes = [resolution, contextKey];
const resolutionValues = normalizeResolutionValues(stringListFromCapability(firstCapabilityValue(source, typeKeys, ['output_resolutions']), [contextKey]));
const allowedAspectValues = normalizeAspectRatioValues(stringListFromCapability(firstCapabilityValue(source, typeKeys, ['aspect_ratio_allowed']), [contextKey]));
const ratioRange = ratioRangeFromValue(firstCapabilityValue(source, typeKeys, ['aspect_ratio_range']));
@ -583,23 +678,36 @@ function deriveSingleMediaModelCapabilities(
: allowedAspectValues.length ? allowedAspectValues : rangedAspectValues;
const maxCountValue = numberFromUnknown(firstCapabilityValue(source, typeKeys, countCapabilityKeys(mode)));
const explicitGroupSupport = boolFromUnknown(firstCapabilityValue(source, typeKeys, groupCapabilityKeys(mode)));
const durationRange = durationRangeFromValue(scopedCapabilityValue(firstCapabilityValue(source, typeKeys, ['duration_range']), durationScopes)) ?? defaultCapabilities.durationRange;
const durationStep = durationStepFromValue(scopedCapabilityValue(firstCapabilityValue(source, typeKeys, ['duration_step']), durationScopes), defaultCapabilities.durationStep);
const durationOptions = normalizeDurationValues(numberListFromCapability(scopedCapabilityValue(firstCapabilityValue(source, typeKeys, ['duration_options']), durationScopes)));
const explicitAudioSupport = boolFromUnknown(firstCapabilityValue(source, typeKeys, ['output_audio']));
const maxCount = explicitGroupSupport === false ? 1 : clampNumber(maxCountValue ?? defaultCapabilities.maxCount, 1, 20);
const supportsGroup = explicitGroupSupport === false ? false : maxCount > 1;
return {
aspectRatios: aspectRatios.length ? aspectRatios : defaultCapabilities.aspectRatios,
durationOptions,
durationRange,
durationStep,
maxCount,
resolutions: resolutionValues.length ? resolutionValues : defaultCapabilities.resolutions,
supportsAudio: explicitAudioSupport ?? defaultCapabilities.supportsAudio,
supportsGroup,
};
}
function defaultMediaModelCapabilities(mode: Exclude<PlaygroundMode, 'chat'>): MediaModelCapabilities {
const isVideo = mode === 'video';
return {
aspectRatios: aspectRatioOptions.map((item) => item.value),
durationOptions: [],
durationRange: isVideo ? [5, 10] : [1, 1],
durationStep: 1,
maxCount: 20,
resolutions: resolutionOptionsForMode(mode).map((item) => item.value),
supportsGroup: true,
supportsAudio: false,
supportsGroup: mode === 'image',
};
}
@ -616,6 +724,55 @@ function filterResolutionOptions(capabilities: MediaModelCapabilities, mode: Exc
return items.length ? items : modeOptions;
}
function durationValuesForCapabilities(capabilities: MediaModelCapabilities) {
const [min, max] = capabilities.durationRange;
const step = Math.max(1, capabilities.durationStep);
const options = capabilities.durationOptions.length
? capabilities.durationOptions
: expandDurationRange(min, max, step);
const filtered = options.filter((value) => (
value >= min
&& value <= max
&& durationMatchesStep(value, min, step)
));
return filtered.length ? filtered : [min];
}
function expandDurationRange(min: number, max: number, step: number) {
const values: number[] = [];
for (let value = min; value <= max; value += step) {
values.push(value);
}
if (!values.includes(max)) values.push(max);
return normalizeDurationValues(values);
}
function durationMatchesStep(value: number, min: number, step: number) {
if (step <= 1) return true;
const ratio = (value - min) / step;
return Math.abs(ratio - Math.round(ratio)) < 0.000001;
}
function closestDurationValue(value: number, values: number[]) {
if (!values.length) return value;
return values.reduce((closest, item) => (
Math.abs(item - value) < Math.abs(closest - value) ? item : closest
), values[0]);
}
function durationSliderMarks(values: number[], showAll: boolean) {
const markValues = showAll || values.length <= 16
? values
: uniqueNumberValues([values[0], values[values.length - 1]]);
return Object.fromEntries(markValues.map((value) => [value, `${value}s`]));
}
function durationRangeFromValues(values: number[]): [number, number] {
const normalized = normalizeDurationValues(values);
if (!normalized.length) return [5, 10];
return [normalized[0], normalized[normalized.length - 1]];
}
function resolutionOptionsForMode(mode: Exclude<PlaygroundMode, 'chat'>) {
return resolutionOptions.filter((item) => item.modes.includes(mode));
}
@ -627,6 +784,13 @@ function intersectOptionValues(values: string[][], fallback: string[]) {
return intersection.length ? intersection : nonEmptyValues[0];
}
function intersectNumberValues(values: number[][], fallback: number[]) {
const nonEmptyValues = values.filter((items) => items.length > 0);
if (!nonEmptyValues.length) return fallback;
const intersection = fallback.filter((item) => nonEmptyValues.every((items) => items.includes(item)));
return intersection.length ? intersection : nonEmptyValues[0];
}
function capabilityTypeKeys(
model: PlatformModel,
source: Record<string, unknown>,
@ -664,6 +828,23 @@ function nestedCapabilityValue(source: Record<string, unknown>, typeKeys: string
return undefined;
}
function scopedCapabilityValue(value: unknown, scopes: Array<string | undefined>): unknown {
const record = recordFromUnknown(value);
if (!record) return value;
const scopeKeys = uniqueStrings(scopes.filter((item): item is string => Boolean(item)));
for (const scope of scopeKeys) {
const scoped = record[scope];
if (hasCapabilityValue(scoped)) {
return scopedCapabilityValue(scoped, scopeKeys.filter((item) => item !== scope));
}
}
for (const scope of ['default', 'all', '*']) {
const scoped = record[scope];
if (hasCapabilityValue(scoped)) return scoped;
}
return value;
}
function groupCapabilityKeys(mode: Exclude<PlaygroundMode, 'chat'>) {
return mode === 'image'
? ['output_multiple_images', 'multiple_images', 'support_multiple_images', 'supports_group']
@ -700,6 +881,15 @@ function stringListFromCapability(value: unknown, preferredKeys: Array<string |
return Object.values(record).flatMap((item) => stringListFromCapability(item));
}
function numberListFromCapability(value: unknown): number[] {
if (Array.isArray(value)) return value.flatMap((item) => numberListFromCapability(item));
if (typeof value === 'number') return [value];
if (typeof value === 'string') return value.match(/-?\d+(?:\.\d+)?/g)?.map(Number).filter(Number.isFinite) ?? [];
const record = recordFromUnknown(value);
if (!record) return [];
return Object.values(record).flatMap((item) => numberListFromCapability(item));
}
function normalizeAspectRatioValues(values: string[]) {
const allowedValues = new Set(aspectRatioOptions.map((item) => item.value));
return uniqueStrings(values.map((value) => {
@ -743,6 +933,14 @@ function ratioRangeFromValue(value: unknown): [number, number] | undefined {
return undefined;
}
function durationRangeFromValue(value: unknown): [number, number] | undefined {
const range = ratioRangeFromValue(value);
if (!range) return undefined;
const min = Math.max(1, Math.round(range[0]));
const max = Math.max(1, Math.round(range[1]));
return [Math.min(min, max), Math.max(min, max)];
}
function aspectRatioWithinRange(value: string, range: [number, number]) {
const [width, height] = value.split(':').map(Number);
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) return false;
@ -771,6 +969,23 @@ function numberFromUnknown(value: unknown) {
return undefined;
}
function durationStepFromValue(value: unknown, fallback: number) {
const parsed = numberFromUnknown(value);
if (!parsed || parsed <= 0) return fallback;
return Math.max(1, Math.round(parsed));
}
function normalizeDurationValues(values: number[]) {
return uniqueNumberValues(values
.filter((item) => Number.isFinite(item) && item > 0)
.map((item) => Math.round(item)))
.sort((left, right) => left - right);
}
function uniqueNumberValues(values: number[]) {
return Array.from(new Set(values));
}
function uniqueStrings(values: string[]) {
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
}
@ -779,8 +994,10 @@ function mediaSettingsEqual(left: MediaGenerationSettings, right: MediaGeneratio
return left.aspectRatio === right.aspectRatio
&& left.countPreset === right.countPreset
&& left.customCount === right.customCount
&& left.durationSeconds === right.durationSeconds
&& left.height === right.height
&& left.outputMode === right.outputMode
&& left.outputAudio === right.outputAudio
&& left.resolution === right.resolution
&& left.width === right.width;
}

View File

@ -920,6 +920,19 @@
font-weight: var(--font-weight-medium);
}
.mediaSettingsTitleRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mediaSettingsTitleRow strong {
color: var(--primary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.mediaAspectGrid {
display: grid;
grid-template-columns: repeat(auto-fill, 44px);
@ -1055,6 +1068,73 @@
color: #06a6bd;
}
.mediaAudioSegment.ant-segmented {
width: 100%;
padding: 2px;
border-radius: var(--radius-md);
background: var(--muted);
}
.mediaAudioSegment .ant-segmented-item {
min-height: 34px;
color: var(--muted-foreground);
font-weight: var(--font-weight-semibold);
}
.mediaAudioSegment .ant-segmented-item-label {
min-height: 34px;
line-height: 34px;
}
.mediaAudioSegment .ant-segmented-item-selected {
background: var(--surface);
color: var(--primary);
}
.mediaAudioSegment .ant-segmented-item-disabled {
color: var(--muted-foreground);
opacity: 0.4;
}
.mediaDurationSlider.ant-slider {
margin: 8px 8px 18px;
}
.mediaDurationSlider .ant-slider-rail {
background: var(--border);
}
.mediaDurationSlider .ant-slider-track {
background: var(--primary);
}
.mediaDurationSlider .ant-slider-handle::after {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 65%, transparent);
}
.mediaDurationSlider .ant-slider-handle:hover::after,
.mediaDurationSlider .ant-slider-handle:focus::after {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 22%, transparent);
}
.mediaDurationSlider .ant-slider-dot {
border-color: var(--border);
background: var(--surface);
}
.mediaDurationSlider .ant-slider-dot-active {
border-color: var(--primary);
}
.mediaDurationSlider .ant-slider-mark-text {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
}
.mediaDurationSlider .ant-slider-mark-text-active {
color: var(--text-normal);
}
.mediaSizeRow {
display: grid;
grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr) auto;