diff --git a/docs/development/images/fractional-video-duration/en-US.png b/docs/development/images/fractional-video-duration/en-US.png new file mode 100644 index 00000000..037f2a92 Binary files /dev/null and b/docs/development/images/fractional-video-duration/en-US.png differ diff --git a/docs/development/images/fractional-video-duration/zh-CN.png b/docs/development/images/fractional-video-duration/zh-CN.png new file mode 100644 index 00000000..659d5d4d Binary files /dev/null and b/docs/development/images/fractional-video-duration/zh-CN.png differ diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index dedc3237..96645333 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -35,6 +35,8 @@ For a self-hosted chat service, allow at least 8192 tokens of context: the application sends `max_tokens: 8192`, so a smaller `--max-model-len` (for example vLLM's 4096 default) rejects every request with HTTP 400. +For vLLM-Omni video connections, enter fractional seconds directly, for example `6.583333`. The CLI accepts `opendirector video create --duration 6.583333` together with the other required options. Read `opendirector video create --help` and `opendirector model list` first; `inputRules.fractionalDuration` identifies support, and each output's duration list defines the allowed range. H3 can align output to its supported frame count, so inspect the actual result duration before assembling segments. + For detailed video settings, see [Video model capability configuration (Chinese)](video-model-capabilities.md). When no explicit output specification is provided, use **Auto** or **Service default duration**. Automatic region selection in images and video matting require a model with segmentation capabilities. Image generation and editing still require the corresponding generation or editing model. diff --git a/docs/guides/user-guide.zh-CN.md b/docs/guides/user-guide.zh-CN.md index 377ba8cf..08495569 100644 --- a/docs/guides/user-guide.zh-CN.md +++ b/docs/guides/user-guide.zh-CN.md @@ -47,6 +47,8 @@ HTTP 400。 在设置的“通用”页中可切换主题、调整去除水印选项。水印设置仅影响后续生成的内容,实际效果取决于模型服务是否支持。 +vLLM-Omni 视频连接支持直接输入小数秒,例如 `6.583333`。CLI 可使用 `opendirector video create --duration 6.583333` 并填写其余必需参数。先运行 `opendirector video create --help` 和 `opendirector model list`;`inputRules.fractionalDuration` 表示支持小数秒,各输出规格的时长列表定义允许范围。H3 可能按模型支持的帧数对齐,拼接分段前应核对结果的实际时长。 + ## 制作第一支短片 建议先完成一个镜头的生成与导出,再扩展到多段素材。 diff --git a/packages/core/src/opendirector/application/creation_generation.py b/packages/core/src/opendirector/application/creation_generation.py index fb5fb207..6b99ce80 100644 --- a/packages/core/src/opendirector/application/creation_generation.py +++ b/packages/core/src/opendirector/application/creation_generation.py @@ -29,6 +29,7 @@ from opendirector.domain.input_rules import input_rules from opendirector.domain.minimax_speech import is_minimax_speech from opendirector.domain.projects import ProjectError +from opendirector.domain.rule_facts import duration_matches from opendirector.domain.video_output import apply_video_resolution from opendirector.ports.gateway import gateway from opendirector.integrations.http_settings import MODEL_JOB_CONTEXT @@ -212,7 +213,7 @@ def _parameters(self, spec, config, node, records): if node.get('videoMode') == 'continuation': raise ProjectError('接续沿用上一段视频的画幅,请将比例设为自动。', 422) caps = rules.get('videoCapabilities') or {} - choices = [item for item in caps.get('outputs', []) if seconds is None or seconds in item['durations']] + choices = [item for item in caps.get('outputs', []) if seconds is None or duration_matches(seconds, item['durations'], rules.get('fractionalDuration', False))] if not choices: raise ProjectError('指定视频比例前,请在模型接入中配置输出规格。', 422) preferred = next((item for item in choices if item['resolution'] == caps.get('defaultResolution')), choices[0]) diff --git a/packages/core/src/opendirector/cli.py b/packages/core/src/opendirector/cli.py index 2b66ec8a..9d00d2cc 100644 --- a/packages/core/src/opendirector/cli.py +++ b/packages/core/src/opendirector/cli.py @@ -434,7 +434,7 @@ def add_material(ctx: typer.Context, asset: Annotated[UUID, typer.Option(help="R def video_create(ctx: typer.Context, model: Annotated[str, typer.Option(help="Video model configuration UUID or unique exact model name from opendirector model list.")], prompt: Annotated[str, typer.Option(help="Video generation prompt.")], - duration: Annotated[float, typer.Option(min=0, help="Video duration in seconds.")], + duration: Annotated[float, typer.Option(min=0, help="Video duration in seconds, e.g. 6.583333. Fractional values require model inputRules.fractionalDuration; inspect model list for supported ranges. H3 may align the generated frame count.")], x: Annotated[float, typer.Option(help="Canvas X coordinate. Choose using the current item positions and sizes.")], y: Annotated[float, typer.Option(help="Canvas Y coordinate. Choose using the current item positions and sizes.")], first_frame: Annotated[UUID | None, typer.Option(help="Source image canvas item for first-frame mode.")] = None, diff --git a/packages/core/src/opendirector/domain/creation/generation.py b/packages/core/src/opendirector/domain/creation/generation.py index 91b6f0b4..9b4222ed 100644 --- a/packages/core/src/opendirector/domain/creation/generation.py +++ b/packages/core/src/opendirector/domain/creation/generation.py @@ -13,7 +13,7 @@ FASTH3_MEDIA_REFERENCE_LIMIT, FRAME_IMAGE_LIMIT, LYRICS_MAX_LENGTH, MAX_REFERENCES, MUSIC_SECONDS_INITIAL, MUSIC_SECONDS_MAX, MUSIC_SECONDS_MIN, PROMPT_MAX_LENGTH, REFERENCE_MEDIA_SECONDS, STORED_VOICE_PATTERN, TEXT_DEFAULT_MAX_TOKENS, TEXT_MAX_TOKENS, - effective_video_mode, generate_audio_options, + duration_matches, effective_video_mode, generate_audio_options, ) from .assets import AssetRef, MediaMetadata from .base import CanvasItemVersion, Contract, Digest, GenerationKind, NonNegative, ObjectVersion, PositiveInt, ProjectEntity, Revision, digest, unique @@ -36,7 +36,7 @@ class ImageParameters(Contract): class VideoParameters(Contract): generateAudio: bool = Field(default=True, description='Include audio; value must be allowed by model inputRules.generateAudioOptions.') videoMode: VideoMode = Field(default='first_frame', description='Video generation input mode.') - durationSeconds: Annotated[float, Field(strict=True, ge=1, le=60)] | None = Field(default=None, description='Duration in seconds.') + durationSeconds: Annotated[float, Field(strict=True, ge=1, le=60)] | None = Field(default=None, description='Duration in seconds; fractional values require model inputRules.fractionalDuration. Generated frames may be aligned by the model.') aspectRatio: str | None = Field(default=None, pattern=r'^[1-9]\d?:[1-9]\d?$', description='Output width-to-height ratio.') resolution: str = Field(default='auto', pattern=r'^(?:auto|[A-Z0-9][A-Z0-9_.-]{0,23})$', description='Requested output resolution.') @@ -490,5 +490,5 @@ def validate_generation(spec: GenerationSpec, model: ModelConfigPublic, referenc caps = rules.videoCapabilities if caps and params.durationSeconds is not None: choices = [output for output in caps.outputs if params.resolution == 'auto' or output.resolution == params.resolution] - if not any(params.durationSeconds in output.durations for output in choices): + if not any(duration_matches(params.durationSeconds, output.durations, rules.fractionalDuration) for output in choices): raise ValueError('当前时长与分辨率组合不受支持。') diff --git a/packages/core/src/opendirector/domain/creation/models.py b/packages/core/src/opendirector/domain/creation/models.py index 87484fea..65cdb960 100644 --- a/packages/core/src/opendirector/domain/creation/models.py +++ b/packages/core/src/opendirector/domain/creation/models.py @@ -46,6 +46,7 @@ class VoiceLibraryCapabilities(Contract): class InputRules(Contract): + fractionalDuration: bool = Field(default=False, exclude_if=lambda value: not value, description="Accept fractional video seconds within each output duration range.") profile: str = Field(min_length=1, max_length=100, description='Model capability profile.') voiceLibrary: VoiceLibraryCapabilities | None = Field(default=None, exclude_if=lambda value: value is None, description='Supported voice registry and adapter requirements; omitted for unsupported models.') diff --git a/packages/core/src/opendirector/domain/rule_facts.py b/packages/core/src/opendirector/domain/rule_facts.py index 756fd44a..91f96d75 100644 --- a/packages/core/src/opendirector/domain/rule_facts.py +++ b/packages/core/src/opendirector/domain/rule_facts.py @@ -102,6 +102,12 @@ def aspect_ratio_choices(spec): return ['auto'] +def duration_matches(seconds: float, durations: list[int] | tuple[int, ...], fractional: bool = False) -> bool: + if fractional: + return bool(durations) and min(durations) <= seconds <= max(durations) + return seconds in durations + + def resolution_choices(spec): """Sizes offered before submit. Callers report whether an image reference is connected.""" kind = spec['kind'] @@ -122,7 +128,7 @@ def resolution_choices(spec): duration = spec.get('durationSeconds') if outputs is not None and duration is not None: choices = [label for label in choices - if any(item['resolution'] == label and duration in item['durations'] for item in outputs)] + if any(item['resolution'] == label and duration_matches(duration, item['durations'], spec.get('fractionalDuration', False)) for item in outputs)] return ['auto', *choices] @@ -336,7 +342,14 @@ def derivation_vectors(): return ["auto"]; } +export function durationMatches(seconds: number, durations: readonly number[], fractional = false): boolean { + return fractional + ? durations.length > 0 && seconds >= Math.min(...durations) && seconds <= Math.max(...durations) + : durations.includes(seconds); +} + export function resolutionChoices(input: { + readonly fractionalDuration?: boolean; readonly kind: string; readonly videoMode?: string | null; readonly aspectRatio?: string | null; @@ -380,7 +393,7 @@ def derivation_vectors(): ? choices.filter((label) => outputs.some( (item) => - item.resolution === label && item.durations.includes(duration), + item.resolution === label && durationMatches(duration, item.durations, input.fractionalDuration), ), ) : choices; diff --git a/packages/core/src/opendirector/domain/video_capabilities.py b/packages/core/src/opendirector/domain/video_capabilities.py index 3d5f1e30..658b0a37 100644 --- a/packages/core/src/opendirector/domain/video_capabilities.py +++ b/packages/core/src/opendirector/domain/video_capabilities.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator from opendirector.domain.projects import ProjectError +from opendirector.domain.rule_facts import duration_matches from opendirector.domain.minimax_models import MINIMAX_MODELS, minimax_capabilities from opendirector.domain.kling_models import kling_capabilities from opendirector.domain.bigmodel_video import cogvideo_capabilities, validate_cogvideo_outputs @@ -97,6 +98,8 @@ def capability_fields(config, rules): validate_cogvideo_outputs(capabilities) if rules['profile'] == 'kling-official': validate_kling_outputs(capabilities, config['modelId']) + if config.get('kind') == 'video' and config.get('protocol') == 'vllm_omni': + rules['fractionalDuration'] = True rules['videoCapabilities'] = capabilities rules['outputResolutions'] = [item['resolution'] for item in capabilities['outputs']] if capabilities else [] return rules @@ -153,9 +156,10 @@ def check_video_config(kind, protocol, adapter, capabilities): def validate_video_duration(config, seconds, resolution=None): from opendirector.domain.input_rules import input_rules - capabilities = input_rules(config).get('videoCapabilities') + rules = input_rules(config) + capabilities = rules.get('videoCapabilities') if capabilities: choices = [item for item in capabilities['outputs'] if not resolution or resolution in {'auto', '自动'} or item['resolution'] == resolution.upper()] - if not any(seconds in item['durations'] for item in choices): + if not any(duration_matches(seconds, item['durations'], rules.get('fractionalDuration', False)) for item in choices): raise ProjectError('当前模型的分辨率与时长组合不受支持,请重新选择;未提交生成。') diff --git a/packages/core/src/opendirector/integrations/providers.py b/packages/core/src/opendirector/integrations/providers.py index 4cc3cdb6..6ac55400 100644 --- a/packages/core/src/opendirector/integrations/providers.py +++ b/packages/core/src/opendirector/integrations/providers.py @@ -179,7 +179,9 @@ async def generate(self, config, task, submitted, *, result_ready=None): files.append(('input_references' if fast else 'input_reference', (meta['name'], data, image_type(data) if source_kind == 'image' else meta['type']))) parameters = task['parameters'] form = {'model': config['modelId'], 'prompt': prepared['prompt']} - if 'duration' in parameters: form['seconds'] = format(parameters['duration'], 'g') + if 'duration' in parameters: + seconds = parameters['duration'] + form['seconds'] = str(int(seconds)) if float(seconds).is_integer() else str(seconds) if 'size' in parameters: form['size'] = parameters['size'] if fast: form.update({ diff --git a/src/backend/rules.ts b/src/backend/rules.ts index 09d49add..14748b5b 100644 --- a/src/backend/rules.ts +++ b/src/backend/rules.ts @@ -3,6 +3,7 @@ import { nodeByteSize } from "../canvas/uploadPolicy.ts"; import type { CanvasNode, GenerationSettings } from "../canvas/model.ts"; import { RULE_FACTS, + durationMatches, aspectRatioChoices, effectiveVideoMode, generateAudioOptions, @@ -33,6 +34,7 @@ export interface VideoCapabilities { } export interface InputRules { + fractionalDuration?: boolean; profile: string; allowedKinds: string[]; maxImages: number; @@ -369,6 +371,7 @@ export function backendResolutions( ): string[] { const rules = config?.inputRules; return resolutionChoices({ + fractionalDuration: rules?.fractionalDuration, kind: settings.mode, videoMode: settings.videoMode, aspectRatio: settings.aspectRatio, @@ -483,7 +486,7 @@ export function serviceVideoSettings( const next = { ...settings }; const caps = config.inputRules.videoCapabilities; const durations = backendDurations(config); - if (durations.length && !durations.includes(next.durationSeconds)) + if (durations.length && !durationMatches(next.durationSeconds, durations, config.inputRules.fractionalDuration)) next.durationSeconds = caps!.defaultDuration; if ( next.videoMode === "continuation" && @@ -637,7 +640,7 @@ export function backendBlocker( const references = backendReferences(settings, [...incoming, ...pinned], config); if (settings.mode === "video") { const durations = backendDurations(config); - if (durations.length && !durations.includes(settings.durationSeconds)) + if (durations.length && !durationMatches(settings.durationSeconds, durations, rules.fractionalDuration)) return tNow("errors.rules.duration"); if ( rules.outputResolutions && diff --git a/src/canvas/DurationControl.tsx b/src/canvas/DurationControl.tsx index 6b1349d0..8a10c83d 100644 --- a/src/canvas/DurationControl.tsx +++ b/src/canvas/DurationControl.tsx @@ -9,7 +9,9 @@ export function DurationControl({ disabled, onChange, scale, + fractional = false, }: { + fractional?: boolean; values: readonly number[]; value: number; disabled: boolean; @@ -52,14 +54,14 @@ export function DurationControl({ const hint = (seconds: number) => seconds <= minimum || seconds >= maximum ? limit - : !values.includes(seconds) + : !fractional && !values.includes(seconds) ? t("canvasNodes.duration.unsupported", { values: values.join(t("canvasNodes.duration.separator")), }) : ""; const choose = (seconds: number) => { if (!Number.isFinite(seconds) || !values.length || disabled) return; - const allowed = values.reduce((nearest, candidate) => + const allowed = fractional ? Math.max(minimum, Math.min(maximum, seconds)) : values.reduce((nearest, candidate) => Math.abs(candidate - seconds) < Math.abs(nearest - seconds) ? candidate : nearest, @@ -97,7 +99,7 @@ export function DurationControl({ aria-label={t("canvasNodes.duration.aria")} min={min} max={max} - step={1} + step={fractional ? "any" : 1} disabled={disabled || values.length <= 1} value={Math.max(min, Math.min(max, value))} aria-valuetext={t("canvasNodes.duration.value", { value })} @@ -109,6 +111,7 @@ export function DurationControl({ if (event.buttons === 1) pointerHint(event); }} onKeyDown={(event) => { + if (fractional) return; const index = Math.max(0, values.indexOf(value)); const next = event.key === "Home" @@ -142,9 +145,10 @@ export function DurationControl({ = minimum && Number(next) <= maximum)) choose(Number(next)); }} onBlur={commit} diff --git a/src/canvas/NodeComposer.tsx b/src/canvas/NodeComposer.tsx index ef5af467..e90b20b4 100644 --- a/src/canvas/NodeComposer.tsx +++ b/src/canvas/NodeComposer.tsx @@ -788,6 +788,7 @@ export function NodeComposer({ placement="above-anchor" > 0 && seconds >= Math.min(...durations) && seconds <= Math.max(...durations) + : durations.includes(seconds); +} + export function resolutionChoices(input: { + readonly fractionalDuration?: boolean; readonly kind: string; readonly videoMode?: string | null; readonly aspectRatio?: string | null; @@ -120,7 +127,7 @@ export function resolutionChoices(input: { ? choices.filter((label) => outputs.some( (item) => - item.resolution === label && item.durations.includes(duration), + item.resolution === label && durationMatches(duration, item.durations, input.fractionalDuration), ), ) : choices; diff --git a/src/i18n/messages/en-US/generation.ts b/src/i18n/messages/en-US/generation.ts index d1144f17..b479a5ac 100644 --- a/src/i18n/messages/en-US/generation.ts +++ b/src/i18n/messages/en-US/generation.ts @@ -1,5 +1,6 @@ /** Composer, generation state, model choice and generation rules. */ export const generation = { + "generation.duration.fractionalHint": "Fractional seconds are supported. H3 may align the output to its supported frame count.", "generation.bigmodel.watermarkWaiver": "BigModel requires a signed waiver to remove watermarks. Complete it in Personal Center → Security Management → Watermark Removal Management; otherwise outputs may still contain watermarks.", "generation.model.unconfigured": diff --git a/src/i18n/messages/zh-CN/generation.ts b/src/i18n/messages/zh-CN/generation.ts index 5b718bb5..f2c11ee0 100644 --- a/src/i18n/messages/zh-CN/generation.ts +++ b/src/i18n/messages/zh-CN/generation.ts @@ -1,5 +1,6 @@ /** Composer, generation state, model choice and generation rules. */ export const generation = { + "generation.duration.fractionalHint": "支持小数秒。H3 实际输出时长可能因模型帧数对齐而变化。", "generation.bigmodel.watermarkWaiver": "BigModel 去水印需先签署免责声明,请前往个人中心 → 安全管理 → 去水印管理完成签署,否则生成结果仍可能带有水印。", "generation.model.unconfigured": diff --git a/tests/browser/composer_duration.mjs b/tests/browser/composer_duration.mjs index 0a723957..e6a586e4 100644 --- a/tests/browser/composer_duration.mjs +++ b/tests/browser/composer_duration.mjs @@ -48,11 +48,10 @@ const config = await call("/api/model-configs", { kind: "video", modelId: "fixture-duration-sparse", url: "http://127.0.0.1:12345/v1", - apiKey: "", - connectionType: "self_hosted", - protocol: "vllm_omni", - authMode: "none", - videoAdapter: "standard", + apiKey: "fixture-only", + connectionType: "api", + protocol: "volcengine", + authMode: "bearer", videoCapabilities: { outputs: [ { resolution: "480P", width: 832, height: 480, durations: [3, 5, 7] }, @@ -290,6 +289,26 @@ try { await page.getByText("已保存到本机", { exact: true }).waitFor(); await page.locator(".react-flow__node").click(); assert.equal(await trigger.innerText(), "7 秒"); + await chooseGenerationModel(page, `config:${fastConfig.id}`); + for (const [locale, language, triggerName, dialogName, inputName] of [ + ["zh-CN", "中文", "视频生成时长", "视频生成时长设置", "精确创作时长"], + ["en-US", "EN", "Video duration", "Video duration settings", "Exact creation duration"], + ]) { + await page.getByRole("button", { name: language, exact: true }).click(); + const decimalTrigger = page.getByRole("button", { name: triggerName, exact: true }); + await decimalTrigger.click(); + const decimalPopup = page.getByRole("dialog", { name: dialogName, exact: true }); + const decimalInput = decimalPopup.getByRole("spinbutton", { name: inputName, exact: true }); + await decimalInput.fill("6.583333"); + await decimalInput.press("Enter"); + assert.equal(await decimalInput.inputValue(), "6.583333", locale); + assert.equal(await decimalInput.getAttribute("step"), "any"); + await decimalInput.press("Escape"); + await page.waitForTimeout(500); + const saved = await call(`/api/creation/projects/${projectId}`); + assert.equal(saved.generationSpecs.find(spec => spec.kind === "video").parameters.durationSeconds, 6.583333); + } + await page.getByRole("button", { name: "中文", exact: true }).click(); // The picker only lists configured services; one without published // durations must not offer invented choices. await chooseGenerationModel(page, `config:${unknown.id}`); diff --git a/tests/ruleFacts.test.ts b/tests/ruleFacts.test.ts index 3cfec088..f2296ddb 100644 --- a/tests/ruleFacts.test.ts +++ b/tests/ruleFacts.test.ts @@ -7,6 +7,7 @@ import { GENERATION_RATIOS } from "../src/canvas/generationCatalog.ts"; import { RULE_DERIVATION_VECTORS, RULE_FACTS, + durationMatches, aspectRatioChoices, effectiveVideoMode, generateAudioOptions, @@ -128,3 +129,16 @@ test("one function assigns persisted reference roles from canvas slots", () => { "reference", ); }); + + +test("fractional video duration follows the published capability", () => { + assert.equal(durationMatches(6.583333, [4, 15], true), true); + assert.equal(durationMatches(6.583333, [4, 15]), false); + assert.equal(durationMatches(15.001, [4, 15], true), false); + assert.deepEqual(resolutionChoices({ + kind: "video", videoMode: "reference", hasImageReference: false, + imageOutputs: [], outputResolutions: ["480P"], referenceImageResolutions: null, + durationSeconds: 6.583333, fractionalDuration: true, + capabilityOutputs: [{ resolution: "480P", durations: [4, 15] }], + }), ["auto", "480P"]); +}); diff --git a/tests/test_creation_parameters.py b/tests/test_creation_parameters.py index 3176df37..c5e46926 100644 --- a/tests/test_creation_parameters.py +++ b/tests/test_creation_parameters.py @@ -514,3 +514,46 @@ def handler(request): # pragma: no cover - the query must never call the model assert send(store, pid, 'generationSpec.update', session=session, versions=[ver('generation_spec', spec)], spec=fixed).status == 'applied' assert canvas.projection(pid)['capabilities'][str(item.id)].canGenerate is True + + +@pytest.mark.anyio +@pytest.mark.parametrize('seconds', [107 / 24, 5.25, 6.583333]) +@pytest.mark.parametrize('model', ['fixture', 'MiniMaxAI/MiniMax-H3']) +async def test_fractional_video_duration_survives_acceptance_and_wire(project, seconds, model): + from opendirector.integrations.vllm_omni import h3_frames + + calls = [] + clip = subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'color=blue:s=48x84:r=24', + '-t', '0.3', '-threads', '1', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', + '-movflags', 'frag_keyframe+empty_moov', '-f', 'mp4', 'pipe:1'], capture_output=True, check=True).stdout + + def handler(request): + if request.method == 'POST': + calls.append(form(request)[0]) + return httpx.Response(200, json={'id': 'fractional-video'}) + if request.url.path.endswith('/content'): + return httpx.Response(200, content=clip) + return httpx.Response(200, json={'status': 'completed'}) + + runner, item, spec_id = setup(project, 'video', + dict(durationSeconds=seconds, resolution='480P', aspectRatio='16:9'), handler, + model=model, protocol='vllm_omni', caps=dict(outputs=[dict(resolution='480P', width=832, + height=480, durations=[4, 15])], defaultResolution='480P', defaultDuration=4, dimensionStep=32)) + store, pid, _ = project + try: + await submit(project, runner, item, spec_id) + await done(runner) + snapshot = store.snapshot(pid) + job = snapshot.jobs[-1] + assert job.status == 'succeeded', job.errorDetails + assert job.inputSnapshot.spec.parameters.durationSeconds == seconds + assert job.execution.durationSeconds == seconds + if model == 'MiniMaxAI/MiniMax-H3': + assert calls[0]['num_frames'] == str(h3_frames(seconds)) + if seconds == 107 / 24: + assert calls[0]['num_frames'] == '107' + else: + assert calls[0]['seconds'] == str(seconds) + assert CreationProjects(LocalProjects(store.local.repository)).snapshot(pid) == snapshot + finally: + await runner.close() diff --git a/tests/test_rule_facts.py b/tests/test_rule_facts.py index 52525a95..01a7328d 100644 --- a/tests/test_rule_facts.py +++ b/tests/test_rule_facts.py @@ -133,3 +133,17 @@ def image_ref(number): validate_generation(spec, public, tuple(image_ref(number) for number in range(FRAME_IMAGE_LIMIT + 1))) with pytest.raises(ValueError, match='参考素材数量超过上限'): validate_generation(spec, public, tuple(image_ref(number) for number in range(MAX_REFERENCES + 1))) + + +def test_fractional_duration_is_published_only_for_vllm_omni(): + from opendirector.domain.input_rules import input_rules + from opendirector.domain.video_capabilities import validate_video_duration + from opendirector.domain.projects import ProjectError + + config = dict(kind='video', url='http://model.test/v1', modelId='MiniMaxAI/MiniMax-H3', protocol='vllm_omni') + assert input_rules(config)['fractionalDuration'] is True + validate_video_duration(config, 6.583333, '480P') + official = dict(kind='video', url='https://api.minimax.cn/v2', modelId='MiniMax-H3', protocol='minimax_video_v2') + assert input_rules(official).get('fractionalDuration', False) is False + with pytest.raises(ProjectError): + validate_video_duration(official, 6.583333)