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)