feat: refine video playground settings

This commit is contained in:
2026-05-11 22:52:53 +08:00
parent 0049b246c1
commit da1e19d0a9
4 changed files with 396 additions and 85 deletions
+4
View File
@@ -552,12 +552,16 @@ export async function createImageEditTask(
export async function createVideoGenerationTask( export async function createVideoGenerationTask(
token: string, token: string,
input: { input: {
audio?: boolean;
model: string; model: string;
prompt: string; prompt: string;
aspect_ratio?: string; aspect_ratio?: string;
count?: number; count?: number;
duration?: number;
duration_seconds?: number;
height?: number; height?: number;
n?: number; n?: number;
output_audio?: boolean;
resolution?: string; resolution?: string;
runMode?: string; runMode?: string;
simulation?: boolean; simulation?: boolean;
+13 -3
View File
@@ -130,8 +130,10 @@ export function PlaygroundPage(props: {
const mediaCapabilities = useMemo( const mediaCapabilities = useMemo(
() => props.mode === 'chat' () => props.mode === 'chat'
? undefined ? undefined
: deriveMediaModelCapabilities(activeModelOption?.models, props.mode, videoMode), : activeModelOption
[activeModelOption, props.mode, videoMode], ? deriveMediaModelCapabilities(activeModelOption.models, props.mode, videoMode, mediaSettings.resolution)
: undefined,
[activeModelOption, mediaSettings.resolution, props.mode, videoMode],
); );
useEffect(() => { useEffect(() => {
@@ -230,7 +232,7 @@ export function PlaygroundPage(props: {
const requestPayload = { const requestPayload = {
model: selectedModel, model: selectedModel,
prompt: trimmedPrompt, prompt: trimmedPrompt,
...mediaRequestPayload(runSettings), ...mediaRequestPayload(runSettings, runMode),
}; };
const response = runMode === 'video' const response = runMode === 'video'
? await createVideoGenerationTask(credential, requestPayload) ? await createVideoGenerationTask(credential, requestPayload)
@@ -1117,8 +1119,10 @@ function mediaSettingsFromStorage(value: unknown): MediaGenerationSettings {
aspectRatio: stringFromUnknown(record.aspectRatio) || fallback.aspectRatio, aspectRatio: stringFromUnknown(record.aspectRatio) || fallback.aspectRatio,
countPreset: countPresetFromStorage(record.countPreset, fallback.countPreset), countPreset: countPresetFromStorage(record.countPreset, fallback.countPreset),
customCount: numberFromUnknown(record.customCount, fallback.customCount, 1, 20), 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), height: numberFromUnknown(record.height, fallback.height, 128, 8192),
outputMode: record.outputMode === 'group' ? 'group' : 'single', outputMode: record.outputMode === 'group' ? 'group' : 'single',
outputAudio: booleanFromUnknown(record.outputAudio ?? record.output_audio ?? record.audio, fallback.outputAudio),
resolution: stringFromUnknown(record.resolution) || fallback.resolution, resolution: stringFromUnknown(record.resolution) || fallback.resolution,
width: numberFromUnknown(record.width, fallback.width, 128, 8192), 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))); 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) { function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms)); return new Promise((resolve) => window.setTimeout(resolve, ms));
} }
+299 -82
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react'; import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
import type { GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts'; import type { GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import Segmented from 'antd/es/segmented';
import Slider from 'antd/es/slider';
import { import {
Download, Download,
Edit3, Edit3,
@@ -25,8 +27,10 @@ export interface MediaGenerationSettings {
aspectRatio: string; aspectRatio: string;
countPreset: MediaCountPreset; countPreset: MediaCountPreset;
customCount: number; customCount: number;
durationSeconds: number;
height: number; height: number;
outputMode: MediaOutputMode; outputMode: MediaOutputMode;
outputAudio: boolean;
resolution: MediaResolution; resolution: MediaResolution;
width: number; width: number;
} }
@@ -57,8 +61,12 @@ interface ResolutionOption {
export interface MediaModelCapabilities { export interface MediaModelCapabilities {
aspectRatios: string[]; aspectRatios: string[];
durationOptions: number[];
durationRange: [number, number];
durationStep: number;
maxCount: number; maxCount: number;
resolutions: MediaResolution[]; resolutions: MediaResolution[];
supportsAudio: boolean;
supportsGroup: boolean; supportsGroup: boolean;
} }
@@ -83,7 +91,7 @@ const aspectRatioOptions: AspectRatioOption[] = [
const resolutionOptions: ResolutionOption[] = [ const resolutionOptions: ResolutionOption[] = [
{ value: '1K', label: '标准 1K', modes: ['image'] }, { value: '1K', label: '标准 1K', modes: ['image'] },
{ value: '2K', label: '高清 2K', 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: '480p', label: '标清 480p', modes: ['video'] },
{ value: '720p', label: '高清 720p', modes: ['video'] }, { value: '720p', label: '高清 720p', modes: ['video'] },
{ value: '1080p', label: '全高清 1080p', modes: ['video'] }, { value: '1080p', label: '全高清 1080p', modes: ['video'] },
@@ -103,8 +111,10 @@ export function defaultMediaGenerationSettings(): MediaGenerationSettings {
aspectRatio: '1:1', aspectRatio: '1:1',
countPreset: 1, countPreset: 1,
customCount: 6, customCount: 6,
durationSeconds: 5,
height: 2048, height: 2048,
outputMode: 'single', outputMode: 'single',
outputAudio: true,
resolution: '2K', resolution: '2K',
width: 2048, width: 2048,
}; };
@@ -116,7 +126,18 @@ export function mediaOutputCount(settings: MediaGenerationSettings) {
return clampNumber(raw, 1, 20); 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 count = mediaOutputCount(settings);
const size = `${settings.width}x${settings.height}`; const size = `${settings.width}x${settings.height}`;
const highQuality = settings.resolution === '4K' || settings.resolution === '2160p'; const highQuality = settings.resolution === '4K' || settings.resolution === '2160p';
@@ -136,14 +157,24 @@ export function deriveMediaModelCapabilities(
models: PlatformModel[] | PlatformModel | undefined, models: PlatformModel[] | PlatformModel | undefined,
mode: Exclude<PlaygroundMode, 'chat'>, mode: Exclude<PlaygroundMode, 'chat'>,
contextKey?: string, contextKey?: string,
resolution?: string,
): MediaModelCapabilities { ): MediaModelCapabilities {
const modelList = (Array.isArray(models) ? models : models ? [models] : []).filter(Boolean); const modelList = (Array.isArray(models) ? models : models ? [models] : []).filter(Boolean);
if (!modelList.length) return defaultMediaModelCapabilities(mode); 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 { return {
aspectRatios: intersectOptionValues(derived.map((item) => item.aspectRatios), aspectRatioOptions.map((item) => item.value)), 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))), maxCount: Math.max(1, Math.min(...derived.map((item) => item.maxCount))),
resolutions: intersectOptionValues(derived.map((item) => item.resolutions), resolutionOptionsForMode(mode).map((item) => item.value)), 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), supportsGroup: derived.every((item) => item.supportsGroup),
}; };
} }
@@ -155,6 +186,7 @@ export function normalizeMediaSettingsForCapabilities(
) { ) {
const aspectOptions = filterAspectRatioOptions(capabilities); const aspectOptions = filterAspectRatioOptions(capabilities);
const resolutionItems = filterResolutionOptions(capabilities, mode); const resolutionItems = filterResolutionOptions(capabilities, mode);
const durationOptions = durationValuesForCapabilities(capabilities);
const maxCount = Math.max(1, Math.min(capabilities.maxCount, 20)); const maxCount = Math.max(1, Math.min(capabilities.maxCount, 20));
const supportsGroup = capabilities.supportsGroup && maxCount > 1; const supportsGroup = capabilities.supportsGroup && maxCount > 1;
const next: MediaGenerationSettings = { 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, 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) { if (!supportsGroup) {
next.countPreset = 1; next.countPreset = 1;
next.customCount = 1; next.customCount = 1;
@@ -191,13 +232,21 @@ export function MediaSettingsPopover(props: {
}) { }) {
const capabilities = props.capabilities ?? defaultMediaModelCapabilities(props.mode); const capabilities = props.capabilities ?? defaultMediaModelCapabilities(props.mode);
const aspectOptions = filterAspectRatioOptions(capabilities); const aspectOptions = filterAspectRatioOptions(capabilities);
const resolutionItems = resolutionOptionsForMode(props.mode); const resolutionItems = filterResolutionOptions(capabilities, props.mode);
const enabledResolutions = new Set(filterResolutionOptions(capabilities, props.mode).map((item) => item.value)); 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 maxCount = Math.max(1, Math.min(capabilities.maxCount, 20));
const supportsGroup = capabilities.supportsGroup && maxCount > 1; const supportsGroup = capabilities.supportsGroup && maxCount > 1;
const countOptions = countPresetOptions.filter((item) => item.value === 'custom' ? maxCount > 4 : item.value <= Math.min(4, maxCount)); const countOptions = countPresetOptions.filter((item) => item.value === 'custom' ? maxCount > 4 : item.value <= Math.min(4, maxCount));
const count = mediaOutputCount(props.settings); const count = isImageMode ? mediaOutputCount(props.settings) : 1;
const unit = props.mode === 'video' ? '条' : '张'; const unit = '张';
function patch(next: Partial<MediaGenerationSettings>) { function patch(next: Partial<MediaGenerationSettings>) {
props.onChange(normalizeMediaSettingsForCapabilities({ ...props.settings, ...next }, capabilities, props.mode)); props.onChange(normalizeMediaSettingsForCapabilities({ ...props.settings, ...next }, capabilities, props.mode));
@@ -247,8 +296,7 @@ export function MediaSettingsPopover(props: {
type="button" type="button"
key={item.value} key={item.value}
data-active={props.settings.resolution === item.value} data-active={props.settings.resolution === item.value}
disabled={!enabledResolutions.has(item.value)} onClick={() => patch({ resolution: item.value })}
onClick={() => enabledResolutions.has(item.value) && patch({ resolution: item.value })}
> >
{item.label} {item.label}
{item.value === '4K' && <Sparkles size={15} />} {item.value === '4K' && <Sparkles size={15} />}
@@ -257,85 +305,130 @@ export function MediaSettingsPopover(props: {
</div> </div>
</section> </section>
<section className="mediaSettingsSection"> {isVideoMode && (
<span className="mediaSettingsLabel"></span> <>
<div className="mediaSizeRow"> <section className="mediaSettingsSection">
<label> <span className="mediaSettingsLabel"></span>
<span>W</span> <Segmented
<Input block
inputMode="numeric" className="mediaAudioSegment"
min={128} options={[
size="sm" { label: '开启', value: 'on', disabled: !capabilities.supportsAudio },
type="number" { label: '关闭', value: 'off' },
value={props.settings.width} ]}
onChange={(event) => patch({ width: clampNumber(Number(event.target.value), 128, 8192) })} value={props.settings.outputAudio && capabilities.supportsAudio ? 'on' : 'off'}
onChange={(value) => patch({ outputAudio: value === 'on' })}
/> />
</label> {!capabilities.supportsAudio && (
<Link2 size={18} /> <p className="mediaUnsupportedNote"></p>
<label> )}
<span>H</span> </section>
<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"> <section className="mediaSettingsSection">
<span className="mediaSettingsLabel"></span> <div className="mediaSettingsTitleRow">
<div className="mediaOutputModeSegment"> <span className="mediaSettingsLabel"></span>
<button type="button" data-active={props.settings.outputMode === 'single'} onClick={() => patch({ countPreset: 1, outputMode: 'single' })}> <strong>{selectedDuration}s</strong>
<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> </div>
{props.settings.countPreset === 'custom' && props.settings.outputMode === 'group' && ( <Slider
<label className="mediaCustomCount"> className="mediaDurationSlider"
<span></span> 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 <Input
inputMode="numeric" inputMode="numeric"
min={2} min={128}
max={maxCount}
size="sm" size="sm"
type="number" type="number"
value={props.settings.customCount} value={props.settings.width}
onChange={(event) => patch({ customCount: clampNumber(Number(event.target.value), 2, maxCount) })} onChange={(event) => patch({ width: clampNumber(Number(event.target.value), 128, 8192) })}
/> />
</label> </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="mediaSettingsHint">
) : ( <Sparkles size={14} />
<p className="mediaUnsupportedNote"></p> {count} / {unit}
)} </p>
<p className="mediaSettingsHint"> </section>
<Sparkles size={14} /> </>
{count} / {unit} )}
</p>
</section>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
@@ -515,11 +608,11 @@ function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode,
} }
function mediaSettingsSummary(settings: MediaGenerationSettings, mode: Exclude<PlaygroundMode, 'chat'>) { 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; 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' ? '单图' : '组图'; 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) { function mediaStatusText(run: MediaGenerationRun) {
@@ -568,10 +661,12 @@ function deriveSingleMediaModelCapabilities(
model: PlatformModel, model: PlatformModel,
mode: Exclude<PlaygroundMode, 'chat'>, mode: Exclude<PlaygroundMode, 'chat'>,
contextKey?: string, contextKey?: string,
resolution?: string,
): MediaModelCapabilities { ): MediaModelCapabilities {
const source = mergeCapabilityRecords(model.capabilities, model.capabilityOverride); const source = mergeCapabilityRecords(model.capabilities, model.capabilityOverride);
const typeKeys = capabilityTypeKeys(model, source, mode, contextKey); const typeKeys = capabilityTypeKeys(model, source, mode, contextKey);
const defaultCapabilities = defaultMediaModelCapabilities(mode); const defaultCapabilities = defaultMediaModelCapabilities(mode);
const durationScopes = [resolution, contextKey];
const resolutionValues = normalizeResolutionValues(stringListFromCapability(firstCapabilityValue(source, typeKeys, ['output_resolutions']), [contextKey])); const resolutionValues = normalizeResolutionValues(stringListFromCapability(firstCapabilityValue(source, typeKeys, ['output_resolutions']), [contextKey]));
const allowedAspectValues = normalizeAspectRatioValues(stringListFromCapability(firstCapabilityValue(source, typeKeys, ['aspect_ratio_allowed']), [contextKey])); const allowedAspectValues = normalizeAspectRatioValues(stringListFromCapability(firstCapabilityValue(source, typeKeys, ['aspect_ratio_allowed']), [contextKey]));
const ratioRange = ratioRangeFromValue(firstCapabilityValue(source, typeKeys, ['aspect_ratio_range'])); const ratioRange = ratioRangeFromValue(firstCapabilityValue(source, typeKeys, ['aspect_ratio_range']));
@@ -583,23 +678,36 @@ function deriveSingleMediaModelCapabilities(
: allowedAspectValues.length ? allowedAspectValues : rangedAspectValues; : allowedAspectValues.length ? allowedAspectValues : rangedAspectValues;
const maxCountValue = numberFromUnknown(firstCapabilityValue(source, typeKeys, countCapabilityKeys(mode))); const maxCountValue = numberFromUnknown(firstCapabilityValue(source, typeKeys, countCapabilityKeys(mode)));
const explicitGroupSupport = boolFromUnknown(firstCapabilityValue(source, typeKeys, groupCapabilityKeys(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 maxCount = explicitGroupSupport === false ? 1 : clampNumber(maxCountValue ?? defaultCapabilities.maxCount, 1, 20);
const supportsGroup = explicitGroupSupport === false ? false : maxCount > 1; const supportsGroup = explicitGroupSupport === false ? false : maxCount > 1;
return { return {
aspectRatios: aspectRatios.length ? aspectRatios : defaultCapabilities.aspectRatios, aspectRatios: aspectRatios.length ? aspectRatios : defaultCapabilities.aspectRatios,
durationOptions,
durationRange,
durationStep,
maxCount, maxCount,
resolutions: resolutionValues.length ? resolutionValues : defaultCapabilities.resolutions, resolutions: resolutionValues.length ? resolutionValues : defaultCapabilities.resolutions,
supportsAudio: explicitAudioSupport ?? defaultCapabilities.supportsAudio,
supportsGroup, supportsGroup,
}; };
} }
function defaultMediaModelCapabilities(mode: Exclude<PlaygroundMode, 'chat'>): MediaModelCapabilities { function defaultMediaModelCapabilities(mode: Exclude<PlaygroundMode, 'chat'>): MediaModelCapabilities {
const isVideo = mode === 'video';
return { return {
aspectRatios: aspectRatioOptions.map((item) => item.value), aspectRatios: aspectRatioOptions.map((item) => item.value),
durationOptions: [],
durationRange: isVideo ? [5, 10] : [1, 1],
durationStep: 1,
maxCount: 20, maxCount: 20,
resolutions: resolutionOptionsForMode(mode).map((item) => item.value), 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; 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'>) { function resolutionOptionsForMode(mode: Exclude<PlaygroundMode, 'chat'>) {
return resolutionOptions.filter((item) => item.modes.includes(mode)); return resolutionOptions.filter((item) => item.modes.includes(mode));
} }
@@ -627,6 +784,13 @@ function intersectOptionValues(values: string[][], fallback: string[]) {
return intersection.length ? intersection : nonEmptyValues[0]; 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( function capabilityTypeKeys(
model: PlatformModel, model: PlatformModel,
source: Record<string, unknown>, source: Record<string, unknown>,
@@ -664,6 +828,23 @@ function nestedCapabilityValue(source: Record<string, unknown>, typeKeys: string
return undefined; 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'>) { function groupCapabilityKeys(mode: Exclude<PlaygroundMode, 'chat'>) {
return mode === 'image' return mode === 'image'
? ['output_multiple_images', 'multiple_images', 'support_multiple_images', 'supports_group'] ? ['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)); 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[]) { function normalizeAspectRatioValues(values: string[]) {
const allowedValues = new Set(aspectRatioOptions.map((item) => item.value)); const allowedValues = new Set(aspectRatioOptions.map((item) => item.value));
return uniqueStrings(values.map((value) => { return uniqueStrings(values.map((value) => {
@@ -743,6 +933,14 @@ function ratioRangeFromValue(value: unknown): [number, number] | undefined {
return 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]) { function aspectRatioWithinRange(value: string, range: [number, number]) {
const [width, height] = value.split(':').map(Number); const [width, height] = value.split(':').map(Number);
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) return false; if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) return false;
@@ -771,6 +969,23 @@ function numberFromUnknown(value: unknown) {
return undefined; 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[]) { function uniqueStrings(values: string[]) {
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean))); 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 return left.aspectRatio === right.aspectRatio
&& left.countPreset === right.countPreset && left.countPreset === right.countPreset
&& left.customCount === right.customCount && left.customCount === right.customCount
&& left.durationSeconds === right.durationSeconds
&& left.height === right.height && left.height === right.height
&& left.outputMode === right.outputMode && left.outputMode === right.outputMode
&& left.outputAudio === right.outputAudio
&& left.resolution === right.resolution && left.resolution === right.resolution
&& left.width === right.width; && left.width === right.width;
} }
+80
View File
@@ -920,6 +920,19 @@
font-weight: var(--font-weight-medium); 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 { .mediaAspectGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, 44px); grid-template-columns: repeat(auto-fill, 44px);
@@ -1055,6 +1068,73 @@
color: #06a6bd; 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 { .mediaSizeRow {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr) auto;