Compare commits

..
Author SHA1 Message Date
SolitaryThinker 26719dcfe7 [new-model] Add PAN2 text-to-video and image-to-video
Port of vllm-project/vllm-omni#8602. PAN2 is a 29B mixture-of-transformers
video DiT (separate video and text streams joined in one attention per
block) with a Qwen3.5 text encoder and the Wan2.2 VAE. One pipeline serves
text-to-video and first-frame image-to-video.

- Native PAN2 DiT; per-stream Q/K/V fused at load time, Ulysses SP with
  replicated text tokens through DistributedAttention.
- Native Qwen3.5 text encoder (Gated DeltaNet + gated attention) with a
  PyTorch chunked delta rule.
- Reuses the Wan2.2 VAE and the flow-match Euler scheduler.
- PAN2 stages: chat-template prompt crop at hidden_states[-3], first-frame
  conditioning, uniform sigmas with shift 7, fp32 latents. Adds a
  DenoisingStage.prepare_scheduler_model_output hook for the last one.
- Registry entry for the Modular Diffusers PAN2ModularPipeline manifest,
  pan2_t2v / pan2_i2v presets, example, docs, and local parity tests
  against a diffusers/transformers reference on the public tiny checkpoint.
2026-10-15 03:44:35 +00:00
d5253fd710 [feat] Add serving cookbook recipes and configuration API (#1941)
Co-authored-by: leo <yixingwang@YIXINGs-MacBook-Pro.local>
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-10-09 15:49:08 -07:00
William Lin 2b164405c3 [bugfix] Accept log_queue in StreamingVideoGenerator.from_fastvideo_args (#1937)
VideoGenerator.from_config calls from_fastvideo_args with a log_queue keyword
argument. The StreamingVideoGenerator override did not accept it, so
StreamingVideoGenerator.from_pretrained raised TypeError. The override now
accepts log_queue and passes it to the executor through VideoGenerator.__init__.
A new test covers the call.
2026-10-07 15:58:03 -07:00
William Lin 1033fa8e1c [tests] Use the env helpers in the HunyuanVideo batch test (#1935)
The HunyuanVideo batch test wrote MASTER_PORT, MASTER_ADDR and
FASTVIDEO_ATTENTION_BACKEND with monkeypatch.setenv, so the environment-variable
contract test failed. The fixture now uses envs.override_external and the
registry override. The values and the restore behavior do not change.
2026-10-07 15:39:39 -07:00
Davide Locatelli e1e2559359 [perf] FastH3 OmniRef: lossless speed-ups behind default-off switches (#1930)
These speed-ups for the MiniMax-H3 pipelines keep the output bit-identical. Each
one has a switch that is off by default:

- FASTVIDEO_MINIMAX_H3_EXACT_KERNELS: Triton RoPE, AdaLN modulation, gated
  residual and SwiGLU kernels that round to bf16 at the same points as the
  eager ops.
- FASTVIDEO_H3_VSA_HEADS_FIRST_TILE: VSA-H3 tiles are written directly in the
  heads-first layout of the kernel.
- FASTVIDEO_H3_VAE_TILE_PARALLEL: VAE spatial tiles are split across the
  sequence-parallel ranks.
- FASTVIDEO_H3_REF2VA_MEMO_ENTRIES: repeat reference encodes are reused.

The example basic_fasth3_omniref_pdd.py --lossless-accel turns them all on.

On GB200 with 4 GPUs, MiniMax-H3 text to video with audio (124 frames,
1344x768) gives bit-identical frames and audio with the first three switches
on and off.
2026-10-07 15:35:37 -07:00
Wenbo Ji 嵇文博 8d2e1cf328 [bugfix] Skip streaming IPC queues for standard VideoGenerator (#1902)
MultiprocExecutor created two streaming multiprocessing queues for every
generator, but only StreamingVideoGenerator uses them. Each queue uses POSIX
named semaphores in /dev/shm. When a job epilog deleted these files, spawned
workers failed to start. The executor now creates the queues only when
FastVideoArgs.enable_streaming_ipc_queues is set. StreamingVideoGenerator sets
it on a copy of its arguments in queue mode. Standard generation creates no
streaming semaphores.
2026-10-07 15:33:11 -07:00
Wenbo Ji 嵇文博 4ebf11f7d9 [bugfix] Fix test env defaults and server dependencies (#1901)
The distributed_setup test fixture now sets MASTER_ADDR and MASTER_PORT when
they are not set, with a new port for each test, and restores them after the
test. pyproject.toml now declares orjson and python-multipart, which the
OpenAI-compatible server needs. Two documents about the SSIM reference
bootstrap are corrected.
2026-10-07 15:32:22 -07:00
Wenbo Ji 嵇文博 ef63de1f60 [perf] Fuse VSA64 tile layout for SM100 inference (#1900)
For Wan VSA inference with 64-token tiles, each attention layer scattered Q, K,
V and the gate into a tile buffer and then made four transposed copies. On
SM100, one Triton kernel (tile_to_bhsd) now does the scatter and the transposes
in one pass. Training, 256-token tiles, other GPUs and builds without the kernel
use the previous path. FASTVIDEO_DISABLE_VSA64_FUSED_LAYOUT=1 turns the fused
path off.

On GB200 the layout and attention step goes from 15.7 ms to 8.6 ms. FastWan 1.3B
frames are bit-exact with the previous path at sequence parallel 1 and 2.
2026-10-07 15:31:58 -07:00
Michael Yang e5f31c1b4c [bugfix] Fix HunyuanVideo forward pass with batch size greater than one (#1921)
The HunyuanVideo and HunyuanVideo 1.5 transformers failed on the first forward
pass when the batch had more than one sample. The modulation inputs had the
shape [B, C], but the fused ops expect [B, 1, C], so they could not broadcast
over the sequence. The models now add the sequence axis, as the refiner and the
final layer already do. Results for batch size 1 do not change.
2026-10-07 15:15:12 -07:00
Wenbo Ji 嵇文博 b7bd7775ed [perf] Default SM100 long-sequence Attn-QAT tiling (#1899)
On SM100, the Attn-QAT training kernel now uses a faster route for non-causal
BF16 attention with equal query and key lengths of 16384 tokens or more. The
forward pass processes full KV tiles without bounds masks, and a separate masked
step handles the partial tail tile. The backward pass uses 64x128 dQ and dK/dV
tiles. Outputs, softmax statistics and all gradients are bitwise identical to the
previous route. FASTVIDEO_ATTN_QAT_SM100_OPTIMIZED=0 turns off the SM100 route,
and FASTVIDEO_ATTN_QAT_SM100_WIDE_BWD=0 keeps the 64x64 backward tiles.

On GB200 with 6 heads and 31200 tokens, the forward kernel time goes from 9.14 ms
to 6.96 ms and the backward kernel time from 17.41 ms to 15.85 ms.
2026-10-07 15:14:38 -07:00
Boyang ZHONG dc72a35f4e [bugfix] Return individual images for batched image API requests (#1917)
For n > 1, the image endpoints returned one preview grid instead of n images.
The endpoints now run one model batch, save each decoded sample as its own file,
and return one URL or base64 item for each image. This applies to generations
and edits.

A keyword-only return_samples field (default False) is added to OutputConfig,
SamplingParam and ForwardBatch. Batched image requests set it and do not build
preview frames. The worker keeps the sample tensor for these requests, and
non-output ranks clear the field. Defaults and positional arguments do not
change.
2026-10-07 15:06:02 -07:00
William Lin 7d61590213 [bugfix] Fix the CUDA device of Ray workers on multi-GPU nodes (#1932)
Ray sets CUDA_VISIBLE_DEVICES of each GPU actor to the GPU of that actor. The
import of fastvideo initializes CUDA, so the executor cannot change the device
list later. On a node with more than one GPU, each worker after the first worker
failed with "invalid device ordinal".

On NVIDIA GPUs, each worker actor now keeps the device list of its raylet
(RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES=1). Each worker uses the position of
its GPU in that list as its local rank. The safetensors loader now reads each
file on node-group rank 0, which is the rank that sends the broadcast.
2026-10-07 15:00:23 -07:00
Chanu Ollala 3c713d808a [bugfix] Prevent ParquetDatasetWriter from overwriting shards across flushes (#1922)
ParquetDatasetWriter.flush() named each shard from the chunk index plus the number
of files in the worker directory. When two flushes split the chunks in different
ways, a new shard could get the name of an existing shard and replace it. The
data in the old shard was lost without an error.

New shards now get numbers after the highest existing shard number in all worker
directories. One helper writes each shard to a temporary file and renames it. It
raises FileExistsError instead of an overwrite. flush(write_remainder=True) now
also counts the remainder rows in its return value.
2026-10-07 14:56:53 -07:00
alanhuangyoo 6ef5b78020 [bugfix] LTX-2: train temporal RoPE at the clip's fps (#1892)
The legacy LTX-2 trainer did not give the clip fps to the DiT. The DiT then
kept temporal RoPE positions in frames during training, but inference uses
seconds (fps 24). The trainer now passes the fps of the stored latents (24 when
the value is missing).

The video transform stage resampled frames to train_fps but kept the source fps
in the batch. The saved clips then had the source fps, and the audio was cut to
the wrong length. The stage now sets the batch fps to train_fps.

The new regression test runs in the CI unit lane.
2026-10-07 14:54:34 -07:00
Boyang ZHONG 6275ac2c8e [bugfix] Validate image parameters before generation and uploads (#1916)
The image endpoints now validate size, output format and count before they
create output directories, save uploads or start generation. An invalid value
returns HTTP 400. Omitted or null values keep their defaults.

Behavior change: an explicit empty size or output format, and a count outside
1-10, now return HTTP 400. Before, the server replaced them with defaults or
clamped the count.
2026-10-07 14:52:15 -07:00
William Lin 73e878a01c [bugfix]: fix the MLX server and a GPU-only test leak after the 2026-10-07 merges (#1931)
- create_app reads distributed_executor_backend with getattr(..., None): the MLX
  server passes a SimpleNamespace without it, so since #1746 the server could not
  start (28 test_openai_video_client failures). An explicit external_launcher
  request is still rejected.
- test_cpu_sdpa patches fastvideo.platforms._current_platform instead of
  current_platform. monkeypatch restored the real platform as a module attribute
  that shadowed the lazy __getattr__, so later tests that fake _current_platform
  (gpu_worker dist timeout, ROCm detection, selector role override) failed on
  GPU hosts.

GB200 unit lane, A/B on one tray: main 36 failures, this change 0 (2720 passed).
2026-10-07 14:35:05 -07:00
Boyang ZHONG 3907a69727 [bugfix] Preserve image output formats (#1915)
The OpenAI image endpoint defaults to a .jpg output path, but the shared
output-path helper treated every non-PNG image path as a directory, so JPEG and
WebP requests were written somewhere other than where the endpoint read them.
Recognized PNG/JPEG/WebP extensions are now preserved, and existing directories
are checked first so a directory named e.g. renders.jpg still receives its
output inside it. Collision handling and filename sanitization are unchanged.
2026-10-07 14:23:15 -07:00
William LinandClaude Opus 5.5 d1416b5930 [misc]: re-run pre-commit after the 2026-10-07 merges (#1929)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 06:13:43 -07:00
Zhang PeiyuanandClaude Opus 5.5 c520878fd2 [perf]: QAT/QAD add safe regional compile for modular training (#1718)
Adds opt-in regional torch.compile to the YAML-driven modular Wan trainer (training.model.enable_torch_compile / torch_compile_kwargs) with FA4 and Wan modulation forward/backward as opaque custom ops, compiled repeated DiT blocks after FSDP setup, and activation checkpointing applied before FSDP for trainable Wan transformers.

Merged with main's #1716 (WanModel resolves the checkpointing type in __init__; the PR's pre-FSDP wrap uses it and the post-load wrap is gone) and #1757 (pre_fsdp_model_transform runs before this PR's pre_fsdp_transform). Maintainer review (Swarm order #776): AdaLN skip names are canonicalized through checkpoint-wrapper prefixes, test_inference_regional_compile.py joins the unit lane, the Wan modulation custom-op test moved to the transformer lane, regional_compile is classified in the schema inventory, and a 1/2-GPU training smoke covers fully_shard(CheckpointWrapper) in eager and regional-compile modes. Validated on GB200: the FA4 CuTe forward/backward parity and fullgraph-backward tests and all four smoke cases pass.

Also exempts MMAudio recipes, whose student builds its pipeline config at init, from the fine-tuning recipe construction check that #1648 left failing on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 05:24:24 -07:00
99f8f3e7a7 [feat] Add streaming and GPU-accelerated LoRA extraction (#1784)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:48:35 -07:00
Raghav KandClaude Opus 5.5 14e4b7189b [feat] Add Cosmos Predict2.5 DFD continuation to DreamVerse (#1768)
Adds a DreamVerse generation backend for Cosmos Predict 2.5 DFD video-to-world continuation (cosmos25_dfd_generation.py), its config wiring and worker hook, README notes, and tests.

This PR stacked on #1767, which landed first with the review fixes for the Cosmos 2.5 pipelines; what lands here is the DreamVerse integration only. The fixes requested in this PR's maintainer review (Swarm order #748: shared fps, FloatingPointError on non-finite rollouts, DFD detector anchoring, 24 fps defaults, FP64 distilled rollout) all reached main through #1767.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:46:54 -07:00
8530fdc550 [feat] Add external-launcher executor for multi-node inference (#1746)
Co-authored-by: Will Lin <willlin@nvidia.com>
Co-authored-by: William Lin <8941107+SolitaryThinker@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:46:26 -07:00
Raghav KandClaude Opus 5.5 315b25d26d [new-model] Add Cosmos Predict2.5 distilled T2W and DFD V2W inference (#1767)
Adds the Cosmos Predict 2.5 distilled text-to-world sampler (sCM, per-frame timesteps) and the DFD video-to-world pipeline, with their schedulers, registry entries, presets and examples.

Maintainer review fixes (Swarm orders #718 and #748): the DiT receives one shared fps value (its RoPE table is batch-independent, so per-sample vectors crashed batch > 1; differing values now raise), non-finite rollouts raise FloatingPointError instead of being zeroed by nan_to_num, the DFD detector matches "dfd" only in the last path component, the distilled example defaults to 24 fps, and the distilled rollout keeps its state in FP64 through the scheduler step as the official driver does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:42:44 -07:00
b8920ba50a [misc]: add env var to control process-aware logging (#1680)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-10-07 04:31:52 -07:00
3467f8befb [feat] Add end-to-end MMAudio V2A training, inference, and evaluation (#1648)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:25:00 -07:00
Kun LinandClaude Opus 5.5 7320d9d350 [feat] Add HunyuanVideo 1.5 embedding preprocessing pipeline (#1663)
Adds preprocess_hunyuan15_overfit.py, which writes VAE latents plus Qwen and ByT5 text embeddings in the dual-text parquet schema (pyarrow_schema_t2v_dual_text) that main's HunyuanVideo 1.5 training (#1662) reads; main's guide and overfit config pointed at this script, but nothing in-tree produced text_embedding_2 before.

Reconciled with #1662 during maintainer review (Swarm orders #723 and #777): main's schema and collate are the base. On top of them, both text streams now share one CFG-dropout decision per row also when _sample_index is absent, and a row missing the primary text_embedding raises instead of training unconditioned. The PR's widening of the shared t2v schema and its writer changes were dropped. docs/training/hunyuan15_overfit.md now describes the in-tree script.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:20:58 -07:00
af78c759f1 [new-model] Add Wan2.2-Animate-14B character animation/replacement inference (#1765)
Co-authored-by: SuhaanCommits <suhaan@cedar.build>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-10-07 04:19:37 -07:00
f82c190cbb [kernel] Build + allow attn_qat_infer FP4 attention on sm_121a (DGX Spark) (#1598)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:09:39 -07:00
e722023bd6 [perf] Wan kernel fusion: Triton-fused residual+LayerNorm+modulate inference path (#1708)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:08:07 -07:00
bc0191d775 [perf] Harden Ulysses agreement and tune bounded H3 transfers (#1807)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:01:53 -07:00
2d6e7d9b6b [feat]: MiniMax-H3 encoder split: dedicated text-encoder node group for 720p on DGX Spark (#1877)
Co-authored-by: magicbear <magicbear@users.noreply.github.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:46:33 -07:00
Kevin LinandClaude Opus 5.5 5e61accc37 [misc] Clean up QAD 5090 example inference scripts (#1496)
Split the FastWan-QAD Wan2.1-1.3B examples into per-recipe scripts (fp8, nvfp4_qat, nvfp4_sa2) with a shared _qad_common.py and a standalone multi-run benchmark harness, replacing FastWan_QAD_TAEHV.py.

The PR's fastvideo-kernel build changes (sm_121a arch probe and build.sh mapping) were dropped during maintainer review (Swarm order #664) in favour of #1598, which owns the kernel arch policy; the RTX 5090 (sm_120a) recipes need nothing beyond main's kernels.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:44:49 -07:00
f60514183c [ci] Separate performance behavior tests (#1620)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:43:12 -07:00
William LinandClaude Opus 5.5 156611bab3 [ci]: run the full attention test directory in the unit lane (#1658)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:37:46 -07:00
William LinandClaude Opus 5.5 6d6195a9f1 [feat]: fp8 PV mode for the FA4-FP4 attention path (#1654)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:36:54 -07:00
8348e83e80 [feat] Import MiniMax H3 Comfy NVFP4-AWQ text encoder (#1865)
Co-authored-by: 武垚乐 <wuyaole@mininglamp.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:34:40 -07:00
2452022f15 [new-model] Add LTX-2.5 inference support (#1704)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:34:18 -07:00
4b3f99b224 [new-model] Added LingBot-World-Fast image-to-video support (#1665)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-10-07 03:32:38 -07:00
William LinandClaude Opus 5.5 d967b99928 [bugfix]: registry ambiguity warning by specificity + QAD weights file path (#1649)
Registry: when several model detectors match a path, the first registered match still wins (main's pinned precedence, e.g. test_local_manifest_detectors_keep_first_match). Path specificity against each entry's registered HF paths now only decides whether to warn: the resolution is silent when the first match is strictly more specific than every other match (e.g. LTX-2.3 distilled directories that the LTX-2 base detector also claims through the shared pipeline class name), and warns otherwise, including when a later match is more specific than the one chosen. The PR originally re-ranked matches by specificity; that changed main's tested first-match resolution for local manifest directories, so it was narrowed during maintainer review (Swarm order #720).

Also fixes the QAD weights file path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:30:55 -07:00
8f1eb2992c [bugfix]: retain NVFP4 weights for LTX2 refinement LoRA (#1705)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:29:46 -07:00
pkisfaludi-nvandClaude Opus 5.5 683689de4f [bugfix] Fix Ulysses sequence-parallel crash in self-forcing causal Wan DiT (#1627)
Retargeted onto fastvideo/models/wan/causal_transformer.py, where main moved the causal Wan implementation (fastvideo/models/dits/causal_wanvideo.py is now a compatibility shim), with the guarded flex-attention trim prepared during the maintainer review (Swarm order #657).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:29:22 -07:00
d5287f8352 [feat]: add MiniMax H3 Ref2VA and LoRA training support (#1757)
Co-authored-by: William Lin <8941107+SolitaryThinker@users.noreply.github.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:21:45 -07:00
cdfbd64b04 [perf] FP8: fuse the dynamic activation quantization into Triton kernels (#1861)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:20:04 -07:00
Haochen JiangandClaude Opus 5.5 299d0f6838 [bugfix] wrap the generic DenoisingStage in the inference_denoising profiler region (#1707)
profiler_region_inference_denoising was wrapped in exactly one place (minimax_h3_denoising.py), so every model using the generic DenoisingStage (Wan among them) exported a trace with no denoising region. Wrap the denoising loops in DenoisingStage and DmdDenoisingStage with profiler_region("inference_denoising"); the loop bodies are untouched.

These are the two classes STAGE_METRIC_MAP names directly. The perf harness also reports dit_time_s for every stage that inherits performance_component_metric, and those forward overrides (Cosmos*, LongCat*, Gen3C, GameCraft, HYWorld, MatrixGame2/3, Causal*, DreamXWorldAR, LingBot, GlmImage) remain outside the region for now. With no profiler configured the wrap is a no-op; SSIM is exactly 1.0 on FLASH_ATTN and TORCH_SDPA.

Caveat: the region span now appears in exported traces, but a pre-existing profiler behaviour (region exit toggles CUDA collection off, fastvideo/profiler.py) may leave GPU kernel rows out of the trace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:19:42 -07:00
c18436125a [misc]: Refactor separate training pipeline runner (#1696)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:19:21 -07:00
5d515ee617 [perf]: remove per-step host syncs and repeated work from causal inference (#1687)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:18:56 -07:00
37b9dc1836 [new-model] Add native Helios-Distilled T2V pipeline (#1670)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:18:24 -07:00
a0291a57c1 [ci] Add Wan2.2-TI2V-5B SSIM regression test (#1772)
Co-authored-by: SuhaanCommits <suhaan@cedar.build>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-10-07 02:44:24 -07:00
6102fac00d [bugfix]: fix modular ops activation checkpoint recomputation (#1716)
Co-authored-by: William Lin <8941107+SolitaryThinker@users.noreply.github.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:41:12 -07:00
1ad33c1832 [feat] Support training_cfg_rate > 0 for LTX-2 (#1752)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:38:42 -07:00
da87973412 [ci] Clarify dashboard cohort grouping for legacy v1 records (#1613)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:33:18 -07:00
William LinandClaude Opus 5.5 a3fd1d0bab [ci]: prevent direct tests from minting suite gates (#1615)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:32:48 -07:00
3e87d6f4ae [feat] ROCm: route the video sparse attention backends to their Triton kernels (#1851)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:32:27 -07:00
4bb5627fa8 [feat] MiniMax H3: block-FP8 text encoder on Hopper, its converter, and a 4xH100 FastH3 profile (#1829)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:31:52 -07:00
08ab8b5c00 [bugfix] ROCm: detect the platform from a HIP torch build when amdsmi is unavailable (#1849)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:20:47 -07:00
fb5efebdab [feat] Add HunyuanVideo 1.5 T2V training support (#1662)
Co-authored-by: William Lin <willlin@nvidia.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:19:22 -07:00
6e4813f8b4 [bugfix] use in-process executor for num_gpus=1 (#1755) (#1879)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:18:23 -07:00
13ef81e6da [ci] Surface worker process logs on perf hard regressions (#1701)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-10-07 02:14:02 -07:00
13b3b36b8b [feat] Add Infinite Livestream app (#1878)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:07:37 -07:00
72795d0af8 [perf] avoid full-sequence materialization in VSA coarse/sparse combine (#1813)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-10-07 01:54:35 -07:00
af63a92f6a [bugfix] Shut down Inductor workers gracefully (#1786)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:54:03 -07:00
a4e7322ba6 [bugfix]: synchronize post-FSDP LoRA replicas (#1666)
Co-authored-by: Suckl <Suckl@users.noreply.github.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:53:30 -07:00
d6e13f9cc8 [feat] Add VQeval long-video quality metric (#1727)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:39:11 -07:00
6c4c18690e [bugfix] Enable SP for LongCat BSA refinement (#1862)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:38:43 -07:00
a8688dddd9 [bugfix]: requantize MXFP8 weights after LoRA unmerge (#1876)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:38:02 -07:00
cacc8cfcb3 [bugfix]: condition HunyuanVideo 1.5 i2v on the reference image (#1693)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:36:35 -07:00
ef62ac48d1 [new-model] Add Wan2.2-S2V-14B speech-to-video (model port + checkpoint converter) (#1683)
Co-authored-by: SuhaanCommits <suhaan@cedar.build>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-10-07 01:36:01 -07:00
00cb7d0ba2 [ci] Add DGX Spark (GB10) single-GPU perf config + gpu_types gating (#1679)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:30:42 -07:00
69a6215e7f [feat] Add Dreamverse multimodal inputs and H3 routing (#1835)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:30:26 -07:00
abe98e87f5 [feat] Add phase-aware modular training performance metrics (#1826)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:30:11 -07:00
274b922d39 [misc]: surface GenerationResult.peak_memory_mb in the MiniMax H3 examples (#1785)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:29:56 -07:00
38966056d0 [ci]: make merge-gate startup reliable (#1762)
Co-authored-by: William Lin <8941107+SolitaryThinker@users.noreply.github.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:29:42 -07:00
d8ca702dfd [bugfix] registry: register magi_human presets (#1751)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:28:53 -07:00
William Lin 33d81730e9 [feat]: Kandinsky 6 Lite: register the 3.2B checkpoints and add docs + cookbook recipes (#1927) 2026-10-06 17:09:53 -07:00
02a027c49f [feat]: FastH3 on DGX Spark and Apple Silicon (#1920)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
Co-authored-by: Aryan Kumar <aryank@Aryans-Mac-Studio.local>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:17:11 -07:00
Aryan Kumar e235b6a332 [docs] Announce FastH3 on consumer hardware and FastH3 Trim (#1923) 2026-10-06 11:39:24 -07:00
8b51380466 [feat]: FastH3 on single RTX GPUs (#1919)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
Co-authored-by: Aryan Kumar <aryank@Aryans-Mac-Studio.local>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:37:01 -07:00
William Lin 527a3165b9 [docs]: add Kandinsky 6 to the README news (#1925) 2026-10-06 11:05:40 -07:00
f06d824054 [feat]: add Kandinsky-6.0 TI2VA and VSR pipelines (#1924)
Co-authored-by: leffff <levnovitskiy@gmail.com>
Co-authored-by: Kirill <kykozlov@gmail.com>
Co-authored-by: shaoxiongduan <shaoxiongduan@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:50:17 -07:00
Kyle Hu 6ded84ee0f [ci]: improve CI efficiency by reordering lanes (#1914) 2026-10-04 12:35:12 -07:00
a4d6416c52 [docs] Add OpenAI serving recipes for Wan cookbook pages (#1906)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:20:06 -07:00
lpc0220andClaude Opus 5.5 0cc41a22dc [bugfix] sm_100a VSA: fence the CLC response read before the slot release; forward PV descriptor lifetime (#1904)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:37:34 -07:00
8444c0897a [feat] FastH3 Ref2VA PDD inference with reference-video VSA sparsity (#1907)
Co-authored-by: Davids048 <jundasu@ucsd.edu>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:28:07 -07:00
Kyle Hu 9491c8638a [bugfix]: keep the full audio track when saving through the ffmpeg pipe (#1910) 2026-10-01 22:22:17 -07:00
Ishan 6809a751fb [bugfix] Fixed the MiniMax-H3 pin-fallback decode test setup (#1909) 2026-10-01 22:17:46 -07:00
Kyle Huandaryan5v 8322b01815 [bugfix]: restore the NVFP4 module after test_nvfp4_config re-imports it (#1908)
Co-authored-by: aryan5v <email.aryan1005@gmail.com>
2026-10-01 22:17:32 -07:00
Junda Su 9edc8adf5f [misc] Move test-suite environment access into the registry and allowlists (#1898) 2026-09-30 00:15:39 -07:00
e1f3904799 [kernel] sm_100a CUDA backward for VSA block-sparse attention, 128-token blocks (#1866)
Co-authored-by: Pengcheng Li <pengchengl@ptyche0203.ptyche.clusters.nvidia.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 16:57:22 -07:00
Junda Su 02f1ce11ae [misc] Move environment var reads into env registry and rename unprefixed variables (#1897) 2026-09-29 14:36:16 -07:00
Aryan KumarandAryan Kumar 7f03e03dc6 [docs]: FastH3 V2 cookbook serving and MLX install guide (#1884)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-09-29 13:03:18 -07:00
Junda Su e3b88bb12a [feat] Add a typed environment-variable registry, policy doc, and contract test (#1896) 2026-09-29 12:50:45 -07:00
Junda Su cb66acd400 [bugfix] Fix environment-variable bugs in LTX-2 debug logging, HF token lookup, and attention backend reads (#1895) 2026-09-29 11:48:31 -07:00
li-lizhe 442e2d2e18 [bugfix] fix(metrics): set SceneMetric device_map for any non-CPU device (#1817) 2026-09-28 10:24:00 -07:00
Max LI dd35763ad6 [bugfix] Fix MLX prompt enhancement sampler compatibility (#1891) 2026-09-27 14:53:25 -07:00
Keith e90be598e5 [perf] MiniMax H3: return uint8 frames from the decode worker (#1828) 2026-09-24 18:08:56 -07:00
Leleand武垚乐 ba5e81083c [bugfix] Preserve video frames when decoded audio is shorter (#1857)
Signed-off-by: 武垚乐 <wuyaole@mininglamp.com>
Co-authored-by: 武垚乐 <wuyaole@mininglamp.com>
2026-09-24 17:20:37 -07:00
YZJF 76ce9c7fd6 [bugfix] Reject mismatched model names on image generation routes (#1881) 2026-09-24 17:19:29 -07:00
Junda Su 08d99c089e [perf] Reuse cached VAE offload for H3 conditioning (#1882) 2026-09-24 17:18:39 -07:00
Jerry-XY 20751a21aa [bugfix] Keep the VSA-H3 tile buffer out of the autograd graph (#1858) 2026-09-24 17:16:49 -07:00
alanhuangyooandSolitaryThinker 9dd2a837f4 [bugfix] encoders: keep Qwen2.5-VL importable on transformers 5 (#1791)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-09-24 17:13:23 -07:00
alanhuangyoo 93aab45ac2 [bugfix] tests: include ltx2_3_base in local LTX-2 preset set (#1750) 2026-09-24 17:11:31 -07:00
Yogya Mehrotra 017ce6602d [bugfix] Fix dead NVFP4 QAT plumbing: bench imports, int8 error string, smooth_q crash (#1723) 2026-09-24 17:09:33 -07:00
Yogya Mehrotra a575055eec [perf] benchmark_attn_qat_train: device peak-TFLOPS table instead of silent RTX 5090 default (#1724) 2026-09-24 17:08:03 -07:00
Kyle Hu 81f3fec7fd [bugfix]: workers killed by a signal now log the reason (#1725) 2026-09-24 17:06:55 -07:00
Raghav K d265a454bf [bugfix]: Fall back when large pinned output allocation fails (#1759) 2026-09-24 17:05:59 -07:00
c100c66578 [bugfix]: isolate cancelled streaming requests from GPU result readers (#1848)
Co-authored-by: Gxj230958 <222823329+Gxj230958@users.noreply.github.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-09-24 17:04:59 -07:00
Aryan Kumar 8760eb7a06 [feat] Add FastH3 8-Step V2 MLX inference (#1863)
Keep the four-step uniform AdaLN cache, and use explicit DMD rungs only when the snapshot declares its own scheduler shifts.
2026-09-23 12:42:03 -07:00
Aryan Kumar 361f919c88 [feat]: add a narrow FastH3 8-step MLX ladder
Keep the four-step uniform AdaLN cache, and use explicit DMD rungs only when the snapshot declares its own scheduler shifts.
2026-09-23 11:06:55 -07:00
Aryan Kumar 8b5377aab2 Merge remote-tracking branch 'origin/main' into aryan/pr-1863-fasth3-8step 2026-09-23 10:52:20 -07:00
Yixing Wangandleo d995516da0 [bugfix] Restore page interaction after dismissing Create Job or successfully creating a job (#1869)
Co-authored-by: leo <yixingwang@YIXINGs-MacBook-Pro.local>
2026-09-21 12:03:54 -07:00
Keith f47ad3f5b7 [bugfix] fastvideo-kernel: install the Python + Triton package when the HIP toolchain cannot be configured (#1871) 2026-09-21 11:59:13 -07:00
Hexu ZhaoandClaude Opus 5 10bdf5e076 [bugfix] frozen offload: no host copy for an already resident module
`load` took the host copies before checking which tensors were missing from
the device, so with `vae_cpu_offload=False` — where the loader builds the VAEs
on CUDA and `unload` is never called — it pulled about 11 GB per rank back over
PCIe once and kept a pinned mirror of it for the life of the process, for a
module that never leaves the device.

It now asks what is absent first and returns early when nothing is. The
offloaded path is unchanged: weights still round-trip and come back bit
identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:46:13 -07:00
Hexu ZhaoandClaude Opus 5 3e26db40b0 [perf] H3 decode: pin the frozen VAEs' host copies
With the device-to-host copy gone, the remaining host-to-device copy is the
stage's cost, and from pageable memory it is staged through a bounce buffer at
roughly 2.6 GB/s instead of full PCIe speed. The host copies are made once, so
pinning them costs nothing per request.

It does cost the module's size in non-pageable host memory (10.4 GB for the
H3 video VAE, 0.6 GB for the audio VAE, per rank) for as long as the pipeline
is alive. It follows --pin-cpu-memory, which is on by default; --no-pin-cpu-
memory keeps the copies pageable and only loses the bandwidth. Note that this
flag did not already reach the VAE: its other use is FSDP's own CPUOffloadPolicy
and the VAE does not go through FSDP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATw7g6cxq9N2LtnMratsru
2026-09-20 22:46:13 -07:00
Hexu ZhaoandClaude Opus 5 c73dd0ab55 [perf] H3 decode: offload the frozen VAEs without the device-to-host copy
The decode stages move the 10.4 GB fp32 video VAE (and the 0.6 GB audio VAE)
with module.to(device) / module.to("cpu") on every request. The weights are
frozen -- the whole stage runs under no_grad -- so the copy back to the host
returns bytes the host already has.

Keep one host copy per tensor, made the first time the module is loaded, and
on unload point .data back at it instead of copying. One H2D per request, no
D2H at all; the module still lives on the host between requests. Where the old
path allocated a fresh host tensor per cycle and freed the previous one, this
reuses one buffer, so peak host memory can only go down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATw7g6cxq9N2LtnMratsru
2026-09-20 22:46:13 -07:00
430e52154e [bugfix]: Restore exact Qwen3-VL vision interpolation (#1737)
Co-authored-by: William Lin <8941107+SolitaryThinker@users.noreply.github.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-09-18 05:00:05 -07:00
vanch 384eee8aef feat(mlx): add FastH3-8-Step-V2 DMD schedule and thermal-safe MLX inference
- Support official 8-step DMD schedule from fastvideo_inference.json in MLX pipeline
- Implement MiniMaxH3SchedulerState.from_dmd_steps for exact sigma ladder calculation
- Add auto-detection of fastvideo_inference.json in checkpoint converter AdaLN precomputation
- Add inter_step_cooldown_s and per-step MLX graph/cache cleanup to prevent thermal throttling
- Support configurable VAE tile sizes to guarantee 256px tiled decode without grid artifacts
- Skip non-existent shard files gracefully in Qwen3-VL conditioner
2026-09-17 16:12:02 +08:00
KeithandSolitaryThinker c4824c7764 [bugfix] FP8: gate the ROCm _scaled_mm path on CDNA4 (gfx950) instead of the capability tuple (#1859)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-09-16 19:58:55 -07:00
Raghav K 9b0e57fe4b [ci] Make Dreamverse provider race test deterministic (#1729) 2026-09-15 14:13:56 -07:00
William Lin 0100218594 [feat] Support the FastH3 8-Step V2 checkpoint: checkpoint-defined shifts, explicit DMD schedule, new example (#1852) 2026-09-15 14:13:45 -07:00
li-lizhe 39718cd54d [bugfix] fix(cosmos): make AdaLayerNorm autocast device-agnostic (#1818) 2026-09-15 07:44:51 -07:00
IshanandSolitaryThinker 37d06a832f [feat] Add fastvideo serve configs for Wan CUDA models (#1801)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-09-14 18:23:35 -07:00
sudhirpol522 8839ba8d4d [bugfix] Add OpenAI-compatible image generation endpoint (#1840) 2026-09-14 17:55:56 -07:00
sudhirpol522andSolitaryThinker 61b91220c0 [bugfix] Validate image response format before generation (#1841)
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
2026-09-14 17:01:40 -07:00
Lele 316f3876c2 [bugfix]: allow MiniMax H3 frame padding at the 15-second limit
Accept the causal-VAE-aligned 362-frame bucket (15.083 s) for 15-second H3 requests.

- Hoist MINIMAX_H3_MIN/MAX_ALIGNED_FRAMES next to align_num_frames and reuse them in the CUDA stage, the MLX runtime, and the LoRA example so the bound is consistent across entry points.
- State the accepted frame range in the error messages.
- Cover seconds="15" -> 362 at OpenAI admission and the MLX resolve_geometry bound.
- Update the Spark/openai cookbook frame caps from 345 to 362.

Known gaps: no GPU-lane coverage for the 362 bucket (SSIM runs 124 frames; the golden gate is a fixed-geometry fingerprint), and explicit num_frames=360 still hits the pre-existing grid gate.
2026-09-14 16:54:41 -07:00
Yaegaki1Erika 614b59543c [bugfix]: respect serialized tensor dtype in parquet dataloader (#1843) 2026-09-14 16:34:25 -07:00
William Lin 1c14afd559 [docs]: refresh AGENTS.md maps and add Wan SP/I2V and CI test notes (#1845) 2026-09-14 16:06:53 -07:00
Yaegaki1Erika 9a3c45779c [bugfix]: fix Wan I2V DMD conditioning under sequence parallelism (#1844)
The pipeline pre-sharded only the image conditioning along the temporal axis while the noise input stayed full-length, so concatenation could not match for sp_world_size > 1. WanTransformer3DModel shards the flattened token sequence after patch embedding, so pass full-length mask+latent conditioning and let the transformer shard.

Also reads temporal_compression_ratio from the VAE config, simplifies the conditioning mask, concatenates in the transformer's (bs, c, t, h, w) layout, and adds unit coverage.

Co-authored-by: Yaegaki1Erika <70182590+Yaegaki1Erika@users.noreply.github.com>
2026-09-14 15:58:13 -07:00
Kyle Hu bfc9c01797 [feat]: convert the MiniMax H3 text encoder to NVFP4 (#1838) 2026-09-12 18:48:07 -07:00
Kyle Hu 3a3ad3d209 [feat]: NVFP4 text encoder for MiniMax H3 (#1837) 2026-09-12 18:31:59 -07:00
lpc0220andClaude Fable 5.1 aef4e9b3b1 [kernel] VSA kernel: one sm_100a / sm_103a image per listed arch; un-gate backward on sm_103a (#1833)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 16:49:36 -07:00
a943220c11 [bugfix]: drop dead h3_sequential_load from Spark FastH3 presets (#1831)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-09-08 12:38:06 -07:00
William Lin 556ac7088e [refactor] Simplify Wan sampling and tests (#1825) 2026-09-07 15:57:19 -07:00
Junda Su e7456f1b75 Add H3 support into Dreamverse (#1800) 2026-09-07 13:09:44 -07:00
lpc0220 c993d7393e [kernel] sm_100a CUDA backward for VSA block-sparse attention (blk64) (#1819) 2026-09-06 21:00:18 -07:00
William Lin 7f83164233 [refactor] Move Wan VAE into the Wan package (#1824) 2026-09-05 18:43:55 -07:00
Junda Su 4e52f47d1e [feat] add MXFP8 support on H3 (#1796) 2026-09-05 17:08:58 -07:00
William Lin e19913f6e9 [refactor] Group Wan transformer and config (#1823) 2026-09-05 17:05:32 -07:00
William Lin 2413a57651 Disable old SSIM models (#1820) 2026-09-04 21:20:48 -07:00
SYLAR 7bb76b5ec9 [feat] Add native SM103a VSA support (#1812)
Signed-off-by: lishunyang12 <lishunyang12@163.com>
2026-09-03 15:00:28 -07:00
Aryan KumarandAryan Kumar 0bd19a976b [docs]: add one-Spark FastH3 cookbook runtime with a device-count row (#1811)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-09-02 10:05:59 -07:00
William Lin 40b93784d2 [docs]: add cookbook link to README (#1810) 2026-09-01 12:42:19 -07:00
Aryan Kumar 33d3478bad [docs] Announce local FastH3 support (#1809) 2026-09-01 12:28:04 -07:00
3d8ac9d14b [feat]: collapse cookbook recipe pages into an accordion layout, add … (#1805)
Co-authored-by: Vaish, Ishan <isvaish@UCSD.EDU>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 01:51:51 -07:00
aaef49bfc6 [feat]: run FastH3 across two DGX Sparks with Ray sequence parallel (#1803)
Co-authored-by: Kyle <shh075@ucsd.edu>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Satyam Srivastava <srivastavasatyam53@gmail.com>
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-09-01 01:37:23 -07:00
Aryan KumarandAryan Kumar cf6a00b9be [feat]: add opt-in CUDA TAEH3 preview decode for FastH3 (#1795)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-31 23:34:56 -07:00
Shahrad ZomorrodiandShahrad Zomorrodi 1ae39562dd [bugfix] Write generated documentation as UTF-8 (#1797)
Co-authored-by: Shahrad Zomorrodi <264690209+shahradzomorrodi@users.noreply.github.com>
2026-08-31 22:45:03 -07:00
Aryan KumarandAryan Kumar 26064193e2 [feat] Add an H3 server cookbook and prompt playground (#1798)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-31 22:14:33 -07:00
William Lin 8446fc003e [docs]: add FastH3 Preview v1 news links (#1804) 2026-08-31 22:12:02 -07:00
Aryan KumarandAryan Kumar a28f2bab4b [feat] Add an optional MLX TAEH3 preview decoder (#1794)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-31 05:08:06 -07:00
Aryan KumarandAryan Kumar f82d8be4bf [perf] Sequential MiniMax H3 start with GPU-direct DiT load (#1793)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-31 04:20:21 -07:00
Aryan KumarandAryan Kumar 8e1775183e [perf] Speed up exact MiniMax H3 MLX inference (#1792)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-31 04:14:55 -07:00
Aryan KumarandAryan Kumar 620bc36dc4 [docs]: Cookbook catalog improvements (#1790)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-30 17:50:57 -07:00
Suhaan Khurana 29ff16ec96 [feat] Add MiniMax H3 MLX spatial fast mode (#1789) 2026-08-30 17:47:02 -07:00
Aryan KumarandAryan Kumar 8f9d76a80d [perf]: dispatch wide-M affine H3 MLX linears through dequant plus dense GEMM (#1788)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-30 14:42:01 -07:00
a4d9a75e2c [perf] Add MiniMax H3 MLX VSA and SIMD attention (#1776)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-30 13:44:54 -07:00
KyleNeverGivesUp b2db0c0a13 [ci]: seed stable GB10 grad-norm references (#1756) 2026-08-30 03:09:18 -07:00
Kevin Lin 6aa7d8a278 [misc] FastVideo Studio UI Additions (H3 Ref2V support) (#1783) 2026-08-30 03:06:47 -07:00
Aryan KumarandAryan Kumar ccc9014430 [docs] Add model-family inference cookbook (#1787)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
2026-08-30 02:26:10 -07:00
1333 changed files with 139641 additions and 12852 deletions
@@ -0,0 +1,66 @@
---
date: 2026-09-29
experiment: Wan-VACE multi-GPU inference on shared Slurm nodes
category: infrastructure
severity: important
---
# Spawn workers fail with ENOENT in `SemLock._rebuild`
## What Happened
On Slurm nodes, standard `VideoGenerator` runs with the `mp` executor sometimes
failed during worker startup. A spawn child raised `FileNotFoundError` from
`multiprocessing.synchronize.SemLock._rebuild` while unpickling its arguments,
even though the parent still held its Queue objects. Failures showed up on
several nodes and looked intermittent.
## Root Cause
The cluster's Slurm epilog `80-epilog-cleanup-shm-tmp.sh` runs
`find /dev/shm -maxdepth 2 -user "$SLURM_JOB_USER" -delete` at the end of
**every** job. When one of a user's jobs ends, the epilog deletes that user's
`/dev/shm` files on the node, including those that the user's *other* running
jobs are still using. Python's POSIX named semaphores (`/dev/shm/sem.mp-*`) are
among them. If the deletion lands between queue creation and a spawn child
unpickling it, the child fails with ENOENT.
Evidence:
- **Timing.** Every recorded deletion burst during a probe job matched, to the
second, the end of another job by the same user on the same node
(`sacct -u $USER -N <node>` end times).
- **Controlled reproduction.**
- Job A held a spawn `Lock` semaphore and a marker file in `/dev/shm`.
- Job B, on the same node, started and then ended.
- Job B's start deleted nothing.
- Within 1 s of job B's end, both of job A's objects were gone, and job A's
finalizer then hit the same ENOENT.
- **Still unexplained.** One historical failure has no same-user job
end in its window, so another deleter cannot be ruled out for it. logind
`RemoveIPC` was the earlier hypothesis; it is not needed to explain the other
cases.
## Fix / Workaround
- **FastVideo hardening.** Standard inference no longer creates the two
streaming queues it never uses. `FastVideoArgs.enable_streaming_ipc_queues`
defaults to `False`, and `StreamingVideoGenerator` sets it to `True`. This
removes the exposure for standard inference only.
- **Still exposed.** Streaming queues, NCCL shared-memory segments, and
DataLoader shared memory on the same node can still be deleted by the epilog.
- **Cluster fix (administrators).** Clean `/dev/shm` only when the user has no
other job on the node, or give each job a private `/dev/shm` with
`JobContainerType=job_container/tmpfs`.
- **User workaround.** Don't co-locate your own jobs on one node
(`--exclusive=user`), or avoid ending short jobs next to long-running ones.
## Prevention
- Before blaming an application for `/dev/shm` ENOENT on Slurm, read the
node-local prolog/epilog hooks (`/cm/local/apps/slurm/var/{prologs,epilogs}`).
Then correlate the failure window with `sacct -u $USER -N <node>` job end
times.
- Do not pass IPC primitives to spawn workers unless the worker needs them.
- `fastvideo/tests/worker/test_multiproc_executor.py` checks that standard
workers survive semaphore removal during spawn.
@@ -0,0 +1,79 @@
---
name: env-var-conventions
description: Add, read, rename, or remove an environment variable in FastVideo, or change the environment-variable policy. Use before touching fastvideo/envs.py, os.environ, os.getenv, or monkeypatch.setenv in fastvideo/, and when fastvideo/tests/contract/test_env_policy.py fails.
---
# Environment Variable Conventions
## Purpose
FastVideo registers its environment variables as typed fields in
`fastvideo/envs.py`. The policy that governs them is
`docs/contributing/env_vars.md`, and the contract test
`fastvideo/tests/contract/test_env_policy.py` enforces the policy in the unit
CI lane. This skill routes an environment-variable change through that policy.
The policy doc is the single source of the rules; read it instead of relying
on a summary here.
## Prerequisites
- Read `docs/contributing/env_vars.md` in full.
- Decide whether the setting belongs in an environment variable or an argument
(rule 5 in the policy doc). Settings that users change per deployment are
arguments; add them through `fastvideo/fastvideo_args.py` instead.
## Inputs
| Parameter | Required | Description |
| ---------- | -------- | -------------------------------------------------------------- |
| `change` | Yes | Add, read, rename, or remove a variable, or change the policy. |
| `variable` | Yes | The variable name, with the `FASTVIDEO_` prefix. |
## Steps
1. **Declare or edit the variable in `fastvideo/envs.py`.**
- Pick the field type and category that the policy doc lists.
- Write a description that states what the variable does and its units.
- To rename, keep the old name in `deprecated_names`. To remove, add the
name to `DEPRECATED_VARIABLES`. Update the uses in `examples/`,
`scripts/`, `docs/`, `apps/`, and the tests.
2. **Read the variable with `envs.NAME.get()` inside a function.**
- In tests, change the value with `envs.NAME.override(value)`, and a variable
outside the registry with `envs.override_external(name, value)`; the
`env_overrides` fixture keeps either until the end of the test.
- Name a variable that only tests read `FASTVIDEO_TEST_*`.
- Do not call `os.environ`, `os.getenv`, or `monkeypatch.setenv` for a
FastVideo variable.
- To set a variable that another tool reads, call `envs.set_external`,
`envs.setdefault_external`, or `envs.unset_external`.
3. **Regenerate the table in the policy doc.**
- Run `python fastvideo/tests/contract/test_env_policy.py`.
4. **Run the contract test.**
- Run `pytest fastvideo/tests/contract/test_env_policy.py`.
- When the test reports a fixed known violation, delete or lower its entry
in `KNOWN_VIOLATIONS`. Never add an entry to `KNOWN_VIOLATIONS`.
5. **When the policy itself changes, update the policy doc and the contract
test in the same pull request.**
- The rules in `docs/contributing/env_vars.md`, the checks and allowlist in
`fastvideo/tests/contract/test_env_policy.py`, and this skill must agree.
## Outputs
- A registry entry in `fastvideo/envs.py` and call sites that use
`envs.NAME.get()`.
- A regenerated table in `docs/contributing/env_vars.md`.
- A passing `fastvideo/tests/contract/test_env_policy.py`.
## Example Usage
```
Add a FASTVIDEO_DEBUG_MY_STAGE switch that logs MyStage inputs.
```
## References
- `docs/contributing/env_vars.md`: the policy, the field types, and the
violation kinds that the contract test reports.
- `fastvideo/envs.py`: the registry.
- `fastvideo/tests/contract/test_env_policy.py`: the contract test,
`EXTERNAL_ALLOWLIST`, and `KNOWN_VIOLATIONS`.
@@ -57,7 +57,7 @@ Hardcoded:
- Quality tier: **`default`**. `full_quality` is a separate, deliberate
operation.
- HF repo: `FastVideo/ssim-reference-videos` (override via
`FASTVIDEO_SSIM_REFERENCE_HF_REPO`).
`FASTVIDEO_TEST_SSIM_REFERENCE_HF_REPO`).
- Device folder: `L40S_reference_videos`.
## Prerequisites
@@ -0,0 +1,51 @@
{
"benchmark_id": "wan-t2v-1.3b-1gpu-gb10",
"config_schema_version": 2,
"workload_id": "wan-t2v",
"variant_id": "1.3b-sp1",
"benchmark_version": 3,
"description": "Wan2.1 T2V 1.3B single-GPU inference performance on NVIDIA DGX Spark (GB10). Single-GPU variant of wan-t2v-1.3b (same workload_id for dashboard comparability). Gated to the GB10 via run_config.gpu_types so it does not run on the shared H100/L40S lanes.",
"model": {
"model_path": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
"model_short_name": "Wan2.1-T2V-1.3B"
},
"init_kwargs": {
"num_gpus": 1,
"flow_shift": 7.0,
"sp_size": 1,
"tp_size": 1,
"vae_sp": false,
"vae_tiling": true,
"text_encoder_precisions": ["fp32"]
},
"generation_kwargs": {
"height": 480,
"width": 832,
"num_frames": 45,
"num_inference_steps": 4,
"guidance_scale": 3,
"embedded_cfg_scale": 6,
"seed": 1024,
"fps": 24,
"neg_prompt": "Bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards"
},
"test_prompts": [
"Will Smith casually eats noodles, his relaxed demeanor contrasting with the energetic background of a bustling street food market. The scene captures a mix of humor and authenticity. Mid-shot framing, vibrant lighting."
],
"run_config": {
"num_warmup_runs": 2,
"num_measurement_runs": 5,
"required_gpus": 1,
"gpu_types": ["GB10"]
},
"thresholds": {
"GB10": {
"max_generation_time_s": 55.0,
"max_peak_memory_mb": 12000.0
},
"default": {
"max_generation_time_s": 120.0,
"max_peak_memory_mb": 40000.0
}
}
}
+106 -90
View File
@@ -23,7 +23,100 @@ notify:
# dispatcher, and every test payload executes inside the Slinky Slurm tray.
# fastvideo/tests/modal remains available only for an explicit manual rollback;
# no active pipeline or slash-command route invokes it.
# Buildkite hands jobs to free agents in the order they appear here. Golden-gate comes first
# because every later merge lane waits for it; the fastcheck lanes follow from longest to
# shortest measured runtime, so the longest lane never starts last and stretches the build.
steps:
- label: ":test_tube: Golden-Gate Tests"
key: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
build.env("MERGE_TEST_PLAN") =~ /,golden-gate,/) ||
(build.env("TEST_SCOPE") == "direct" &&
(build.env("TEST_TYPE") == "golden_gate" || build.env("TEST_TYPE") == "golden_gate_ci"))
command: "/opt/fastvideo-ci-runner/run-ci"
timeout_in_minutes: 90
env:
TEST_TYPE: "golden_gate_ci"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
agents:
queue: "ci-runner"
- label: ":microscope: Unit Tests"
key: "unit"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
build.env("MERGE_TEST_PLAN") =~ /,unit,/) ||
build.env("TEST_SCOPE") == "fastcheck" ||
build.env("TEST_SCOPE") == null ||
(build.env("TEST_SCOPE") == "direct" &&
(build.env("TEST_TYPE") == "unit_test" || build.env("TEST_TYPE") == "unit_test_ci"))
command: "/opt/fastvideo-ci-runner/run-unit"
timeout_in_minutes: 90
env:
TEST_TYPE: "unit_test_ci"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
agents:
queue: "ci-runner"
- label: ":microscope: Kernel Tests"
key: "kernel-tests"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
build.env("MERGE_TEST_PLAN") =~ /,kernel-tests,/) ||
build.env("TEST_SCOPE") == "fastcheck" ||
build.env("TEST_SCOPE") == null ||
(build.env("TEST_SCOPE") == "direct" &&
(build.env("TEST_TYPE") == "kernel_tests" || build.env("TEST_TYPE") == "kernel_tests_ci"))
command: "/opt/fastvideo-ci-runner/run-ci"
timeout_in_minutes: 90
env:
TEST_TYPE: "kernel_tests_ci"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
agents:
queue: "ci-runner"
- label: ":microscope: DreamVerse App Tests"
key: "dreamverse"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
build.env("MERGE_TEST_PLAN") =~ /,dreamverse,/) ||
build.env("TEST_SCOPE") == "fastcheck" ||
build.env("TEST_SCOPE") == null ||
(build.env("TEST_SCOPE") == "direct" &&
(build.env("TEST_TYPE") == "dreamverse_app" || build.env("TEST_TYPE") == "dreamverse_app_ci"))
command: "/opt/fastvideo-ci-runner/run-ci"
timeout_in_minutes: 90
env:
TEST_TYPE: "dreamverse_app_ci"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
agents:
queue: "ci-runner"
- label: ":microscope: Encoder Tests"
key: "encoder"
if: |
@@ -93,98 +186,9 @@ steps:
agents:
queue: "ci-runner"
- label: ":microscope: Kernel Tests"
key: "kernel-tests"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
build.env("MERGE_TEST_PLAN") =~ /,kernel-tests,/) ||
build.env("TEST_SCOPE") == "fastcheck" ||
build.env("TEST_SCOPE") == null ||
(build.env("TEST_SCOPE") == "direct" &&
(build.env("TEST_TYPE") == "kernel_tests" || build.env("TEST_TYPE") == "kernel_tests_ci"))
command: "/opt/fastvideo-ci-runner/run-ci"
timeout_in_minutes: 90
env:
TEST_TYPE: "kernel_tests_ci"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
agents:
queue: "ci-runner"
- label: ":microscope: Unit Tests"
key: "unit"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
build.env("MERGE_TEST_PLAN") =~ /,unit,/) ||
build.env("TEST_SCOPE") == "fastcheck" ||
build.env("TEST_SCOPE") == null ||
(build.env("TEST_SCOPE") == "direct" &&
(build.env("TEST_TYPE") == "unit_test" || build.env("TEST_TYPE") == "unit_test_ci"))
command: "/opt/fastvideo-ci-runner/run-unit"
timeout_in_minutes: 90
env:
TEST_TYPE: "unit_test_ci"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
agents:
queue: "ci-runner"
- label: ":microscope: DreamVerse App Tests"
key: "dreamverse"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
build.env("MERGE_TEST_PLAN") =~ /,dreamverse,/) ||
build.env("TEST_SCOPE") == "fastcheck" ||
build.env("TEST_SCOPE") == null ||
(build.env("TEST_SCOPE") == "direct" &&
(build.env("TEST_TYPE") == "dreamverse_app" || build.env("TEST_TYPE") == "dreamverse_app_ci"))
command: "/opt/fastvideo-ci-runner/run-ci"
timeout_in_minutes: 90
env:
TEST_TYPE: "dreamverse_app_ci"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
agents:
queue: "ci-runner"
- label: ":test_tube: Golden-Gate Tests"
key: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
build.env("MERGE_TEST_PLAN") =~ /,golden-gate,/) ||
(build.env("TEST_SCOPE") == "direct" &&
(build.env("TEST_TYPE") == "golden_gate" || build.env("TEST_TYPE") == "golden_gate_ci"))
command: "/opt/fastvideo-ci-runner/run-ci"
timeout_in_minutes: 90
env:
TEST_TYPE: "golden_gate_ci"
retry:
automatic:
- exit_status: 128
limit: 3
- exit_status: -1
limit: 2
agents:
queue: "ci-runner"
- label: ":bar_chart: SSIM Tests"
key: "ssim"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
build.env("TEST_SCOPE") == "scheduled" ||
@@ -211,6 +215,7 @@ steps:
- label: ":test_tube: LoRA Inference Tests"
key: "lora-inference"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -232,6 +237,7 @@ steps:
- label: ":test_tube: LoRA Extraction Tests"
key: "lora-extraction"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -253,6 +259,7 @@ steps:
- label: ":test_tube: Training Tests"
key: "training"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -276,6 +283,7 @@ steps:
- label: ":test_tube: Distillation DMD Tests"
key: "distillation"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -297,6 +305,7 @@ steps:
- label: ":test_tube: Self-Forcing Tests"
key: "self-forcing"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -318,6 +327,7 @@ steps:
- label: ":test_tube: LoRA Training Tests"
key: "lora-training"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -341,6 +351,7 @@ steps:
- label: ":test_tube: Training Tests VSA"
key: "training-vsa"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -364,6 +375,7 @@ steps:
- label: ":test_tube: Inference Tests VMoBA"
key: "inference-vmoba"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -385,6 +397,7 @@ steps:
- label: ":test_tube: Performance Tests"
key: "performance"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -406,6 +419,7 @@ steps:
- label: ":test_tube: API Server Tests"
key: "api-server"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -427,6 +441,7 @@ steps:
- label: ":test_tube: Train Framework Tests"
key: "train-framework"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
@@ -448,6 +463,7 @@ steps:
- label: ":test_tube: Eval Metrics Tests"
key: "eval"
depends_on: "golden-gate"
if: |
build.env("TEST_SCOPE") == "full" ||
(build.env("TEST_SCOPE") == "merge" &&
+2 -2
View File
@@ -13,7 +13,7 @@ if [ -z "$selected" ]; then
selected=all
fi
if [ "$selected" = all ]; then
exec pytest "$golden_root" -vs
exec pytest "$golden_root" -xvs
fi
[[ $selected =~ ^test_[a-z0-9_]+\.py(,test_[a-z0-9_]+\.py)*$ ]] || {
@@ -32,4 +32,4 @@ for golden_file in "${golden_files[@]}"; do
golden_paths+=("$golden_path")
done
exec pytest "${golden_paths[@]}" -vs
exec pytest "${golden_paths[@]}" -xvs
+1 -1
View File
@@ -2,4 +2,4 @@
# Canonical Slurm CI selection for the LoRA-extraction lane.
set -euo pipefail
exec pytest ./fastvideo/tests/lora_extraction/test_lora_extraction.py -vs
exec pytest ./fastvideo/tests/lora_extraction/ -vs
+13 -1
View File
@@ -32,7 +32,7 @@ cleanup() {
}
trap cleanup EXIT INT TERM
pytest ./fastvideo/tests/performance -vs
pytest ./fastvideo/tests/performance/test_inference_performance.py -vs
pytest_rc=$?
compare_rc=0
if [ "$pytest_rc" -eq 0 ] || [ "$PERF_UPLOAD_POLICY" = always ]; then
@@ -41,6 +41,18 @@ if [ "$pytest_rc" -eq 0 ] || [ "$PERF_UPLOAD_POLICY" = always ]; then
fi
python ./fastvideo/tests/performance/dashboard.py || true
cp -f fastvideo/tests/performance/results/*.json "$PERF_REPORTS_DIR/" 2>/dev/null || true
# The trusted host relays only .md/.html/.json/.csv from PERF_REPORTS_DIR, so
# mirror each captured worker log with an allowlisted extension.
for worker_log in fastvideo/tests/performance/results/worker_logs/*.log; do
[ -f "$worker_log" ] || continue
base=$(basename "${worker_log%.log}")
# WorkerLogCapture keeps a .log.1 backup after rollover, and read_log_tail
# includes it; mirror that retained history too so the artifact is complete.
if [ -f "$worker_log.1" ]; then
cp -f "$worker_log.1" "$PERF_REPORTS_DIR/${base}.1.md" 2>/dev/null || true
fi
cp -f "$worker_log" "$PERF_REPORTS_DIR/${base}.md" 2>/dev/null || true
done
echo "--- GPU telemetry (clocks.sm vs clocks.max.sm reveals capped hosts) ---"
cat "$PERF_REPORTS_DIR/gpu_telemetry.csv" || true
+4
View File
@@ -2,4 +2,8 @@
# Canonical Slurm CI selection for the transformer lane.
set -euo pipefail
# The existing block reference records an absent FASTVIDEO_FA4 (FA2). Keep
# that reference identity; the component lane also selects FA2 explicitly.
env -u FASTVIDEO_FA4 pytest ./fastvideo/tests/golden_gate/test_wan_t2v.py -xvs
pytest ./fastvideo/tests/golden_gate/test_wan_causal.py -xvs
exec pytest ./fastvideo/tests/transformers -vs
+1
View File
@@ -2,4 +2,5 @@
# Canonical Slurm CI selection for the VAE lane.
set -euo pipefail
pytest ./fastvideo/tests/golden_gate/test_wan_vae.py -xvs
exec pytest ./fastvideo/tests/vaes -vs
+18 -1
View File
@@ -1,7 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
# Collect the whole attention directory so new files cannot land uncovered.
# Its FA2/FA3 regression files skip when FA4 is selected (the Modal image
# enables FA4 by default), so pin FA4 off for the directory to be real
# coverage on every runner rather than a nominal collection.
export FASTVIDEO_FA4=0
# The livestream app's tests are CPU-only; its single gpu-marked module is
# deselected, and DreamVerse's GPU tests have their own lane.
exec pytest \
./apps/infinite_livestream/infinite_livestream/tests \
./fastvideo/tests/api/ \
./fastvideo/tests/contract/ \
./fastvideo/tests/dataset/ \
@@ -10,16 +19,24 @@ exec pytest \
./fastvideo/tests/loader/ \
./fastvideo/tests/pipelines/ \
./fastvideo/tests/platforms/ \
./fastvideo/tests/schedulers/ \
./fastvideo/tests/train/ \
./fastvideo/tests/stages/ \
./fastvideo/tests/ops/ \
./fastvideo/tests/worker/ \
./fastvideo/tests/training/test_runner.py \
./fastvideo/tests/training/test_trackers.py \
./fastvideo/tests/attention/test_sdpa_metadata_mask_contract.py \
./fastvideo/tests/training/test_ltx2_rope_fps.py \
./fastvideo/tests/inference/test_basic_fasth3_omniref_pdd.py \
./fastvideo/tests/inference/test_inference_regional_compile.py \
./fastvideo/tests/attention/ \
./fastvideo/tests/layers/test_pdd_linear.py \
./fastvideo/tests/layers/test_triton_fused_norm.py \
./fastvideo/tests/modal/test_kernel_build_cache.py \
./fastvideo/tests/modal/test_pr_test.py \
./fastvideo/tests/modal/test_ssim_test.py \
--ignore=./fastvideo/tests/entrypoints/test_openai_api_integration.py \
--ignore=./fastvideo/tests/train/models \
--ignore=./fastvideo/tests/train/methods \
-m "not gpu" \
-vs
+7
View File
@@ -61,3 +61,10 @@ Fixes #
**For model/pipeline changes, also check:**
- [ ] I verified SSIM regression tests pass
- [ ] I updated the support matrix if adding a new model
**For new models or serving runtimes:**
- [ ] I added or updated the native serving YAML and [cookbook deployment](https://github.com/hao-ai-lab/FastVideo/blob/main/docs/contributing/cookbook_configuration.md),
or explained why this is not applicable.
- [ ] I documented the GPU type/count and minimum VRAM for the serving setup with supporting measurements,
or marked the minimum as unknown and any VRAM estimate as an estimate.
+24 -2
View File
@@ -160,6 +160,11 @@ FAMILY_COVERAGE = (
("test_glm_image.py", ),
("test_glm_image_similarity.py", ),
),
FamilyCoverage(
re.compile(r"(^|[/_.-])hunyuan(video)?15([a-z0-9_-]*)([/_.-]|$)"),
(),
("test_hunyuan15_i2v_similarity.py", ),
),
FamilyCoverage(
re.compile(r"(^|[/_.-])kandinsky[_-]?5([/_.-]|$)"),
("test_kandinsky5.py", ),
@@ -211,12 +216,13 @@ FAMILY_COVERAGE = (
("test_turbodiffusion_similarity.py", ),
),
FamilyCoverage(
re.compile(r"(^|[/_.-])wan(video)?([/_.-]|$)"),
("test_wan_t2v.py", ),
re.compile(r"(^|[/_.-])wan(video|vae)?([/_.-]|$)"),
("test_wan_t2v.py", "test_wan_vae.py", "test_wan_causal.py", "test_wan_denoising.py"),
(
"test_causal_similarity.py",
"test_wan_i2v_similarity.py",
"test_wan_t2v_similarity.py",
"test_wan_ti2v_similarity.py",
),
),
FamilyCoverage(
@@ -291,6 +297,18 @@ def _family_coverage(path: str) -> tuple[set[str], set[str]]:
if family.pattern.search(normalized):
golden.update(family.golden_tests)
ssim.update(family.ssim_tests)
# Select the component actually touched, including compatibility paths.
# Family configs/pipeline wiring can affect all four Wan gates.
if re.search(r"(^|[/_.-])wan(video|vae)?([/_.-]|$)", normalized):
if (normalized.endswith(("/wan/vae.py", "/wan/vae_config.py", "/vaes/wanvae.py"))
or normalized.endswith("/wan/stages/conditioning.py")):
golden = {"test_wan_vae.py"}
elif normalized.endswith(("/wan/causal_transformer.py", "/dits/causal_wanvideo.py",
"/wan/stages/causal_denoising.py")):
golden = {"test_wan_causal.py"}
elif (normalized == "fastvideo/models/dits/wanvideo.py"
or normalized.endswith(("/wan/transformer.py", "/wan/stages/denoising.py", "/wan/stages/dmd.py"))):
golden = {"test_wan_t2v.py", "test_wan_denoising.py"}
return golden, ssim
@@ -506,6 +524,10 @@ def classify_paths(paths: list[str]) -> MergePlan:
# DreamVerse is already one of the six automatic Fastcheck lanes.
plan.reasons.append(f"covered by automatic DreamVerse Fastcheck: {path}")
continue
if path.startswith("apps/infinite_livestream/"):
# The app's CPU-only tests run in the automatic unit Fastcheck lane.
plan.reasons.append(f"covered by automatic unit Fastcheck: {path}")
continue
if path.startswith("fastvideo/tests/"):
# The automatic unit/component Fastcheck lanes own the remaining
# package tests. Domain-specific expensive test roots were handled
+8 -2
View File
@@ -65,7 +65,13 @@ jobs:
&& fullSuiteOnly.size === 14
&& [...fullSuiteOnly.values()].every(s => s.state === 'success');
if (fastcheckPassed) {
// Direct reruns may repair a failed suite, never create a gate for
// a suite that did not run.
const failedAggregate = context => data.statuses.some(
s => s.context === context && s.state === 'failure'
);
if (failedAggregate('fastcheck-passed') && fastcheckPassed) {
core.info(
`All ${fastcheck.size} fastcheck tests passed — updating fastcheck-passed`
);
@@ -79,7 +85,7 @@ jobs:
});
}
if (fullSuitePassed) {
if (failedAggregate('full-suite-passed') && fullSuitePassed) {
core.info(
'All 20 full suite tests passed — updating full-suite-passed'
);
+30 -11
View File
@@ -8,6 +8,8 @@ on:
- "fastvideo/mlx_runtime/**"
- "fastvideo/tests/mlx/**"
- "fastvideo/tests/platforms/test_mps_vsa_error.py"
- "fastvideo/tests/platforms/test_cpu_sdpa.py"
- "fastvideo/platforms/cpu.py"
- "fastvideo/platforms/mps.py"
- "fastvideo/platforms/__init__.py"
- "fastvideo/__init__.py"
@@ -31,15 +33,15 @@ jobs:
env:
FASTVIDEO_ATTENTION_BACKEND: TORCH_SDPA
TOKENIZERS_PARALLELISM: "false"
MASTER_ADDR: localhost
MASTER_ADDR: "127.0.0.1"
MASTER_PORT: "29513"
GLOO_SOCKET_IFNAME: lo0
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- uses: astral-sh/setup-uv@v3
@@ -47,9 +49,9 @@ jobs:
run: |
uv pip install --system \
--index-url https://download.pytorch.org/whl/cpu \
torch==2.11.0 torchvision torchaudio
torch==2.12.0 torchvision torchaudio
uv pip install --system \
pytest numpy scipy pillow imageio einops cloudpickle filelock \
pytest pytest-timeout numpy scipy pillow imageio einops cloudpickle filelock \
PyYAML diffusers huggingface_hub remote-pdb safetensors loguru mlx \
"ftfy>=6.3.1" "opencv-python>=4.10.0.84" psutil "transformers>=5.0.0"
@@ -63,8 +65,8 @@ jobs:
print("machine:", platform.machine())
print("processor:", platform.processor())
print("mlx default device:", mx.default_device())
memory_size = mx.metal.device_info().get("memory_size") if mx.metal.is_available() else "metal unavailable"
print("mlx memory_size:", memory_size)
device_info = mx.metal.device_info() if mx.metal.is_available() else "metal unavailable"
print("mlx device_info:", device_info)
print("torch:", torch.__version__)
print("torch mps available:", torch.backends.mps.is_available())
PY
@@ -72,6 +74,7 @@ jobs:
- name: Run MLX smoke tests
run: |
python -m pytest \
fastvideo/mlx_runtime/tests/ \
fastvideo/tests/mlx/test_dmd_sampling.py \
fastvideo/tests/mlx/test_memory_limits.py \
fastvideo/tests/mlx/test_quant_capability.py \
@@ -79,11 +82,18 @@ jobs:
fastvideo/tests/mlx/test_mlx_compile_parity.py \
fastvideo/tests/mlx/test_mlx_checkpoint.py \
fastvideo/tests/mlx/test_mlx_checkpoint_compat.py \
fastvideo/tests/mlx/test_mlx_affine_dq_gemm.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_parity.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_regressions.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_mode.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_spatial.py \
fastvideo/tests/mlx/test_mlx_fastwan_benchmark.py \
fastvideo/tests/mlx/test_taehv_decode.py \
fastvideo/tests/mlx/test_frame_upsample.py \
fastvideo/tests/mlx/test_mlx_fast_spatial.py \
fastvideo/tests/mlx/test_mlx_refine.py \
fastvideo/tests/mlx/test_mlx_prompt_enhance.py \
fastvideo/tests/mlx/test_mlx_prompt_to_video_decode.py \
fastvideo/tests/mlx/test_mlx_wan22_prompt_cache_fingerprint.py \
fastvideo/tests/mlx/test_wan22_sample.py \
@@ -91,7 +101,8 @@ jobs:
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_download_unavailable_has_specific_error \
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_backend_regression_is_not_skip_eligible \
fastvideo/tests/platforms/test_mps_vsa_error.py \
-q
fastvideo/tests/platforms/test_cpu_sdpa.py \
-v -s --timeout=120 -o faulthandler_timeout=120
# Same tests on MLX's CPU backend. Hosted macOS runners are scarce and
# slower to schedule; this Linux job gives fast PR signal on the identical
@@ -112,7 +123,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- uses: astral-sh/setup-uv@v3
@@ -120,15 +130,16 @@ jobs:
run: |
uv pip install --system \
--index-url https://download.pytorch.org/whl/cpu \
torch==2.11.0 torchvision torchaudio
torch==2.12.0 torchvision torchaudio
uv pip install --system \
pytest numpy scipy pillow imageio einops cloudpickle filelock \
pytest pytest-timeout numpy scipy pillow imageio einops cloudpickle filelock \
PyYAML diffusers huggingface_hub remote-pdb safetensors loguru "mlx[cpu]" \
"ftfy>=6.3.1" "opencv-python>=4.10.0.84" psutil "transformers>=5.0.0"
- name: Run MLX smoke tests (CPU backend)
run: |
python -m pytest \
fastvideo/mlx_runtime/tests/ \
fastvideo/tests/mlx/test_dmd_sampling.py \
fastvideo/tests/mlx/test_memory_limits.py \
fastvideo/tests/mlx/test_quant_capability.py \
@@ -136,11 +147,18 @@ jobs:
fastvideo/tests/mlx/test_mlx_compile_parity.py \
fastvideo/tests/mlx/test_mlx_checkpoint.py \
fastvideo/tests/mlx/test_mlx_checkpoint_compat.py \
fastvideo/tests/mlx/test_mlx_affine_dq_gemm.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_parity.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_regressions.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_mode.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_spatial.py \
fastvideo/tests/mlx/test_mlx_fastwan_benchmark.py \
fastvideo/tests/mlx/test_taehv_decode.py \
fastvideo/tests/mlx/test_frame_upsample.py \
fastvideo/tests/mlx/test_mlx_fast_spatial.py \
fastvideo/tests/mlx/test_mlx_refine.py \
fastvideo/tests/mlx/test_mlx_prompt_enhance.py \
fastvideo/tests/mlx/test_mlx_prompt_to_video_decode.py \
fastvideo/tests/mlx/test_mlx_wan22_prompt_cache_fingerprint.py \
fastvideo/tests/mlx/test_wan22_sample.py \
@@ -148,4 +166,5 @@ jobs:
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_download_unavailable_has_specific_error \
fastvideo/tests/mlx/test_mlx_rife_interpolation.py::test_rife_backend_regression_is_not_skip_eligible \
fastvideo/tests/platforms/test_mps_vsa_error.py \
-q
fastvideo/tests/platforms/test_cpu_sdpa.py \
-v -s --timeout=120 -o faulthandler_timeout=120
+23 -3
View File
@@ -32,7 +32,7 @@ jobs:
}
core.setOutput('has_write', String(hasWrite));
- name: Add ready label and react
- name: Add ready label
if: steps.perm.outputs.has_write == 'true'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
@@ -40,14 +40,34 @@ jobs:
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.payload.issue.number;
try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: 'ready' }); } catch {}
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: ['ready'] });
- name: React to comment
if: steps.perm.outputs.has_write == 'true'
continue-on-error: true
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
await github.rest.reactions.createForIssueComment({
owner, repo,
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'rocket',
});
trigger-merge-gate:
needs: handle-merge
if: needs.handle-merge.result == 'success'
permissions:
actions: read
contents: read
pull-requests: read
uses: ./.github/workflows/ci-trigger-full-suite.yml
with:
pr_number: ${{ github.event.issue.number }}
secrets:
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
parse-command:
if: >-
github.event.issue.pull_request != null
+134 -30
View File
@@ -3,74 +3,178 @@ name: Trigger Merge Gate
on:
pull_request_target:
types: [labeled, synchronize]
workflow_call:
inputs:
pr_number:
description: Pull request number to enter into the merge gate
required: true
type: number
secrets:
BUILDKITE_API_TOKEN:
required: true
permissions:
contents: read
pull-requests: read
actions: read
concurrency:
group: merge-gate-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
trigger:
if: >-
(github.event.action == 'labeled' && github.event.label.name == 'ready')
inputs.pr_number > 0
|| (github.event.action == 'labeled' && github.event.label.name == 'ready')
|| github.event.action == 'synchronize'
runs-on: ubuntu-latest
# Job-level concurrency: only this guarded job acquires the group, so an
# unrelated `labeled` event (which skips the job) cannot cancel an in-flight
# gate and then skip its replacement. The newest real trigger (`ready`,
# push, or `/merge`) supersedes the in-flight run, whose Buildkite build the
# cancel step below replaces.
concurrency:
group: merge-gate-${{ inputs.pr_number || github.event.pull_request.number }}
cancel-in-progress: true
# Gate below may wait for cheap checks (up to MAX_WAIT_SECS = 25 min).
timeout-minutes: 35
steps:
- name: Check ready label
id: check
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
CALLED_PR_NUMBER: ${{ inputs.pr_number }}
with:
script: |
const eventPrNumber = context.payload.pull_request?.number;
const calledPrNumber = Number(process.env.CALLED_PR_NUMBER);
const prNumber = eventPrNumber ?? calledPrNumber;
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
core.setFailed(`Invalid pull request number: ${process.env.CALLED_PR_NUMBER}`);
return;
}
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
pull_number: prNumber,
});
if (pr.state !== 'open') {
core.setFailed(`PR #${prNumber} is not open.`);
return;
}
if (pr.base.repo.full_name !== context.payload.repository.full_name
|| pr.base.ref !== context.payload.repository.default_branch) {
core.setFailed(`PR #${prNumber} does not target this repository's default branch.`);
return;
}
const hasReady = pr.labels.some(l => l.name === 'ready');
core.setOutput('has_ready', String(hasReady));
core.setOutput('changed_files', String(pr.changed_files));
core.setOutput('pr_number', String(pr.number));
core.setOutput('head_sha', pr.head.sha);
core.setOutput('head_ref', pr.head.ref);
core.setOutput('base_sha', pr.base.sha);
core.setOutput('title', pr.title);
if (!hasReady) core.info('No ready label — skipping merge-gate trigger.');
- name: Cancel previous Buildkite builds
# Cancelling stale builds only saves agent time. If it cannot run, the
# merge gate must still be triggered by the steps below, so a failure
# here is reported and stepped over rather than ending the job.
continue-on-error: true
timeout-minutes: 3
if: steps.check.outputs.has_ready == 'true'
env:
BK_ORG: ${{ vars.BUILDKITE_ORG_SLUG }}
BK_PIPELINE: ${{ vars.BUILDKITE_PIPELINE_SLUG }}
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_BRANCH: ${{ steps.check.outputs.head_ref }}
PR_NUMBER: ${{ steps.check.outputs.pr_number }}
run: |
# Match both branch and PR number: forks can reuse the same branch name.
builds=$(curl -sS --get -H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
--data-urlencode "branch=$PR_BRANCH" \
--data-urlencode "state=running,scheduled" \
"https://api.buildkite.com/v2/organizations/${{ vars.BUILDKITE_ORG_SLUG }}/pipelines/${{ vars.BUILDKITE_PIPELINE_SLUG }}/builds" \
| jq -r --arg pr_number "$PR_NUMBER" \
'.[] | select((.env.TEST_SCOPE? == "merge") and (.env.PR_NUMBER? == $pr_number)) | .number')
for build_num in $builds; do
echo "Cancelling Buildkite build #$build_num"
curl -sS -X PUT -H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
"https://api.buildkite.com/v2/organizations/${{ vars.BUILDKITE_ORG_SLUG }}/pipelines/${{ vars.BUILDKITE_PIPELINE_SLUG }}/builds/${build_num}/cancel"
done
set -euo pipefail
response_file=$(mktemp)
builds_file=$(mktemp)
trap 'rm -f "$response_file" "$builds_file"' EXIT
# Check out the immutable BASE SHA: pull_request_target must never run a
# planner or gate script from the untrusted PR head.
if [[ ! "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
echo "::warning::Invalid pull request number; stale Buildkite builds may continue."
exit 1
fi
if ! curl -sS --fail-with-body --connect-timeout 5 --max-time 20 --get \
-H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
--data-urlencode "branch=$PR_BRANCH" \
--data-urlencode "state[]=running" \
--data-urlencode "state[]=scheduled" \
--data-urlencode "state[]=failing" \
--data-urlencode "exclude_jobs=true" \
--data-urlencode "exclude_pipeline=true" \
--output "$response_file" \
"https://api.buildkite.com/v2/organizations/${BK_ORG}/pipelines/${BK_PIPELINE}/builds"; then
echo "::warning::Could not list Buildkite builds; stale merge-gate builds may continue."
exit 1
fi
if ! jq -e '
if type != "array" then false
else all(.[];
if type != "object" then false
else
(.number | if type == "number" then . > 0 and floor == . else false end)
and (
(.env? | if . == null then {} else . end) as $env
| if ($env | type) != "object" then false
else
($env.TEST_SCOPE? | . == null or type == "string")
and ($env.PR_NUMBER? | . == null or type == "string")
end
)
end
)
end
' "$response_file" >/dev/null 2>&1; then
# Do not print the response body: it is remote data and may contain
# multiline values that would be interpreted as workflow commands.
echo "::warning::Buildkite returned an invalid build list; stale merge-gate builds may continue."
exit 1
fi
# Match both branch and PR number: forks can reuse the same branch name.
if ! jq -r --arg pr_number "$PR_NUMBER" '
.[]
| select((.env.TEST_SCOPE? == "merge") and (.env.PR_NUMBER? == $pr_number))
| .number
' "$response_file" > "$builds_file"; then
echo "::warning::Could not select stale Buildkite builds; stale merge-gate builds may continue."
exit 1
fi
cancellation_failed=0
while IFS= read -r build_num; do
echo "Cancelling Buildkite build #$build_num"
if ! curl -sS --fail-with-body --connect-timeout 5 --max-time 20 -o /dev/null -X PUT \
-H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
"https://api.buildkite.com/v2/organizations/${BK_ORG}/pipelines/${BK_PIPELINE}/builds/${build_num}/cancel"; then
echo "::warning::Could not cancel Buildkite build #$build_num; trying remaining builds."
cancellation_failed=1
fi
done < "$builds_file"
if (( cancellation_failed != 0 )); then
exit 1
fi
# Check out the immutable BASE SHA: neither pull_request_target nor the
# privileged slash-command call may run code from the untrusted PR head.
- name: Checkout trusted merge planner
if: steps.check.outputs.has_ready == 'true'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.base.sha }}
ref: ${{ steps.check.outputs.base_sha }}
persist-credentials: false
- name: Collect changed paths
if: steps.check.outputs.has_ready == 'true'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_NUMBER: ${{ steps.check.outputs.pr_number }}
EXPECTED_CHANGED_FILES: ${{ steps.check.outputs.changed_files }}
run: |
set -euo pipefail
@@ -105,18 +209,18 @@ jobs:
if: steps.check.outputs.has_ready == 'true'
env:
GH_TOKEN: ${{ github.token }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_SHA: ${{ steps.check.outputs.head_sha }}
PR_NUMBER: ${{ steps.check.outputs.pr_number }}
run: bash .github/scripts/gate_full_suite.sh
- name: Trigger Buildkite merge gate
if: steps.check.outputs.has_ready == 'true'
env:
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_SHA: ${{ steps.check.outputs.head_sha }}
PR_BRANCH: ${{ steps.check.outputs.head_ref }}
PR_NUMBER: ${{ steps.check.outputs.pr_number }}
PR_TITLE: ${{ steps.check.outputs.title }}
BK_ORG: ${{ vars.BUILDKITE_ORG_SLUG }}
BK_PIPELINE: ${{ vars.BUILDKITE_PIPELINE_SLUG }}
MERGE_TEST_PLAN: ${{ steps.plan.outputs.merge_test_plan }}
+15
View File
@@ -56,6 +56,21 @@ jobs:
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install cookbook dependencies
run: npm ci --prefix docs
- name: Validate and generate cookbook catalogs
run: |
npm run check:cookbook --prefix docs
npm run test:cookbook --prefix docs
python docs/tests/test_cookbook_assets.py
npm run build:catalog --prefix docs
- name: Build documentation
run: mkdocs build
+14 -12
View File
@@ -63,12 +63,12 @@ jobs:
torch-cuda-short: 'cu130'
platform:
# x86_64 builds the full cu126 + cu130 set. cu130 ships the
# data-center Blackwell sm_100a VSA and consumer sm_120a FP4
# data-center Blackwell sm_100a/sm_103a VSA and consumer sm_120a FP4
# kernels.
- os: ubuntu-22.04
arch: x86_64
wheel-plat: manylinux_2_35_x86_64
# aarch64 is Blackwell (GB200 sm_100a + DGX Spark / consumer sm_120a), not
# aarch64 is Blackwell (GB200 sm_100a/sm_103a + sm_120a + DGX Spark sm_121a), not
# Hopper, and Blackwell needs CUDA >= 12.8 — so only the cu130 leg applies.
# Added via include so x86 keeps cu126 + cu130 while aarch64 stays cu130-only.
include:
@@ -164,23 +164,24 @@ jobs:
cd fastvideo-kernel
git submodule update --init --recursive # Ensure ThunderKittens submodule is initialized
# Release builds run on GPU-less runners, so set kernels + arch explicitly:
# * aarch64 = Blackwell (GB200 sm_100a + DGX Spark/consumer sm_120a), NOT
# Hopper, so TK (sm_90a wgmma) is OFF. The C++ FP4 (attn_qat_infer, SM120)
# covers sm_120a; turbodiffusion covers sm_100a+sm_120a. The sm_100 FP4
# forward is the FA4 CuTe DSL path in the fastvideo package (PR #1221),
# * aarch64 = Blackwell (GB200 sm_100a/sm_103a + sm_120a + DGX Spark sm_121a), NOT
# Hopper, so TK (sm_90a wgmma) is OFF. The C++ FP4 (attn_qat_infer)
# covers sm_120a+sm_121a; turbodiffusion covers every listed arch. The
# sm_100 FP4 forward is the FA4 CuTe DSL path in the fastvideo package (PR #1221),
# JIT-compiled at runtime — not built into this wheel.
# * x86_64 cu130 = Hopper TK + data-center Blackwell sm_100a VSA
# * x86_64 cu130 = Hopper TK + data-center Blackwell sm_100a/sm_103a VSA
# + consumer Blackwell sm_120a FP4.
# * x86_64 cu126 = Hopper TK only (older drivers; CUDA < 12.8 has no FP4).
# The per-arch split in CMakeLists pins the FP4 targets to sm_120a and builds
# the main extension for the full arch list. CMAKE_BUILD_PARALLEL_LEVEL caps
# The per-arch split in CMakeLists pins the FP4 targets to requested
# sm_120a/sm_121a and builds the main extension for the full arch list.
# CMAKE_BUILD_PARALLEL_LEVEL caps
# Ninja so heavy CUTLASS/TK template TUs don't OOM the 16 GB runner (exit 143).
if [ "${{ matrix.platform.arch }}" = "aarch64" ]; then
export TORCH_CUDA_ARCH_LIST="10.0a;12.0a"
export TORCH_CUDA_ARCH_LIST="10.0a;10.3a;12.0a;12.1a"
export CMAKE_ARGS="${CMAKE_ARGS:-} -DFASTVIDEO_KERNEL_BUILD_TK=OFF -DFASTVIDEO_KERNEL_BUILD_ATTN_QAT_INFER=ON"
export CMAKE_BUILD_PARALLEL_LEVEL=1
elif [ "${{ matrix.torch-cuda.torch-cuda-short }}" = "cu130" ]; then
export TORCH_CUDA_ARCH_LIST="9.0a;10.0a;12.0a"
export TORCH_CUDA_ARCH_LIST="9.0a;10.0a;10.3a;12.0a"
export CMAKE_ARGS="${CMAKE_ARGS:-} -DFASTVIDEO_KERNEL_BUILD_TK=ON -DFASTVIDEO_KERNEL_BUILD_ATTN_QAT_INFER=ON -DCMAKE_CUDA_ARCHITECTURES=90a"
# A single FP4 TU (attn_qat_infer) can use ~8-12 GB on its own, so serialize.
export CMAKE_BUILD_PARALLEL_LEVEL=1
@@ -250,7 +251,8 @@ jobs:
- name: Download PyPI wheels
# Publish the cu130 (CUDA 13) wheels to PyPI for both architectures:
# x86_64 — Hopper sm_90a TK + consumer Blackwell sm_120a FP4
# aarch64 — Blackwell: turbodiffusion (sm_100a/sm_120a) + C++ FP4 (sm_120a);
# aarch64 — Blackwell: turbodiffusion (sm_100a/sm_103a/sm_120a/sm_121a)
# + C++ FP4 (sm_120a/sm_121a);
# no TK (Hopper). sm_100 FP4 forward is the FA4 CuTe DSL path in the
# fastvideo package (#1221), shipped/JIT separately.
# The x86_64 cu126 wheel stays available as a build artifact / GitHub-release asset.
+12
View File
@@ -55,6 +55,10 @@ eggs/
# MkDocs documentation
site/
docs/assets/cookbook-serving.json
docs/assets/cookbook-config/
examples/serving/clients/node_modules/
docs/node_modules/
docs/getting_started/examples/
docs/examples/
docs/inference/examples/
@@ -132,8 +136,16 @@ openspec/
fastvideo/tests/ssim/reference_videos/**
!fastvideo/tests/ssim/reference_videos/**/*.mp4
!fastvideo/tests/ssim/reference_videos/**/*.png
fastvideo/tests/ssim/.reference_videos_download.lock
# Local H3 MLX kernel / exactness benches (JSON, logs, frames, videos)
.kernel_bench/
# Editor logs and local Python version pins (accidentally committed)
*.nvimlog
.nvimlog
.python-version
/LTX-2-Reference/
/DFDReference/
scripts/benchmarks/minimax_h3_pro6000/headline_results/
fastvideo/tests/ssim/.reference_videos_download.lock
+3
View File
@@ -7,3 +7,6 @@
[submodule "fastvideo/third_party/eval/vbench"]
path = fastvideo/third_party/eval/vbench
url = https://github.com/Vchitect/VBench.git
[submodule "fastvideo/third_party/eval/vqeval"]
path = fastvideo/third_party/eval/vqeval
url = https://github.com/JiusiServe/LongVideoSparseAttention.git
+10 -2
View File
@@ -6,10 +6,10 @@ exclude: |
fastvideo/third_party/.*|
fastvideo-kernel/.*|
assets/.*|
tests/.*|
(?<!docs/)tests/.*|
scripts/.*|
fastvideo/dataset/.*|
fastvideo/models/.*|
fastvideo/models/(?!wan/(config|vae_config|pipeline_config|definition|__init__)\.py$).*|
^apps/dreamverse/web/.*|
examples/.*|
\.agents/.*|
@@ -65,6 +65,14 @@ repos:
language: system
always_run: true
pass_filenames: false
- id: cookbook-javascript
name: Cookbook JavaScript lint and format
entry: npm run check:cookbook --prefix docs
language: system
# CI's trusted manual stage must not execute PR-authored npm scripts.
stages: [pre-commit]
files: ^docs/(js/(build-)?cookbook-.*\.mjs|tests/.*\.test\.mjs|eslint\.config\.mjs|prettier\.config\.mjs|package(-lock)?\.json)$
pass_filenames: false
# Keep `suggestion` last
- id: suggestion
name: Suggestion
+4
View File
@@ -66,14 +66,18 @@ Local guidance lives next to the code. Read the in-scope file before editing:
| `fastvideo/AGENTS.md` | Core package map, public API, registry-driven model dispatch |
| `fastvideo/configs/AGENTS.md` | Arch + pipeline config dataclasses, `param_names_mapping` |
| `fastvideo/models/AGENTS.md` | DiT / VAE / encoder / scheduler / loader layout (pre-commit excluded) |
| `fastvideo/models/wan/AGENTS.md` | Wan family-local transformers, VAE, configs, and the SP sharding invariant |
| `fastvideo/layers/AGENTS.md` | Tensor-parallel linear/attention layer rules for ports |
| `fastvideo/attention/AGENTS.md` | Backend registry + env-var override |
| `fastvideo/pipelines/AGENTS.md` | Stage ABC, `basic/<model>/`, `preprocess/`, presets |
| `fastvideo/pipelines/basic/wan/AGENTS.md` | Wan sampling stages, first-frame conditioning, DMD/causal boundaries |
| `fastvideo/pipelines/basic/magi_human/AGENTS.md` | MagiHuman umbrella repo, lazy-loaded components, packing invariants |
| `fastvideo/training/AGENTS.md` | Legacy monolithic pipelines (frozen for existing models) |
| `fastvideo/train/AGENTS.md` | New modular trainer (methods × models × callbacks, YAML) |
| `fastvideo/tests/AGENTS.md` | Test taxonomy, conftest, pre-commit-excluded path |
| `fastvideo/tests/ssim/AGENTS.md` | GPU SSIM regression authoring + reference video sync |
| `scripts/checkpoint_conversion/AGENTS.md` | Adding a converter for a new HF/official checkpoint |
| `apps/dreamverse/AGENTS.md` | DreamVerse app structure and conventions |
## Critical: Two Training Stacks Coexist
+15 -10
View File
@@ -3,14 +3,18 @@
</div>
<p align="center">
| <a href="https://hao-ai-lab.github.io/FastVideo"><b>Documentation</b></a> | <a href="https://hao-ai-lab.github.io/FastVideo/inference/inference_quick_start/"><b> Quick Start</b></a> | <a href="https://github.com/hao-ai-lab/FastVideo/discussions/982" target="_blank"><b>Weekly Dev Meeting</b></a> | 🟣💬 <a href="https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ" target="_blank"> <b>Slack</b> </a> | 🟣💬 <a href="https://github.com/hao-ai-lab/FastVideo/discussions/1097" target="_blank"> <b> WeChat </b> </a> |
| <a href="https://hao-ai-lab.github.io/FastVideo"><b>Documentation</b></a> | <a href="https://haoailab.com/FastVideo/cookbook/"><b>Cookbook</b></a> | <a href="https://hao-ai-lab.github.io/FastVideo/inference/inference_quick_start/"><b> Quick Start</b></a> | <a href="https://github.com/hao-ai-lab/FastVideo/discussions/982" target="_blank"><b>Weekly Dev Meeting</b></a> | 🟣💬 <a href="https://join.slack.com/t/fastvideo/shared_invite/zt-3f4lao1uq-u~Ipx6Lt4J27AlD2y~IdLQ" target="_blank"> <b>Slack</b> </a> | 🟣💬 <a href="https://github.com/hao-ai-lab/FastVideo/discussions/1097" target="_blank"> <b> WeChat </b> </a> |
</p>
**FastVideo is a unified post-training and real-time inference framework for accelerated video generation.**
## NEWS
- `2026/08/23`: [FastH3 Preview v0.2](https://huggingface.co/FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2) is a 4-step DMD2-distilled MiniMax-H3 checkpoint that generates synchronized video and audio. Run the verified [basic FastH3 example](examples/inference/basic/basic_fasth3.py), see the [inference guide](examples/inference/basic/README.md#fasth3-preview), or have a coding agent install FastVideo with the [agent setup prompt](#install-with-an-ai-coding-agent).
- `2026/08/19`: FastVideo now supports MLX on Apple Silicon with [FastMetal-QAD](https://huggingface.co/collections/FastVideo/fastmetal), a family of 1.3B, 5B, and 14B models optimized for Mac—follow the [Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/) and read the [Blog](https://haoailab.com/blogs/fastmetal/).
- `2026/10/06`: FastH3 V2 now runs on a single consumer machine: NVIDIA RTX 5090, RTX 4090 and RTX PRO 6000 GPUs, DGX Spark and Apple Silicon. We also release [FastH3 Trim](https://huggingface.co/FastVideo/FastVideo-FastH3-Trim-8-Step-NVFP4), an experimental pruned model that is 4.2× smaller than base H3 and runs in as little as 8 GB of GPU memory. Get the [models](https://huggingface.co/collections/FastVideo/fastvideo-fasth3) and read the [Blog](https://haoailab.com/blogs/fasth3-rtx/).
- `2026/10/06`: FastVideo now supports [Kandinsky 6](https://x.com/kandinskylab_ai/status/2107374635218055345) from Kandinsky Lab: text- and image-to-video with synchronized audio (base and 10-step distilled pi-Flow checkpoints) plus video super-resolution up to 4x. See the [Kandinsky 6 recipes](https://haoailab.com/FastVideo/cookbook/kandinsky6/).
- `2026/09/15`: Release [FastH3 8-Step V2](https://huggingface.co/FastVideo/FastVideo-FastH3-8-Step-V2), an eight-forward data-free DMD2 checkpoint distilled from MiniMax-H3 with 80% Video Sparse Attention. Run it with `examples/inference/basic/basic_fasth3_8step.py` or the [FastH3 8-Step V2 recipe](https://haoailab.com/FastVideo/cookbook/minimax-h3/).
- `2026/09/01`: FastH3 now runs locally on Apple Silicon through MLX and on NVIDIA DGX Spark through CUDA 13, including two-Spark inference. Follow the [FastH3 recipes](https://haoailab.com/FastVideo/cookbook/minimax-h3/) and read the [Blog](https://haoailab.com/blogs/fasth3-local/).
- `2026/08/27`: [FastH3 Preview v1](https://haoailab.com/blogs/fasth3-preview/) is an open-weight 4-step sparse-distilled MiniMax-H3 model for synchronized video-and-audio generation, developed in collaboration with [Nuva Lab](https://nuvalab.ai/) and the [NVIDIA FastGen team](https://github.com/NVlabs/FastGen). Download the recommended [VSA / Data-Free weights](https://huggingface.co/FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree), or see the [full FastH3 collection](https://huggingface.co/collections/FastVideo/fastvideo-fasth3).
- `2026/08/19`: FastVideo now supports MLX on Apple Silicon with [FastMetal-QAD](https://huggingface.co/collections/FastVideo/fastmetal), a family of 1.3B, 5B, and 14B models optimized for Mac. Follow the [MLX install guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mlx/) and read the [Blog](https://haoailab.com/blogs/fastmetal/).
- `2026/06/23`: Release FastWan-QAD: 5s of Video generated in 1.8s E2E. See the [FastWan-QAD models](https://huggingface.co/FastVideo/FastWan-QAD-FP8-1.3B), [Attn-QAT training guide](https://haoailab.com/FastVideo/training/attn_qat/), and [blog](https://haoailab.com/blogs/fastwan-qad/).
- `2026/03/17`: Release demo: Into the Dreamverse: Vibe Directing in FastVideo, check out the [Blog](https://haoailab.com/blogs/dreamverse/).
- `2026/03/13`: Release demo: Create a 5s 1080p Video in 4.5s with FastVideo on a Single GPU, check out the [Blog](https://haoailab.com/blogs/fastvideo_realtime_1080p/).
@@ -62,13 +66,12 @@ UV_TORCH_BACKEND=cu126 uv pip install fastvideo
```
Use `UV_TORCH_BACKEND=cu130` on CUDA 13. Apple silicon users should follow the
[MPS installation guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/).
[MLX install guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mlx/).
> **On an Apple Silicon Mac?** FastVideo runs FastMetal-QAD through an MLX
> runtime. Install with `uv pip install -e '.[mlx]'`, download
> [`FastVideo/FastMetal-1.3B-QAD`](https://huggingface.co/FastVideo/FastMetal-1.3B-QAD),
> and follow the
> [Apple Silicon guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mps/).
> **On an Apple Silicon Mac?** Install with `uv pip install -e '.[mlx]'` from
> a clone, then pick a recipe in the
> [cookbook](https://haoailab.com/FastVideo/cookbook/). See the
> [MLX install guide](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/mlx/).
Please see our [docs](https://hao-ai-lab.github.io/FastVideo/getting_started/installation/) for more detailed installation instructions.
@@ -86,7 +89,7 @@ Install FastVideo (https://github.com/hao-ai-lab/FastVideo) into a fresh uv virt
https://hao-ai-lab.github.io/FastVideo/getting_started/installation/):
- NVIDIA GPU, x86_64 -> docs/getting_started/installation/gpu.md
- NVIDIA DGX Spark / GB10, aarch64, CUDA 13 -> docs/getting_started/installation/spark.md
- Apple Silicon, macOS -> docs/getting_started/installation/mps.md
- Apple Silicon, macOS -> docs/getting_started/installation/mlx.md
3. Use uv for every step. If a command fails, debug it and tell me what you changed.
4. Verify the result:
python -c "import fastvideo, torch; print('cuda', torch.cuda.is_available())"
@@ -151,6 +154,8 @@ if __name__ == '__main__':
main()
```
`num_gpus=1` runs the worker in-process (weights load once, no extra Python process). On Colab/Kaggle-style machines with ~16GB host RAM, keep `num_gpus=1`; free-tier system memory does not grow with extra T4s, so `num_gpus>1` is likely to OOM.
Run the script with:
```bash
+57 -2
View File
@@ -97,13 +97,33 @@ dreamverse-server --port 8009
dreamverse-mock-server --port 8009
```
### Run Dreamverse with FastH3
Select the VSA data-free FastH3 Preview profile when you start the backend:
```bash
DREAMVERSE_MODEL_ID=fast-h3 dreamverse-server --port 8009
```
The `fast-h3` profile uses four visible GPUs by default. It loads the `MiniMaxAI/MiniMax-H3` base checkpoint and the
`vsa-datafree/adapter_model.safetensors` adapter from
`FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA`. Each request generates a 124-frame, 768×1344 video with
synchronized audio and five sigma-grid points. Dreamverse uses the last frame of each segment as first-frame
conditioning for the following segment.
Set `CUDA_VISIBLE_DEVICES` when you need to choose the four physical GPUs:
```bash
CUDA_VISIBLE_DEVICES=0,1,2,3 DREAMVERSE_MODEL_ID=fast-h3 dreamverse-server --port 8009
```
> **Expect a slow first boot.** With `torch.compile` and startup warmup enabled
> (the default), the backend compiles the segment 1 and segment 2 inference
> paths before it reports ready — this can take **tens of minutes on a cold
> cache**, regardless of how you deploy (local, server, Docker, or Modal).
> `/healthz` responds as soon as the process is up; `/readyz` stays `503` until
> warmup finishes. For a faster, uncompiled startup while testing, set
> `FASTVIDEO_ENABLE_STARTUP_WARMUP=0` before starting the backend.
> warmup finishes. To defer compilation until the first generated request while
> testing, set `FASTVIDEO_ENABLE_STARTUP_WARMUP=0` before starting the backend.
## Frontend Setup
@@ -138,6 +158,40 @@ dreamverse-server --host 0.0.0.0 --port 8009
The Dreamverse backend defaults to `0.0.0.0:8009` and starts one GPU worker on
the first visible GPU by default.
### Cosmos Predict2.5 DFD continuation (experimental)
Dreamverse can combine two converted Cosmos Predict2.5 2B packages: the
distilled Text2World student creates an unconditioned first segment, then the
Data-Forcing Distillation (DFD) Video2World student conditions each later
segment on the prior terminal frame. Point the runtime at both local converted
packages:
```bash
export DREAMVERSE_MODEL_ID=cosmos25-dfd
export DREAMVERSE_MODEL_PATH=/path/to/Cosmos-Predict2.5-2B-Distilled-TrigFlow-FastVideo
export DREAMVERSE_COSMOS25_DFD_MODEL_PATH=/path/to/Cosmos-Predict2.5-2B-DFD-FastVideo
export ENABLE_TORCH_COMPILE=0
dreamverse-server --host 0.0.0.0 --port 8009
```
The backend loads and warms both model roles before reporting ready. Both use
BF16, Torch SDPA, 704x1280 output, 24 FPS, and four steps. Bootstrap segments
contain 77 frames. DFD segments contain 81 decoded frames, but Dreamverse drops
the repeated conditioning frame before streaming, leaving 80 new frames. An
initial user image selects DFD immediately without treating that first frame as
a cross-segment overlap.
The profile uses a 30-minute session lease because sequential generation on
GB10-class hardware can exceed Dreamverse's five-minute default while the GPU
is still making progress. Deployments can override the lease with
`FASTVIDEO_SESSION_TIMEOUT_SECONDS`.
Cosmos does not produce audio, so the backend supplies duration-matched silent
24 kHz audio for the existing browser streaming contract and trims 1,000 audio
samples with each repeated DFD boundary frame. Runtime LoRA changes are not
supported. Full segments take roughly 145 seconds on GB10, so this profile is a
continuation-quality integration rather than a real-time configuration.
### Check Readiness
In another shell, verify that the backend process is alive:
@@ -219,6 +273,7 @@ selection, and mock-server behavior:
pytest apps/dreamverse/dreamverse/tests/test_config.py \
apps/dreamverse/dreamverse/tests/test_entrypoints.py \
apps/dreamverse/dreamverse/tests/test_gpu_pool.py \
apps/dreamverse/dreamverse/tests/test_minimax_h3_generation.py \
apps/dreamverse/dreamverse/tests/test_mock_server.py -q
```
+12 -1
View File
@@ -139,7 +139,18 @@ session.
- startup warmup
- user join/leave commands
- `USER_STEP` execution for each segment
- continuation state between segments
- generation-command routing and stream-result delivery
Model generation has a separate ownership boundary inside each GPU process:
- `apps/dreamverse/dreamverse/generation_worker.py` selects the backend that the active model profile declares and owns
the backend lifecycle.
- `apps/dreamverse/dreamverse/ltx2_generation.py` owns LTX-2 generator configuration, video and audio continuation, and
runtime LoRA application.
- `apps/dreamverse/dreamverse/minimax_h3_generation.py` owns the VSA data-free FastH3 adapter, FastH3 generator and
request configuration, and last-frame continuation through MiniMax H3 first-frame conditioning.
- `apps/dreamverse/dreamverse/generation_contracts.py` defines the decoded media and stream-trimming result that both
model backends return to `apps/dreamverse/dreamverse/gpu_pool.py`.
`apps/dreamverse/dreamverse/prompt_enhancer.py` manages:
+184
View File
@@ -0,0 +1,184 @@
"""Bounded, runtime-local media library shared by the HTTP and generation APIs."""
from __future__ import annotations
import json
import math
import os
import re
import shutil
import subprocess
import tempfile
import threading
import uuid
from dataclasses import dataclass
from pathlib import Path
from PIL import Image, UnidentifiedImageError
IMAGE_LIMIT = 15 * 1024 * 1024
MEDIA_LIMIT = 100 * 1024 * 1024
STORE_LIMIT = 2 * 1024 * 1024 * 1024
ASSET_LIMIT = 100
MAX_MEDIA_SECONDS = 30
MIME_TYPES = {
"image/png": ("image", ".png"),
"image/jpeg": ("image", ".jpg"),
"image/webp": ("image", ".webp"),
"video/mp4": ("video", ".mp4"),
"video/quicktime": ("video", ".mov"),
"video/webm": ("video", ".webm"),
"audio/mpeg": ("audio", ".mp3"),
"audio/mp4": ("audio", ".m4a"),
"audio/x-m4a": ("audio", ".m4a"),
"audio/wav": ("audio", ".wav"),
"audio/x-wav": ("audio", ".wav"),
"audio/flac": ("audio", ".flac"),
"audio/x-flac": ("audio", ".flac"),
"audio/ogg": ("audio", ".ogg"),
"audio/webm": ("audio", ".webm"),
}
@dataclass(frozen=True)
class StoredAsset:
asset_id: str
kind: str
path: str
name: str
mime_type: str
size: int
def public(self) -> dict:
return {
"asset_id": self.asset_id,
"kind": self.kind,
"name": self.name,
"mime_type": self.mime_type,
"size": self.size,
"url": f"/assets/{self.asset_id}",
}
def validate_media(path: Path, mime_type: str) -> None:
"""Inspect content, not filenames; refuse playlists and non-media uploads."""
kind = MIME_TYPES[mime_type][0]
if kind == "image":
try:
with Image.open(path) as img:
expected = {"image/png": "PNG", "image/jpeg": "JPEG", "image/webp": "WEBP"}[mime_type]
if img.format != expected:
raise ValueError("The image content does not match its file type.")
if img.width * img.height > 16_777_216:
raise ValueError("Images must contain at most 16 megapixels.")
if getattr(img, "is_animated", False):
raise ValueError("Use a still image or upload the animation as a video.")
img.verify()
except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as exc:
raise ValueError("The image could not be decoded. Use PNG, JPEG, or WebP.") from exc
return
probe = shutil.which(os.getenv("FASTVIDEO_FFPROBE_BIN", "ffprobe"))
if not probe:
raise ValueError("This runtime needs ffprobe installed to accept video and audio assets.")
try:
result = subprocess.run(
[
probe, "-v", "error", "-protocol_whitelist", "file,pipe", "-format_whitelist",
"mov,matroska,webm,mp3,wav,flac,ogg", "-show_format", "-show_streams", "-of", "json",
str(path)
],
check=True,
capture_output=True,
timeout=15,
)
info = json.loads(result.stdout)
formats = set(info.get("format", {}).get("format_name", "").split(","))
if not formats.intersection({"mov", "mp4", "matroska", "webm", "mp3", "wav", "flac", "ogg"}):
raise ValueError("Upload a media file, not a playlist or external reference.")
streams = [stream for stream in info.get("streams", []) if stream.get("codec_type") == kind]
if not streams:
raise ValueError(f"The file contains no {kind} stream.")
for stream in info.get("streams", []):
if stream.get("codec_type") == "audio" and int(stream.get("channels", 0)) not in (1, 2):
raise ValueError("H3 references require mono or stereo audio, including video soundtracks.")
duration = float(info.get("format", {}).get("duration", "nan"))
if not math.isfinite(duration) or not 0 < duration <= MAX_MEDIA_SECONDS:
raise ValueError(f"Reference video and audio must be between 0 and {MAX_MEDIA_SECONDS} seconds long.")
for stream in streams:
if kind == "video" and int(stream.get("width", 0)) * int(stream.get("height", 0)) > 8_294_400:
raise ValueError("Reference videos must be 4K or smaller.")
except (subprocess.SubprocessError, json.JSONDecodeError, OSError) as exc:
raise ValueError("The media file could not be decoded. Check its format and try again.") from exc
class AssetStore:
"""Assets live until deletion or runtime exit; pinned generation inputs cannot be deleted."""
def __init__(self) -> None:
self._directory: tempfile.TemporaryDirectory | None = None
self._assets: dict[str, StoredAsset] = {}
self._pins: dict[str, int] = {}
self._lock = threading.RLock()
def staging_path(self, mime_type: str) -> Path:
with self._lock:
if mime_type not in MIME_TYPES:
raise ValueError("Unsupported media type. Use PNG/JPEG/WebP, MP4/WebM/MOV, or WAV/MP3/M4A/FLAC/OGG.")
if len(self._assets) >= ASSET_LIMIT or sum(item.size for item in self._assets.values()) >= STORE_LIMIT:
raise ValueError("The runtime asset library is full. Remove unused assets before uploading more.")
if self._directory is None:
self._directory = tempfile.TemporaryDirectory(prefix="dreamverse-assets-")
return Path(self._directory.name) / f"{uuid.uuid4().hex}{MIME_TYPES[mime_type][1]}"
def add(self, path: Path, name: str, mime_type: str) -> StoredAsset:
validate_media(path, mime_type)
size = path.stat().st_size
if size == 0 or size > (IMAGE_LIMIT if MIME_TYPES[mime_type][0] == "image" else MEDIA_LIMIT):
raise ValueError("The asset is empty or exceeds its upload size limit.")
with self._lock:
if len(self._assets) >= ASSET_LIMIT or size + sum(item.size
for item in self._assets.values()) > STORE_LIMIT:
raise ValueError("The runtime asset library is full. Remove unused assets before uploading more.")
if self._directory is None or path.parent != Path(self._directory.name):
raise ValueError("The asset must be uploaded to this runtime.")
display_name = re.sub(r"[\x00-\x1f\x7f/\\]", "_", name).strip()[:200] or "Untitled asset"
asset = StoredAsset(path.stem, MIME_TYPES[mime_type][0], str(path), display_name, mime_type, size)
self._assets[asset.asset_id] = asset
return asset
def get(self, asset_id: str) -> StoredAsset:
with self._lock:
if not isinstance(asset_id, str) or not re.fullmatch(r"[a-f0-9]{32}", asset_id):
raise ValueError("Invalid asset ID. Upload or select an asset from the library.")
asset = self._assets.get(asset_id)
if asset is None or not Path(asset.path).is_file():
raise ValueError("An asset is no longer available. Upload it again and reselect it.")
return asset
def pin(self, asset_ids: list[str]) -> None:
with self._lock:
for asset_id in asset_ids:
self.get(asset_id)
for asset_id in asset_ids:
self._pins[asset_id] = self._pins.get(asset_id, 0) + 1
def release(self, asset_ids: list[str]) -> None:
with self._lock:
for asset_id in asset_ids:
count = self._pins.get(asset_id, 0)
if count > 1:
self._pins[asset_id] = count - 1
else:
self._pins.pop(asset_id, None)
def delete(self, asset_id: str) -> None:
with self._lock:
asset = self.get(asset_id)
if self._pins.get(asset_id, 0):
raise ValueError("This asset is in use by a generation session. End the session before deleting it.")
Path(asset.path).unlink(missing_ok=True)
del self._assets[asset_id]
asset_store = AssetStore()
@@ -1,6 +1,6 @@
"""Benchmark the LTX-2 generation pipeline driven by the dreamverse Python SDK path.
Mirrors how ``apps/dreamverse/dreamverse/video_generation.py`` constructs
Mirrors how ``apps/dreamverse/dreamverse/ltx2_generation.py`` constructs
``GeneratorConfig`` and calls ``VideoGenerator.generate()``, then
captures per-stage timings via the ``FASTVIDEO_STAGE_LOGGING=1`` log
hooks (same mechanism as ``FastVideo-internal/examples/inference/basic/
@@ -111,7 +111,13 @@ def _build_generator_config(model_path: str, enable_compile: bool, num_gpus: int
mode="max-autotune-no-cudagraphs",
dynamic=False),
use_fsdp_inference=False,
quantization=QuantizationConfig(transformer_quant="NVFP4"),
# The bundled LTX2 model enables a refinement LoRA during the
# first request. NVFP4 otherwise purges the dense weights that
# FastVideo's LoRA merge path requires.
quantization=QuantizationConfig(
transformer_quant="NVFP4",
transformer_retain_original_weights=True,
),
),
pipeline=PipelineSelection(
components=components,
+90 -18
View File
@@ -1,5 +1,6 @@
import os
from pathlib import Path
from typing import cast
_REPO_ROOT = Path(__file__).resolve().parents[1]
_SERVER_ROOT = Path(__file__).resolve().parent
@@ -55,16 +56,65 @@ FRONTEND_STATIC_DIR_CANDIDATES = _resolve_frontend_static_dir_candidates()
MODEL_REGISTRY = {
"fast-ltx2": {
"name": "FastLTX2",
"generation_backend": "ltx2",
"default_sp_size": 1,
"model_path": "FastVideo/LTX2-Distilled-Diffusers",
"config_model_path": "FastVideo/LTX2-Distilled-Diffusers",
"lora_repo": "FastVideo/LTX2-OmniNFT-LoRA",
},
"fast-ltx23": {
"name": "FastLTX23",
"generation_backend": "ltx2",
"default_sp_size": 1,
"model_path": "FastVideo/LTX-2.3-Distilled-Diffusers",
"config_model_path": "FastVideo/LTX-2.3-Distilled-Diffusers",
"lora_repo": "FastVideo/LTX-2.3-OmniNFT-LoRA",
},
"fast-h3": {
"name": "FastH3",
"generation_backend": "minimax_h3",
"default_sp_size": 4,
"model_path": "MiniMaxAI/MiniMax-H3",
"adapter_repo": "FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA",
"adapter_filename": "vsa-datafree/adapter_model.safetensors",
"attention_backend": "VIDEO_SPARSE_ATTN_H3",
"height": 768,
"width": 1344,
"num_frames": 124,
"num_inference_steps": 5,
"seed": 1000,
},
"full-h3": {
"name": "MiniMax H3 (Full)",
"generation_backend": "minimax_h3",
"default_sp_size": 4,
"model_path": "MiniMaxAI/MiniMax-H3",
"attention_backend": "FLASH_ATTN",
"height": 768,
"width": 1344,
"num_frames": 124,
"num_inference_steps": 50,
"seed": 1000,
"full_checkpoint": True,
},
"cosmos25-dfd": {
"name": "Cosmos Predict2.5 DFD",
"generation_backend": "cosmos25_dfd",
"default_sp_size": 1,
"model_path": "FastVideo/Cosmos-Predict2.5-2B-Distilled-TrigFlow",
"continuation_model_path": "FastVideo/Cosmos-Predict2.5-2B-DFD",
"attention_backend": "TORCH_SDPA",
"height": 704,
"width": 1280,
"bootstrap_num_frames": 77,
"continuation_num_frames": 81,
"fps": 24,
"num_inference_steps": 4,
"seed": 42,
# Six sequential GB10 segments can exceed the legacy five-minute
# DreamVerse lease even though the GPU is making progress.
"session_timeout_seconds": 1800,
},
}
DEFAULT_MODEL_ID = "fast-ltx2"
@@ -76,22 +126,6 @@ if ACTIVE_MODEL_ID not in MODEL_REGISTRY:
# Active model configuration
MODEL_CONFIG = MODEL_REGISTRY[ACTIVE_MODEL_ID]
# Generation limits
SESSION_TIMEOUT_SECONDS = 300
# Frame settings
NUM_FRAMES = 121
FRAME_HEIGHT = 1088
FRAME_WIDTH = 1920
NUM_INFERENCE_STEPS = 5
JPEG_QUALITY = 100
BATCH_SIZE = 3
# Streaming mode:
# - legacy_jpeg: send frame_batch JSON payloads with base64 JPEGs
# - av_fmp4: send muxed fMP4 binary chunks over WebSocket
STREAM_MODE = os.getenv("STREAM_MODE", "av_fmp4").strip().lower()
def _env_int(name: str, default: int) -> int:
value = os.getenv(name)
@@ -168,10 +202,41 @@ def _optional_env(*names: str) -> str | None:
return None
# Generation limits
# Slower backends may own a longer default lease. A profile can set
# ``session_timeout_seconds``; Full H3 loads and generates substantially longer
# than the Preview adapter, which also covers a base/ref pipeline reload inside a
# retained session. An explicit environment override remains available for
# deployment policy: DREAMVERSE_SESSION_TIMEOUT_SECONDS, with
# FASTVIDEO_SESSION_TIMEOUT_SECONDS accepted as an alias.
# Values below 60 seconds are floored so a single segment cannot outlast the session.
_DEFAULT_SESSION_TIMEOUT_SECONDS = cast(
int, MODEL_CONFIG.get("session_timeout_seconds", 7200 if ACTIVE_MODEL_ID == "full-h3" else 300))
SESSION_TIMEOUT_SECONDS = max(
60,
_env_int(
"DREAMVERSE_SESSION_TIMEOUT_SECONDS",
_env_int("FASTVIDEO_SESSION_TIMEOUT_SECONDS", _DEFAULT_SESSION_TIMEOUT_SECONDS),
),
)
# Frame settings
NUM_FRAMES = 121
FRAME_HEIGHT = 1088
FRAME_WIDTH = 1920
NUM_INFERENCE_STEPS = 5
JPEG_QUALITY = 100
BATCH_SIZE = 3
# Streaming mode:
# - legacy_jpeg: send frame_batch JSON payloads with base64 JPEGs
# - av_fmp4: send muxed fMP4 binary chunks over WebSocket
STREAM_MODE = os.getenv("STREAM_MODE", "av_fmp4").strip().lower()
DEVTOOLS_ENABLED = _env_bool("FASTVIDEO_ENABLE_DEVTOOLS", False)
PROMPT_SAFETY_ENABLED = _env_bool("FASTVIDEO_ENABLE_PROMPT_SAFETY", False)
DREAMVERSE_MAX_AUTOTUNE = _env_bool("DREAMVERSE_MAX_AUTOTUNE", True)
DREAMVERSE_SP_SIZE = max(1, _env_int("DREAMVERSE_SP_SIZE", 1))
DREAMVERSE_SP_SIZE = max(1, _env_int("DREAMVERSE_SP_SIZE", cast(int, MODEL_CONFIG["default_sp_size"])))
DREAMVERSE_MODEL_PATH = (os.getenv("DREAMVERSE_MODEL_PATH", "").strip() or None)
if DREAMVERSE_MODEL_PATH:
@@ -181,6 +246,13 @@ if DREAMVERSE_MODEL_PATH:
"config_model_path": DREAMVERSE_MODEL_PATH,
}
DREAMVERSE_COSMOS25_DFD_MODEL_PATH = (os.getenv("DREAMVERSE_COSMOS25_DFD_MODEL_PATH", "").strip() or None)
if DREAMVERSE_COSMOS25_DFD_MODEL_PATH and MODEL_CONFIG.get("generation_backend") == "cosmos25_dfd":
MODEL_CONFIG = {
**MODEL_CONFIG,
"continuation_model_path": DREAMVERSE_COSMOS25_DFD_MODEL_PATH,
}
AVAILABLE_LORAS = {
"pixar": {
"repo": "vrgamedevgirl84/LTX_2.3_Pixar_Toon_Style_LoRa",
@@ -213,7 +285,7 @@ def _resolve_lora_spec(spec: str) -> str | None:
if not spec:
return None
if spec.lower() == "omninft":
return MODEL_CONFIG.get("lora_repo")
return cast(str | None, MODEL_CONFIG.get("lora_repo"))
if spec.lower() in AVAILABLE_LORAS:
return AVAILABLE_LORAS[spec.lower()]["repo"]
return spec
@@ -0,0 +1,252 @@
"""Cosmos Predict2.5 distilled bootstrap and DFD continuation for DreamVerse."""
from __future__ import annotations
import gc
import os
import time
from typing import TYPE_CHECKING, Any
import numpy as np
import torch
from dreamverse.generation_contracts import StepResult
from dreamverse.generation_inputs import GenerationInputs
if TYPE_CHECKING:
from PIL.Image import Image
_SILENT_AUDIO_SAMPLE_RATE = 24_000
def _required_config_str(model_config: dict, field_name: str) -> str:
value = model_config.get(field_name)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"Cosmos Predict2.5 DFD model configuration requires `{field_name}`.")
return value.strip()
class Cosmos25DFDGenerationBackend:
"""Own complementary Cosmos T2W and one-frame-conditioned DFD generators."""
def __init__(self, gpu_id: int):
self.gpu_id = gpu_id
self.bootstrap_generator: Any | None = None
self.continuation_generator: Any | None = None
self.model_config: dict = {}
self.continuation_image: Image | None = None
def _gpu_mem(self) -> str:
allocated_gib = torch.cuda.memory_allocated() / 1024**3
reserved_gib = torch.cuda.memory_reserved() / 1024**3
return f"alloc={allocated_gib:.2f}GiB, reserved={reserved_gib:.2f}GiB"
@staticmethod
def _configure_environment(attention_backend: str) -> None:
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = attention_backend
os.environ.pop("FASTVIDEO_INFERENCE_TORCH_COMPILE", None)
@staticmethod
def _load_generator(model_path: str):
from fastvideo import VideoGenerator
return VideoGenerator.from_pretrained(
model_path,
num_gpus=1,
use_fsdp_inference=False,
dit_cpu_offload=False,
vae_cpu_offload=False,
text_encoder_cpu_offload=True,
pin_cpu_memory=True,
enable_torch_compile=False,
)
def initialize(self, model_config: dict | None = None) -> None:
"""Load both package roles so bootstrap and continuation are ready."""
if model_config is not None:
self.model_config = dict(model_config)
if not self.model_config:
raise ValueError("Cosmos Predict2.5 DFD initialization requires a model configuration.")
self.shutdown()
bootstrap_path = _required_config_str(self.model_config, "model_path")
continuation_path = _required_config_str(self.model_config, "continuation_model_path")
attention_backend = _required_config_str(self.model_config, "attention_backend")
self._configure_environment(attention_backend)
print(f"[GPU {self.gpu_id}] Loading Cosmos T2W bootstrap: {bootstrap_path}")
print(f"[GPU {self.gpu_id}] Before bootstrap load: {self._gpu_mem()}")
self.bootstrap_generator = self._load_generator(bootstrap_path)
print(f"[GPU {self.gpu_id}] Loading Cosmos DFD continuation: {continuation_path}")
self.continuation_generator = self._load_generator(continuation_path)
print(f"[GPU {self.gpu_id}] Cosmos T2W + DFD loaded: {self._gpu_mem()} (warmup pending)")
def shutdown(self) -> None:
"""Release both FastVideo generators and the retained terminal frame."""
self.clear_conditioning()
for attr_name in ("bootstrap_generator", "continuation_generator"):
generator = getattr(self, attr_name)
if generator is not None:
try:
generator.shutdown()
except Exception as exc:
print(f"[GPU {self.gpu_id}] Cosmos generator shutdown warning: {exc}")
setattr(self, attr_name, None)
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def clear_conditioning(self) -> None:
if self.continuation_image is not None:
self.continuation_image.close()
self.continuation_image = None
@staticmethod
def _load_rgb_image(image_path: str) -> Image:
from PIL import Image
with Image.open(image_path) as image:
return image.convert("RGB").copy()
def _select_conditioning_image(
self,
segment_idx: int,
image_path: str | None,
reset_conditioning: bool,
) -> tuple[Image | None, bool]:
if reset_conditioning:
self.clear_conditioning()
if segment_idx > 1 and self.continuation_image is not None:
return self.continuation_image.copy(), True
if segment_idx > 1 and not reset_conditioning:
raise RuntimeError(f"Cosmos DFD segment {segment_idx} requires a retained continuation frame.")
if segment_idx == 1 and image_path:
return self._load_rgb_image(image_path), False
return None, False
def _sampling_param(self, *, conditioned: bool):
# ``num_cond_frames`` is not yet exposed by the typed SamplingConfig,
# so this backend uses the compatibility request until that field lands.
from fastvideo.api.sampling_param import SamplingParam
num_frames_key = "continuation_num_frames" if conditioned else "bootstrap_num_frames"
return SamplingParam(
negative_prompt="",
save_video=False,
return_frames=True,
height=int(self.model_config["height"]),
width=int(self.model_config["width"]),
num_frames=int(self.model_config[num_frames_key]),
fps=int(self.model_config["fps"]),
num_inference_steps=int(self.model_config["num_inference_steps"]),
guidance_scale=1.0,
seed=int(self.model_config["seed"]),
num_cond_frames=1 if conditioned else 0,
)
def _save_continuation_frame(self, frame: object) -> None:
from PIL import Image
self.clear_conditioning()
if isinstance(frame, Image.Image):
self.continuation_image = frame.convert("RGB").copy()
return
pixels = np.asarray(frame)
self.continuation_image = Image.fromarray(np.ascontiguousarray(pixels)).convert("RGB")
@staticmethod
def _silent_audio(frame_count: int, fps: int) -> torch.Tensor:
sample_count = max(1, int(round((frame_count / float(fps)) * _SILENT_AUDIO_SAMPLE_RATE)))
return torch.zeros(sample_count, dtype=torch.float32)
def generate_step(
self,
prompt: str,
segment_idx: int,
image_path: str | None,
reset_conditioning: bool,
generation_inputs: GenerationInputs | None = None,
) -> StepResult:
"""Generate a T2W start or DFD continuation and retain its last frame."""
if generation_inputs is not None and (generation_inputs.mode not in (None, "t2va") or generation_inputs.assets):
raise ValueError("Cosmos supports text generation only through the generation mode API.")
if self.bootstrap_generator is None or self.continuation_generator is None:
raise RuntimeError("Cosmos T2W + DFD generators are not initialized.")
conditioning_image, uses_continuation = self._select_conditioning_image(
segment_idx,
image_path,
reset_conditioning,
)
conditioned = conditioning_image is not None
generator = self.continuation_generator if conditioned else self.bootstrap_generator
sampling_param = self._sampling_param(conditioned=conditioned)
started = time.perf_counter()
try:
if conditioned:
sampling_param.pil_image = conditioning_image
result = generator.generate_video(prompt, sampling_param=sampling_param)
finally:
if conditioning_image is not None:
conditioning_image.close()
torch.cuda.synchronize()
generation_ms = (time.perf_counter() - started) * 1000.0
if not isinstance(result, dict):
raise RuntimeError("Cosmos generation did not return one result dictionary.")
frames = result.get("frames")
expected_frames = int(sampling_param.num_frames)
if not isinstance(frames, list) or len(frames) != expected_frames:
actual_frames = len(frames) if isinstance(frames, list) else None
raise RuntimeError(f"Cosmos generation returned {actual_frames} frames; expected {expected_frames}.")
save_started = time.perf_counter()
self._save_continuation_frame(frames[-1])
save_conditioning_ms = (time.perf_counter() - save_started) * 1000.0
fps = int(sampling_param.fps)
timings = {
"generation_ms": generation_ms,
"generation_time_ms": float(result.get("generation_time") or 0.0) * 1000.0,
"save_conditioning_ms": save_conditioning_ms,
"e2e_latency_ms": (time.perf_counter() - started) * 1000.0,
}
trim_frames = 1 if uses_continuation else 0
mode = "DFD continuation" if conditioned else "T2W bootstrap"
print(f"[GPU {self.gpu_id}] Cosmos {mode} segment {segment_idx}: "
f"{len(frames)} frames, gen={generation_ms:.0f}ms, "
f"save_conditioning={save_conditioning_ms:.0f}ms, "
f"e2e={timings['e2e_latency_ms']:.0f}ms")
return StepResult(
frames=frames,
audio=self._silent_audio(len(frames), fps),
audio_sample_rate=_SILENT_AUDIO_SAMPLE_RATE,
timings=timings,
head_trim_frames=trim_frames,
head_trim_audio_frames=trim_frames,
)
def warmup(self, prompt: str) -> dict[str, float]:
"""Exercise both T2W bootstrap and retained-frame DFD request shapes."""
warmup_prompt = (prompt or "").strip()
if not warmup_prompt:
raise RuntimeError("Startup warmup prompt must be non-empty.")
print(f"[GPU {self.gpu_id}] Cosmos startup warmup starting "
"(synthetic segments: T2W bootstrap, DFD continuation)")
started = time.perf_counter()
bootstrap_result = self.generate_step(warmup_prompt, 1, None, True)
continuation_result = self.generate_step(warmup_prompt, 2, None, False)
total_ms = (time.perf_counter() - started) * 1000.0
self.clear_conditioning()
bootstrap_ms = float(bootstrap_result.timings.get("e2e_latency_ms", 0.0))
continuation_ms = float(continuation_result.timings.get("e2e_latency_ms", 0.0))
print(f"[GPU {self.gpu_id}] Cosmos startup warmup complete: "
f"bootstrap={bootstrap_ms:.0f}ms, continuation={continuation_ms:.0f}ms, total={total_ms:.0f}ms")
return {
"warmup_bootstrap_ms": bootstrap_ms,
"warmup_continuation_ms": continuation_ms,
"warmup_total_ms": total_ms,
}
def apply_lora_stack(self, stack: list[tuple[str, float]]) -> tuple[str | None, str | None]:
del stack
raise RuntimeError("Cosmos Predict2.5 DFD does not support DreamVerse runtime LoRA changes.")
@@ -0,0 +1,49 @@
"""Shared contract between DreamVerse generation backends and GPU workers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from dreamverse.generation_inputs import GenerationInputs
@dataclass
class StepResult:
"""Decoded media and stream-trimming metadata for one DreamVerse segment."""
frames: list
audio: Any
audio_sample_rate: int | None
timings: dict[str, float]
head_trim_frames: int
head_trim_audio_frames: int
class GenerationBackend(Protocol):
"""Model-owned generation operations used by one GPU worker process."""
def initialize(self, model_config: dict | None = None) -> None:
...
def shutdown(self) -> None:
...
def clear_conditioning(self) -> None:
...
def generate_step(
self,
prompt: str,
segment_idx: int,
image_path: str | None,
reset_conditioning: bool,
generation_inputs: GenerationInputs | None = None,
) -> StepResult:
...
def warmup(self, prompt: str) -> dict[str, float]:
...
def apply_lora_stack(self, stack: list[tuple[str, float]]) -> tuple[str | None, str | None]:
...
@@ -0,0 +1,102 @@
"""GPU-independent validation for generation modes and ordered asset handles."""
from __future__ import annotations
from dataclasses import dataclass
from PIL import Image, UnidentifiedImageError
from dreamverse.assets import asset_store
GENERATION_MODES = ("t2va", "fl2va", "ref2va")
@dataclass(frozen=True)
class GenerationAsset:
asset_id: str
kind: str
path: str
role: str
@dataclass(frozen=True)
class GenerationInputs:
mode: str | None = None
assets: tuple[GenerationAsset, ...] = ()
@property
def first_frame_path(self) -> str | None:
return next((asset.path for asset in self.assets if asset.role == "first_frame"), None)
@property
def last_frame_path(self) -> str | None:
return next((asset.path for asset in self.assets if asset.role == "last_frame"), None)
@property
def references(self) -> tuple[GenerationAsset, ...]:
return tuple(asset for asset in self.assets if asset.role == "reference")
def supported_generation_modes(model_id: str) -> tuple[str, ...]:
return GENERATION_MODES if model_id in ("full-h3", "mock") else ("t2va", )
def resolve_generation_inputs(payload: dict, model_id: str) -> GenerationInputs:
mode = payload.get("generation_mode")
raw_assets = payload.get("conditioning_assets", [])
if mode is None and "generation_mode" not in payload:
if raw_assets:
raise ValueError("Select a generation mode before attaching conditioning assets.")
return GenerationInputs()
if not isinstance(mode, str) or mode not in GENERATION_MODES:
raise ValueError("Unknown generation mode. Choose T2VA, FL2VA, or Ref2VA.")
if mode not in supported_generation_modes(model_id):
raise ValueError(f"{mode.upper()} requires the Full H3 runtime. This runtime is running {model_id}.")
if payload.get("initial_image") is not None:
raise ValueError("Use asset IDs for generation modes; do not combine them with the legacy initial_image field.")
if not isinstance(raw_assets, list) or len(raw_assets) > 12:
raise ValueError("conditioning_assets must be an ordered list with at most 12 assets.")
if mode == "t2va" and raw_assets:
raise ValueError("T2VA accepts text only. Remove conditioning assets or choose another mode.")
assets: list[GenerationAsset] = []
for item in raw_assets:
if not isinstance(item, dict) or set(item) != {"asset_id", "role"}:
raise ValueError("Each conditioning asset must contain only asset_id and role.")
role = item["role"]
if role not in ("first_frame", "last_frame", "reference"):
raise ValueError("Asset role must be first_frame, last_frame, or reference.")
stored = asset_store.get(item["asset_id"])
assets.append(GenerationAsset(stored.asset_id, stored.kind, stored.path, role))
if mode == "fl2va":
if any(asset.kind != "image" or asset.role == "reference" for asset in assets):
raise ValueError("FL2VA accepts only first-frame and last-frame images.")
if sum(asset.role == "first_frame" for asset in assets) != 1:
raise ValueError("FL2VA requires exactly one first-frame image.")
if sum(asset.role == "last_frame" for asset in assets) > 1:
raise ValueError("FL2VA accepts at most one last-frame image.")
elif mode == "ref2va":
if not assets or any(asset.role != "reference" for asset in assets):
raise ValueError("Ref2VA requires an ordered list of reference assets, without keyframe roles.")
if not any(asset.kind in ("image", "video") for asset in assets):
raise ValueError("Ref2VA requires at least one image or video; audio alone is not supported.")
for kind, limit in (("image", 9), ("video", 3), ("audio", 3)):
if sum(asset.kind == kind for asset in assets) > limit:
raise ValueError(f"Ref2VA accepts at most {limit} {kind} references.")
for asset in assets:
if asset.kind == "image":
try:
with Image.open(asset.path) as image:
if image.width > 4 * image.height or image.height > 4 * image.width:
raise ValueError(
"Ref2VA image aspect ratios must be between 1:4 and 4:1. Crop this image first.")
except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as exc:
raise ValueError("A selected reference image could not be decoded. Upload it again.") from exc
return GenerationInputs(mode, tuple(assets))
def pin_generation_inputs(inputs: GenerationInputs) -> None:
asset_store.pin([asset.asset_id for asset in inputs.assets])
def release_generation_inputs(inputs: GenerationInputs) -> None:
asset_store.release([asset.asset_id for asset in inputs.assets])
@@ -0,0 +1,103 @@
"""Select and own one model-specific generation backend per GPU process."""
from __future__ import annotations
from dreamverse.config import MODEL_CONFIG
from dreamverse.generation_contracts import GenerationBackend, StepResult
from dreamverse.generation_inputs import GenerationInputs
def _create_generation_backend(backend_name: str, gpu_id: int) -> GenerationBackend:
"""Construct the backend that owns the selected model family's behavior."""
if backend_name == "ltx2":
from dreamverse.ltx2_generation import LTX2GenerationBackend
return LTX2GenerationBackend(gpu_id)
if backend_name == "minimax_h3":
from dreamverse.minimax_h3_generation import MiniMaxH3GenerationBackend
return MiniMaxH3GenerationBackend(gpu_id)
if backend_name == "cosmos25_dfd":
from dreamverse.cosmos25_dfd_generation import Cosmos25DFDGenerationBackend
return Cosmos25DFDGenerationBackend(gpu_id)
raise ValueError(f"Unsupported DreamVerse generation backend: {backend_name!r}")
class VideoGenerationWorker:
"""Delegate GPU lifecycle and generation calls to the active model backend."""
def __init__(self, gpu_id: int):
self.gpu_id = gpu_id
self.model_config: dict = dict(MODEL_CONFIG)
self.backend_name: str | None = None
self.backend: GenerationBackend | None = None
def initialize(self, model_config: dict | None = None) -> None:
"""Load the requested model through its generation backend.
Model selection belongs here so the GPU process and streaming layers
use one stable media contract without importing model-specific code.
"""
requested_model_config = dict(model_config) if model_config is not None else dict(self.model_config)
backend_name = requested_model_config.get("generation_backend")
if not isinstance(backend_name, str) or not backend_name:
raise ValueError("DreamVerse model configuration requires `generation_backend`.")
candidate_backend = self.backend
if candidate_backend is None or self.backend_name != backend_name:
if candidate_backend is not None:
candidate_backend.shutdown()
candidate_backend = _create_generation_backend(backend_name, self.gpu_id)
try:
candidate_backend.initialize(requested_model_config)
except Exception:
try:
candidate_backend.shutdown()
except Exception as shutdown_error:
print(f"[GPU {self.gpu_id}] Backend cleanup after initialization failure: {shutdown_error}")
self.backend = None
self.backend_name = None
raise
self.model_config = requested_model_config
self.backend = candidate_backend
self.backend_name = backend_name
def _require_backend(self) -> GenerationBackend:
"""Return the initialized backend or fail before processing a command."""
if self.backend is None:
raise RuntimeError("Generation backend is not initialized.")
return self.backend
def shutdown(self) -> None:
"""Release model resources owned by the selected backend."""
if self.backend is not None:
self.backend.shutdown()
def clear_conditioning(self) -> None:
self._require_backend().clear_conditioning()
def generate_step(
self,
prompt: str,
segment_idx: int,
image_path: str | None,
reset_conditioning: bool,
generation_inputs: GenerationInputs | None = None,
) -> StepResult:
"""Generate one segment through the selected model backend."""
return self._require_backend().generate_step(
prompt,
segment_idx,
image_path,
reset_conditioning,
generation_inputs=generation_inputs,
)
def warmup(self, prompt: str) -> dict[str, float]:
return self._require_backend().warmup(prompt)
def apply_lora_stack(self, stack: list[tuple[str, float]]) -> tuple[str | None, str | None]:
return self._require_backend().apply_lora_stack(stack)
+46 -11
View File
@@ -12,7 +12,7 @@ from enum import Enum
from multiprocessing import Process, Queue
from dreamverse.config import (
DEFAULT_MODEL_ID,
ACTIVE_MODEL_ID,
DREAMVERSE_SP_SIZE,
MODEL_REGISTRY,
STARTUP_WARMUP_ENABLED,
@@ -29,6 +29,7 @@ from dreamverse.av_streaming import (
generate_stream_id,
stream_fmp4,
)
from dreamverse.generation_inputs import GenerationInputs, pin_generation_inputs, release_generation_inputs
from dreamverse.worker_ipc import (
CommandPayload,
InitAck,
@@ -54,7 +55,7 @@ from dreamverse.worker_ipc import (
def _parse_requested_gpu_limit() -> int | None:
raw_value = os.getenv("FASTVIDEO_GPU_COUNT", "").strip().lower()
if not raw_value:
return 1
return DREAMVERSE_SP_SIZE
if raw_value == "all":
return None
try:
@@ -164,12 +165,12 @@ def gpu_worker_process(
os.environ["CUDA_VISIBLE_DEVICES"] = cuda_device
os.environ["FASTVIDEO_ATTENTION_BACKEND"] = "FLASH_ATTN"
from dreamverse.video_generation import VideoGenerationWorker
from dreamverse.generation_worker import VideoGenerationWorker
worker = VideoGenerationWorker(gpu_id)
def event_loop(first_cmd: Command = None):
"""Blocking event loop for LTX2; dispatches user commands."""
"""Block on generation commands after the model is initialized."""
print(f"[GPU {gpu_id}] Entering event loop")
def handle_command(cmd: Command):
@@ -189,6 +190,7 @@ def gpu_worker_process(
segment_idx,
image_path=payload.image_path,
reset_conditioning=payload.reset_conditioning,
generation_inputs=payload.generation_inputs,
)
head_trim_frames = step_result.head_trim_frames
head_trim_audio_frames = step_result.head_trim_audio_frames
@@ -432,10 +434,11 @@ class GPUSlot:
self.connected_users: set[str] = set()
self._pending_futures: dict[str, asyncio.Future] = {}
self._stream_queues: dict[str, asyncio.Queue] = {}
self._step_asset_inputs: dict[str, GenerationInputs] = {}
self._response_reader_task: asyncio.Task | None = None
self._active: bool = False
self._reader_lock: asyncio.Lock | None = None
self.current_model_id: str = DEFAULT_MODEL_ID
self.current_model_id: str | None = ACTIVE_MODEL_ID
self.shared_stream_buffer = None
self.shared_stream_buffer_size = SHARED_STREAM_BUFFER_BYTES
@@ -663,6 +666,9 @@ class GPUSlot:
if isinstance(event, (StepComplete, WarmupComplete)):
event.timings["ipc_get_done_ns"] = time.time_ns()
if isinstance(event, (StepComplete, WorkerError)) and event.user_id is not None:
self._release_step_assets(event.user_id)
user_id = event.user_id
if user_id and user_id in self._pending_futures:
future = self._pending_futures.pop(user_id)
@@ -690,7 +696,7 @@ class GPUSlot:
async def join_user(self, user_id: str, model_id: str = None) -> JoinAck:
"""Add a user to this GPU."""
if model_id is None:
model_id = DEFAULT_MODEL_ID
model_id = ACTIVE_MODEL_ID
# Reload model if a different one is requested
if model_id != self.current_model_id and model_id in MODEL_REGISTRY:
@@ -705,16 +711,23 @@ class GPUSlot:
self.connected_users.clear()
model_config = MODEL_REGISTRY[model_id]
reload_response = await self._send_command(Command(CommandType.RELOAD_MODEL,
payload=ReloadModelPayload(model_config=model_config),
user_id="__reload__"),
timeout=600.0)
try:
reload_response = await self._send_command(Command(
CommandType.RELOAD_MODEL,
payload=ReloadModelPayload(model_config=model_config),
user_id="__reload__"),
timeout=600.0)
except Exception:
self.current_model_id = None
raise
match reload_response:
case ReloadAck():
pass
case WorkerError(message=msg):
self.current_model_id = None
raise RuntimeError(f"Model reload failed: {msg}")
case _:
self.current_model_id = None
raise RuntimeError(f"Unexpected reload response: "
f"{type(reload_response).__name__}")
@@ -746,6 +759,7 @@ class GPUSlot:
segment_idx: int = 1,
image_path: str | None = None,
reset_conditioning: bool = False,
generation_inputs: GenerationInputs | None = None,
) -> dict[str, float]:
"""Execute a generation step for a specific user.
@@ -759,9 +773,19 @@ class GPUSlot:
segment_idx=segment_idx,
image_path=image_path,
reset_conditioning=bool(reset_conditioning),
generation_inputs=generation_inputs,
)
if generation_inputs is not None:
if user_id in self._step_asset_inputs:
raise RuntimeError("The previous generation is still using this project's assets.")
pin_generation_inputs(generation_inputs)
self._step_asset_inputs[user_id] = generation_inputs
# Pins intentionally survive a waiter timeout/cancellation: the GPU
# command keeps running. The response reader releases them when the
# worker actually completes (even if that response is now unmatched).
response = await self._send_command_tagged(Command(CommandType.USER_STEP, payload=payload, user_id=user_id),
timeout=1800.0)
self._release_step_assets(user_id)
match response:
case StepComplete(timings=timings):
return timings
@@ -771,6 +795,11 @@ class GPUSlot:
raise RuntimeError(f"Unexpected step response for {user_id[:8]}: "
f"{type(response).__name__}")
def _release_step_assets(self, user_id: str) -> None:
inputs = self._step_asset_inputs.pop(user_id, None)
if inputs is not None:
release_generation_inputs(inputs)
async def apply_lora_stack(
self,
stack: list[tuple[str, float]],
@@ -793,7 +822,9 @@ class GPUSlot:
async def leave_user(self, user_id: str) -> None:
"""Remove a user from this GPU."""
try:
await self._send_command_tagged(Command(CommandType.USER_LEAVE, user_id=user_id), timeout=30.0)
response = await self._send_command_tagged(Command(CommandType.USER_LEAVE, user_id=user_id), timeout=30.0)
if isinstance(response, LeaveAck):
self._release_step_assets(user_id)
except Exception as e:
print(f"[GPU {self.gpu_id}] Leave user error: {e}")
finally:
@@ -830,6 +861,10 @@ class GPUSlot:
except Exception:
pass
if self.process is None or not self.process.is_alive():
for user_id in list(self._step_asset_inputs):
self._release_step_assets(user_id)
for q in (self.command_queue, self.response_queue):
if q is not None:
try:
@@ -1,9 +1,9 @@
"""LTX2 model lifecycle and continuation conditioning.
"""LTX-2 model lifecycle and continuation conditioning.
Runs inside a GPU worker subprocess. Owns the model, the audio
encoder, and the per-session continuation state carried across
segments. Callers must set ``os.environ["CUDA_VISIBLE_DEVICES"]``
before constructing ``VideoGenerationWorker`` — all ``fastvideo.*``
before constructing ``LTX2GenerationBackend`` — all ``fastvideo.*``
imports are deferred to method bodies so nothing touches CUDA at
module import time.
"""
@@ -14,9 +14,6 @@ import gc
import os
import re
import time
from dataclasses import dataclass
from typing import Any
import numpy as np
import torch
@@ -35,6 +32,8 @@ from dreamverse.config import (
DREAMVERSE_LORA_STACK,
_resolve_lora_spec,
)
from dreamverse.generation_contracts import StepResult
from dreamverse.generation_inputs import GenerationInputs
# Multi-frame decoded continuation defaults from
# examples/inference/basic/basic_ltx2_distilled_video_continuation.py.
@@ -80,22 +79,6 @@ def _reset_lora_registry(worker) -> dict:
return {"status": "lora_registry_reset"}
@dataclass
class StepResult:
"""Output of one generation step.
``head_trim_frames`` / ``head_trim_audio_frames`` are derived here
so downstream AV streaming never needs to import conditioning
constants.
"""
frames: list
audio: Any
audio_sample_rate: int | None
timings: dict
head_trim_frames: int
head_trim_audio_frames: int
class ContinuationState:
"""Per-session video + audio conditioning carried across segments."""
@@ -202,7 +185,7 @@ class ContinuationState:
self.audio_latents = latents.detach().clone().cpu()
class VideoGenerationWorker:
class LTX2GenerationBackend:
"""Single-GPU LTX2 generator with continuation state.
Caller must set ``os.environ["CUDA_VISIBLE_DEVICES"]`` before
@@ -308,7 +291,13 @@ class VideoGenerationWorker:
dynamic=False,
),
use_fsdp_inference=False,
quantization=QuantizationConfig(transformer_quant="NVFP4"),
# The bundled LTX2 model enables a refinement LoRA during the
# first request. NVFP4 otherwise purges the dense weights that
# FastVideo's LoRA merge path requires.
quantization=QuantizationConfig(
transformer_quant="NVFP4",
transformer_retain_original_weights=True,
),
),
pipeline=PipelineSelection(
components=components,
@@ -472,8 +461,11 @@ class VideoGenerationWorker:
segment_idx: int,
image_path: str | None,
reset_conditioning: bool,
generation_inputs: GenerationInputs | None = None,
) -> StepResult:
"""Execute one generation step; snapshot state for the next segment."""
if generation_inputs is not None and (generation_inputs.mode not in (None, "t2va") or generation_inputs.assets):
raise ValueError("LTX supports text generation only through the generation mode API.")
timings: dict = {}
prompt = self._inject_style_trigger(prompt)
+9
View File
@@ -15,6 +15,7 @@ from dreamverse.gpu_pool import GPUPool, get_available_gpus
from dreamverse.session_logger import SessionEventLogger
from dreamverse.config import (
ACTIVE_MODEL_ID,
AVAILABLE_LORAS,
DEVTOOLS_ENABLED,
FRONTEND_STATIC_DIR_CANDIDATES,
@@ -34,6 +35,8 @@ from dreamverse.routes.presets import (
curated_presets_router,
)
from dreamverse.session.controller import SessionController
from dreamverse.generation_inputs import supported_generation_modes
from dreamverse.routes.assets import router as asset_router
class _HeartbeatAccessLogFilter(logging.Filter):
@@ -92,10 +95,16 @@ app.add_middleware(
app.include_router(build_health_router(lambda: runtime.gpu_pool))
app.include_router(internal_monitor_router)
app.include_router(prompt_config_router)
app.include_router(asset_router)
if DEVTOOLS_ENABLED:
app.include_router(curated_presets_router)
@app.get("/generation-capabilities")
async def generation_capabilities() -> dict:
return {"model_id": ACTIVE_MODEL_ID, "modes": supported_generation_modes(ACTIVE_MODEL_ID), "mock": False}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
controller = SessionController(
@@ -0,0 +1,363 @@
"""Full/Preview H3 lifecycle, conditioning and per-project pipeline selection."""
from __future__ import annotations
import gc
import os
import time
from typing import TYPE_CHECKING, Any
import numpy as np
import torch
from dreamverse.config import DREAMVERSE_SP_SIZE
from dreamverse.generation_contracts import StepResult
from dreamverse.generation_inputs import GenerationInputs
if TYPE_CHECKING:
from PIL.Image import Image
def _required_config_str(model_config: dict, field_name: str) -> str:
"""Read one required non-empty string from a DreamVerse model profile."""
value = model_config.get(field_name)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"FastH3 model configuration requires `{field_name}`.")
return value.strip()
class MiniMaxH3GenerationBackend:
"""Own one H3 pipeline at a time and retain base-pipeline continuation."""
def __init__(self, gpu_id: int):
self.gpu_id = gpu_id
self.generator: Any | None = None
self.model_config: dict = {}
self.continuation_image: Image | None = None
self.pipeline_mode = "base"
def _gpu_mem(self) -> str:
allocated_gib = torch.cuda.memory_allocated() / 1024**3
reserved_gib = torch.cuda.memory_reserved() / 1024**3
return f"alloc={allocated_gib:.2f}GiB, reserved={reserved_gib:.2f}GiB"
@staticmethod
def _configure_environment(attention_backend: str) -> None:
"""Apply the fixed boot-time switches from the FastH3 reference recipe."""
os.environ.update({
"FASTVIDEO_ATTENTION_BACKEND": attention_backend,
"FASTVIDEO_FA4": "1",
"FASTVIDEO_MINIMAX_H3_FUSIONS": "all",
"FASTVIDEO_VSA_SM100A": "0",
})
os.environ.pop("FASTVIDEO_INFERENCE_TORCH_COMPILE", None)
def initialize(self, model_config: dict | None = None) -> None:
"""Load the profile's base pipeline; Ref2VA is loaded on first use."""
if model_config is not None:
self.model_config = dict(model_config)
if not self.model_config:
raise ValueError("FastH3 initialization requires a model configuration.")
self._load_pipeline("base")
def _load_pipeline(self, pipeline_mode: str) -> None:
"""Unload the old executor before loading a base or reference transformer.
GPU worker commands are serialized, so a project boundary never swaps
weights while another request is using them. Keeping one executor also
avoids simultaneously retaining two large H3 transformers in VRAM. A
failed load leaves no executor behind so the next step retries it.
"""
full_checkpoint = bool(self.model_config.get("full_checkpoint", False))
if pipeline_mode == "ref2va" and not full_checkpoint:
raise ValueError("Ref2VA requires the full-h3 model profile.")
if self.generator is not None:
previous_generator = self.generator
self.generator = None
previous_generator.shutdown()
del previous_generator
gc.collect()
torch.cuda.empty_cache()
self.clear_conditioning()
model_path = _required_config_str(self.model_config, "model_path")
attention_backend = _required_config_str(self.model_config, "attention_backend")
self._configure_environment(attention_backend)
from fastvideo import VideoGenerator
from fastvideo.api import (
CompileConfig,
ComponentConfig,
EngineConfig,
GeneratorConfig,
OffloadConfig,
ParallelismConfig,
PipelineSelection,
)
components = ComponentConfig()
if not full_checkpoint:
from huggingface_hub import hf_hub_download
adapter_repo = _required_config_str(self.model_config, "adapter_repo")
adapter_filename = _required_config_str(self.model_config, "adapter_filename")
components.lora_path = hf_hub_download(repo_id=adapter_repo, filename=adapter_filename)
components.lora_strength = 1.0
print(f"[GPU {self.gpu_id}] FastH3 adapter: {adapter_repo}/{adapter_filename}")
if pipeline_mode == "ref2va":
components.override_pipeline_cls_name = "MiniMaxH3Ref2VAModularPipeline"
experimental = {
"attention_backend": attention_backend,
"inference_torch_compile": not full_checkpoint and attention_backend == "FLASH_ATTN",
"vae_parallel_decode": True,
"vae_parallel_decode_strategy": "gather",
}
if attention_backend == "VIDEO_SPARSE_ATTN_H3":
experimental.update({
"VSA_sparsity": 0.9,
"VSA_tile_size": 64,
})
generator_config = GeneratorConfig(
model_path=model_path,
pipeline=PipelineSelection(
workload_type="i2v" if pipeline_mode == "ref2va" else None,
components=components,
experimental=experimental,
),
engine=EngineConfig(
num_gpus=DREAMVERSE_SP_SIZE,
parallelism=ParallelismConfig(tp_size=1, sp_size=DREAMVERSE_SP_SIZE),
offload=OffloadConfig(
dit=False,
dit_layerwise=False,
text_encoder=True,
image_encoder=True,
vae=True,
pin_cpu_memory=not full_checkpoint,
),
compile=CompileConfig(enabled=False, vae_enabled=True),
use_fsdp_inference=full_checkpoint and DREAMVERSE_SP_SIZE > 1,
),
)
print(f"[GPU {self.gpu_id}] Loading H3 model: {model_path} ({pipeline_mode})")
print(f"[GPU {self.gpu_id}] Before model load: {self._gpu_mem()}")
try:
self.generator = VideoGenerator.from_config(generator_config)
except Exception:
# The old executor is already gone; leaving no executor behind lets
# the next step retry this load instead of stranding the GPU slot.
self.generator = None
raise
self.pipeline_mode = pipeline_mode
print(f"[GPU {self.gpu_id}] FastH3 loaded: {self._gpu_mem()} (warmup pending)")
def shutdown(self) -> None:
"""Release the FastVideo generator and cached continuation image."""
self.clear_conditioning()
if self.generator is not None:
self.generator.shutdown()
self.generator = None
def clear_conditioning(self) -> None:
"""Release the first-frame image retained for the next segment."""
if self.continuation_image is not None:
self.continuation_image.close()
self.continuation_image = None
@staticmethod
def _load_rgb_image(image_path: str) -> Image:
"""Load an image into an independent RGB buffer with no open file handle."""
from PIL import Image
with Image.open(image_path) as image:
return image.convert("RGB").copy()
def _select_conditioning_image(
self,
segment_idx: int,
image_path: str | None,
reset_conditioning: bool,
) -> tuple[Image | None, bool]:
"""Select the initial upload or retained last frame for one segment."""
if reset_conditioning:
self.clear_conditioning()
if segment_idx > 1 and self.continuation_image is not None:
return self.continuation_image.copy(), True
if segment_idx > 1 and not reset_conditioning:
raise RuntimeError(f"FastH3 segment {segment_idx} requires a retained continuation frame.")
if segment_idx == 1 and image_path:
return self._load_rgb_image(image_path), False
return None, False
def _build_request(
self,
prompt: str,
conditioning_image: Image | None,
last_image: Image | None = None,
generation_inputs: GenerationInputs | None = None,
):
"""Build the typed FastVideo request owned by the FastH3 profile."""
from fastvideo.api import GenerationRequest, InputConfig, OutputConfig, SamplingConfig
references = None
if generation_inputs is not None and generation_inputs.mode == "ref2va":
from fastvideo.api import MiniMaxH3Reference
references = [
MiniMaxH3Reference(source=str(asset.path), media_type=asset.kind)
for asset in generation_inputs.references
]
return GenerationRequest(
prompt=prompt,
negative_prompt="",
inputs=InputConfig(pil_image=conditioning_image, last_image=last_image, references=references),
sampling=SamplingConfig(
height=int(self.model_config["height"]),
width=int(self.model_config["width"]),
num_frames=int(self.model_config["num_frames"]),
fps=24,
num_inference_steps=int(self.model_config["num_inference_steps"]),
guidance_scale=1.0,
batch_cfg=False,
seed=int(self.model_config["seed"]),
),
output=OutputConfig(save_video=False, return_frames=True),
)
def _save_continuation_frame(self, frames: list) -> None:
"""Retain the last decoded frame as first-frame conditioning."""
from PIL import Image
self.clear_conditioning()
self.continuation_image = Image.fromarray(np.ascontiguousarray(frames[-1])).convert("RGB")
def generate_step(
self,
prompt: str,
segment_idx: int,
image_path: str | None,
reset_conditioning: bool,
generation_inputs: GenerationInputs | None = None,
) -> StepResult:
"""Generate one synchronized FastH3 segment and retain its last frame.
Later segments use MiniMax H3's first-frame-to-video path. The first
conditioned frame and its matching audio duration are trimmed before
streaming so adjacent segments do not duplicate media.
"""
mode = generation_inputs.mode if generation_inputs is not None else None
if mode not in (None, "t2va", "fl2va", "ref2va"):
raise ValueError(f"Unsupported H3 generation mode: {mode!r}.")
if mode in ("fl2va", "ref2va") and not self.model_config.get("full_checkpoint", False):
raise ValueError(f"{mode.upper()} requires the full-h3 model profile.")
pipeline_mode = "ref2va" if mode == "ref2va" else "base"
if self.generator is None or self.pipeline_mode != pipeline_mode:
# A failed switch leaves no executor behind; reload here so the
# slot recovers on the next step instead of staying broken.
if segment_idx > 1 and not reset_conditioning:
raise ValueError("Generation mode cannot change in the middle of a project.")
self._load_pipeline(pipeline_mode)
conditioning_image = None
last_image = None
uses_continuation = False
if mode == "ref2va":
# The reference pipeline rejects first/last-frame inputs. Preserve
# all original references for every clip and do not trim overlap.
self.clear_conditioning()
else:
if mode == "fl2va" and generation_inputs is not None:
image_path = generation_inputs.first_frame_path
conditioning_image, uses_continuation = self._select_conditioning_image(
segment_idx,
image_path,
reset_conditioning,
)
started = time.perf_counter()
try:
if (mode == "fl2va" and segment_idx == 1 and generation_inputs is not None
and generation_inputs.last_frame_path):
last_image = self._load_rgb_image(generation_inputs.last_frame_path)
request = self._build_request(prompt, conditioning_image, last_image, generation_inputs)
result = self.generator.generate(request)
finally:
if conditioning_image is not None:
conditioning_image.close()
if last_image is not None:
last_image.close()
torch.cuda.synchronize()
generation_ms = (time.perf_counter() - started) * 1000.0
if isinstance(result, list):
raise RuntimeError("FastH3 returned multiple results for one DreamVerse segment.")
frames = result.frames
if not isinstance(frames, list) or not frames:
raise RuntimeError("FastH3 generation did not return decoded frames.")
audio = result.audio
audio_sample_rate = result.audio_sample_rate
if audio is not None and audio_sample_rate is None:
raise RuntimeError("FastH3 returned audio without an audio sample rate.")
save_started = time.perf_counter()
if mode != "ref2va":
self._save_continuation_frame(frames)
save_conditioning_ms = (time.perf_counter() - save_started) * 1000.0
timings = {
"generation_ms": generation_ms,
"generation_time_ms": float(result.generation_time or 0.0) * 1000.0,
"save_conditioning_ms": save_conditioning_ms,
"e2e_latency_ms": (time.perf_counter() - started) * 1000.0,
}
trim_frames = 1 if uses_continuation else 0
print(f"[GPU {self.gpu_id}] FastH3 segment {segment_idx}: "
f"{len(frames)} frames, gen={generation_ms:.0f}ms, "
f"save_conditioning={save_conditioning_ms:.0f}ms, "
f"e2e={timings['e2e_latency_ms']:.0f}ms")
return StepResult(
frames=frames,
audio=audio,
audio_sample_rate=audio_sample_rate,
timings=timings,
head_trim_frames=trim_frames,
head_trim_audio_frames=trim_frames,
)
def warmup(self, prompt: str) -> dict[str, float]:
"""Compile the FastH3 text and first-frame paths before readiness."""
warmup_prompt = (prompt or "").strip()
if not warmup_prompt:
raise RuntimeError("Startup warmup prompt must be non-empty.")
print(f"[GPU {self.gpu_id}] FastH3 startup warmup starting "
"(synthetic segments: text-to-video, first-frame-to-video)")
started = time.perf_counter()
text_result = self.generate_step(
warmup_prompt,
segment_idx=1,
image_path=None,
reset_conditioning=True,
)
first_frame_result = self.generate_step(
warmup_prompt,
segment_idx=2,
image_path=None,
reset_conditioning=False,
)
total_ms = (time.perf_counter() - started) * 1000.0
self.clear_conditioning()
text_ms = float(text_result.timings.get("e2e_latency_ms", 0.0))
first_frame_ms = float(first_frame_result.timings.get("e2e_latency_ms", 0.0))
print(f"[GPU {self.gpu_id}] FastH3 startup warmup complete: "
f"text_to_video={text_ms:.0f}ms, "
f"first_frame_to_video={first_frame_ms:.0f}ms, "
f"total={total_ms:.0f}ms")
return {
"warmup_text_to_video_ms": text_ms,
"warmup_first_frame_to_video_ms": first_frame_ms,
"warmup_total_ms": total_ms,
}
def apply_lora_stack(self, stack: list[tuple[str, float]]) -> tuple[str | None, str | None]:
"""Reject runtime LoRA mutation because FastH3 uses one startup adapter."""
del stack
raise RuntimeError("FastH3 uses its fixed startup adapter and does not support runtime LoRA changes.")
+41 -1
View File
@@ -32,6 +32,14 @@ from fastapi.staticfiles import StaticFiles
from dreamverse._deps import require_dreamverse_runtime_deps
from dreamverse.config import FRONTEND_STATIC_DIR_CANDIDATES, GENERATION_SEGMENT_CAP
from dreamverse.session_init_image import cleanup_session_init_image, persist_session_init_image
from dreamverse.generation_inputs import (
GenerationInputs,
pin_generation_inputs,
release_generation_inputs,
resolve_generation_inputs,
supported_generation_modes,
)
from dreamverse.routes.assets import router as asset_router
LATENCY_MS = 200
SESSION_TIMEOUT_SECONDS = 300
@@ -170,6 +178,12 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(asset_router)
@app.get("/generation-capabilities")
async def generation_capabilities():
return {"model_id": "mock", "modes": supported_generation_modes("mock"), "mock": True}
@app.get("/healthz")
@@ -290,6 +304,7 @@ async def websocket_endpoint(websocket: WebSocket):
send_lock = asyncio.Lock()
stop_event = asyncio.Event()
session_init_image = None
generation_inputs = GenerationInputs()
async def ws_send_json(payload: dict) -> None:
async with send_lock:
@@ -347,10 +362,13 @@ async def websocket_endpoint(websocket: WebSocket):
generation_paused = bool(initial_rollout_prompt and not single_clip_mode and len(curated_prompts) == 0)
try:
generation_inputs = resolve_generation_inputs(init_data, "mock")
pin_generation_inputs(generation_inputs)
session_init_image = persist_session_init_image(init_data.get("initial_image"))
except ValueError as exc:
await ws_send_json({
"type": "error",
"error_code": "invalid_generation_input",
"message": str(exc),
})
await websocket.close(code=1003, reason="Invalid initial image")
@@ -362,6 +380,8 @@ async def websocket_endpoint(websocket: WebSocket):
"type": "gpu_assigned",
"gpu_id": 0,
"session_timeout": SESSION_TIMEOUT_SECONDS,
"generation_mode": generation_inputs.mode,
"mock": True,
})
raw_prompt_queue: asyncio.Queue[PromptSubmission] = asyncio.Queue()
@@ -486,6 +506,7 @@ async def websocket_endpoint(websocket: WebSocket):
})
async def apply_project_init_payload(payload: dict[str, object], ) -> bool:
nonlocal generation_inputs
nonlocal preset_id
nonlocal preset_label
nonlocal initial_rollout_prompt
@@ -519,10 +540,19 @@ async def websocket_endpoint(websocket: WebSocket):
]
try:
replace_session_image(payload.get("initial_image"))
next_inputs = resolve_generation_inputs(payload, "mock")
pin_generation_inputs(next_inputs)
try:
replace_session_image(payload.get("initial_image"))
except ValueError:
release_generation_inputs(next_inputs)
raise
release_generation_inputs(generation_inputs)
generation_inputs = next_inputs
except ValueError as exc:
await ws_send_json({
"type": "error",
"error_code": "invalid_generation_input",
"message": str(exc),
})
return False
@@ -568,6 +598,7 @@ async def websocket_endpoint(websocket: WebSocket):
return drained
async def enter_project_idle() -> None:
nonlocal generation_inputs
nonlocal seed_prompt_memory
nonlocal curated_prompts
nonlocal curated_idx
@@ -587,6 +618,8 @@ async def websocket_endpoint(websocket: WebSocket):
dropped_raw = drain_queue_nowait(raw_prompt_queue)
dropped_ready = drain_queue_nowait(ready_prompt_queue)
release_generation_inputs(generation_inputs)
generation_inputs = GenerationInputs()
seed_prompt_memory = []
curated_prompts = []
curated_idx = 0
@@ -767,10 +800,16 @@ async def websocket_endpoint(websocket: WebSocket):
continue
try:
if generation_inputs.mode is not None and data.get("initial_image") is not None:
raise ValueError("Choose conditioning assets when starting a project; legacy initial_image "
"cannot replace generation mode inputs.")
if "generation_mode" in data or "conditioning_assets" in data:
raise ValueError("simple_generate cannot change the mode; use project_init_v1.")
replace_session_image(data.get("initial_image"))
except ValueError as exc:
await ws_send_json({
"type": "error",
"error_code": "invalid_generation_input",
"message": str(exc),
})
continue
@@ -1182,6 +1221,7 @@ async def websocket_endpoint(websocket: WebSocket):
finally:
stop_event.set()
cleanup_session_init_image(session_init_image)
release_generation_inputs(generation_inputs)
for static_dir in FRONTEND_STATIC_DIR_CANDIDATES:
@@ -0,0 +1,72 @@
"""Raw, bounded media uploads keep large binary data out of websocket messages."""
from __future__ import annotations
import asyncio
from urllib.parse import unquote
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import FileResponse
from starlette.concurrency import run_in_threadpool
from dreamverse.assets import IMAGE_LIMIT, MEDIA_LIMIT, MIME_TYPES, asset_store
router = APIRouter()
_upload_lock = asyncio.Lock()
@router.post("/assets", status_code=201)
async def upload_asset(request: Request) -> dict:
mime_type = request.headers.get("content-type", "").split(";", 1)[0].lower()
if mime_type not in MIME_TYPES:
raise HTTPException(415, "Unsupported asset type. Select a supported image, video, or audio file.")
limit = IMAGE_LIMIT if MIME_TYPES[mime_type][0] == "image" else MEDIA_LIMIT
try:
if int(request.headers.get("content-length", "0")) > limit:
raise HTTPException(413, f"Asset exceeds the {limit // (1024 * 1024)} MB upload limit.")
except ValueError as exc:
raise HTTPException(400, "Invalid Content-Length.") from exc
async with _upload_lock:
try:
path = asset_store.staging_path(mime_type)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
try:
size = 0
with path.open("xb") as handle:
async for chunk in request.stream():
size += len(chunk)
if size > limit:
raise HTTPException(413, f"Asset exceeds the {limit // (1024 * 1024)} MB upload limit.")
await run_in_threadpool(handle.write, chunk)
asset = await run_in_threadpool(asset_store.add, path,
unquote(request.headers.get("x-asset-name", "Untitled asset")), mime_type)
return asset.public()
except ValueError as exc:
path.unlink(missing_ok=True)
raise HTTPException(400, str(exc)) from exc
except BaseException:
path.unlink(missing_ok=True)
raise
@router.api_route("/assets/{asset_id}", methods=["GET", "HEAD"])
async def get_asset(asset_id: str) -> FileResponse:
try:
asset = asset_store.get(asset_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return FileResponse(asset.path, media_type=asset.mime_type, headers={"X-Content-Type-Options": "nosniff"})
@router.delete("/assets/{asset_id}", status_code=204)
async def delete_asset(asset_id: str) -> Response:
try:
asset_store.get(asset_id)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
try:
asset_store.delete(asset_id)
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
return Response(status_code=204)
@@ -26,11 +26,17 @@ from typing import TYPE_CHECKING
from fastapi import WebSocket, WebSocketDisconnect
from dreamverse.gpu_pool import GPUSlot
from dreamverse.generation_inputs import (
GenerationInputs,
pin_generation_inputs,
release_generation_inputs,
resolve_generation_inputs,
)
from dreamverse.session_init_image import cleanup_session_init_image, persist_session_init_image
from dreamverse.worker_ipc import MediaChunk, MediaComplete, MediaInit
from dreamverse.config import (
DEFAULT_MODEL_ID,
ACTIVE_MODEL_ID,
GENERATION_SEGMENT_CAP,
PROMPT_AUTO_SLEEP_MS,
PROMPT_AUTO_TIMEOUT_MS,
@@ -156,6 +162,7 @@ class SessionController:
prompt_worker_task: asyncio.Task | None = None
rewrite_seed_prompts_task: asyncio.Task | None = None
session_init_image = None
generation_inputs: GenerationInputs | None = None
async def session_timeout():
"""Close the session after timeout."""
@@ -191,6 +198,18 @@ class SessionController:
init_data = {}
init_type = init_data.get("type")
try:
next_generation_inputs = resolve_generation_inputs(init_data, ACTIVE_MODEL_ID)
pin_generation_inputs(next_generation_inputs)
generation_inputs = next_generation_inputs
except ValueError as exc:
await ws_send_json({
"type": "error",
"error_code": "invalid_generation_input",
"message": str(exc),
})
await websocket.close(code=1008, reason="Invalid generation inputs")
return
preset_id = init_data.get("preset_id")
preset_label = str(init_data.get("preset_label") or "").strip()
initial_rollout_prompt = str(init_data.get("initial_rollout_prompt") or "").strip()
@@ -264,13 +283,14 @@ class SessionController:
timeout_task = asyncio.create_task(session_timeout())
# Join the engine on this GPU.
await slot.join_user(client_id, model_id=DEFAULT_MODEL_ID)
await slot.join_user(client_id, model_id=ACTIVE_MODEL_ID)
# Notify client they're connected to a GPU.
await ws_send_json({
"type": "gpu_assigned",
"gpu_id": gpu_id,
"session_timeout": SESSION_TIMEOUT_SECONDS,
"generation_mode": generation_inputs.mode,
})
await log_event(
"gpu_assigned",
@@ -361,10 +381,16 @@ class SessionController:
return
try:
if generation_inputs.mode is not None and payload.get("initial_image") is not None:
raise ValueError("Choose conditioning assets when starting a project; legacy initial_image "
"cannot replace generation mode inputs.")
if "generation_mode" in payload or "conditioning_assets" in payload:
raise ValueError("simple_generate cannot change the mode; use project_init_v1.")
replace_session_init_image(payload.get("initial_image"))
except ValueError as exc:
await ws_send_json({
"type": "error",
"error_code": "invalid_generation_input",
"message": str(exc),
})
return
@@ -417,6 +443,7 @@ class SessionController:
})
async def apply_project_init_payload(payload: dict[str, object]) -> bool:
nonlocal generation_inputs
nonlocal preset_id
nonlocal preset_label
nonlocal initial_rollout_prompt
@@ -496,15 +523,26 @@ class SessionController:
})
return False
next_generation_inputs = None
next_inputs_pinned = False
try:
next_generation_inputs = resolve_generation_inputs(payload, ACTIVE_MODEL_ID)
pin_generation_inputs(next_generation_inputs)
next_inputs_pinned = True
replace_session_init_image(payload.get("initial_image"))
except ValueError as exc:
if next_inputs_pinned:
release_generation_inputs(next_generation_inputs)
await ws_send_json({
"type": "error",
"error_code": "invalid_generation_input",
"message": str(exc),
})
return False
release_generation_inputs(generation_inputs)
generation_inputs = next_generation_inputs
initial_rollout_prompt = next_initial_rollout_prompt
enhancement_enabled = next_enhancement_enabled
auto_extension_enabled = next_auto_extension_enabled
@@ -1171,6 +1209,7 @@ class SessionController:
return drained
async def enter_project_idle() -> None:
nonlocal generation_inputs
nonlocal curated_prompts
nonlocal seed_prompt_memory
nonlocal curated_idx
@@ -1221,6 +1260,9 @@ class SessionController:
project_active = False
pending_project_end = False
release_generation_inputs(generation_inputs)
generation_inputs = GenerationInputs()
if project_stream_started:
project_stream_started = False
await ws_send_json({"type": "ltx2_stream_complete"})
@@ -1627,6 +1669,7 @@ class SessionController:
segment_idx=segment_idx,
image_path=step_image_path,
reset_conditioning=step_reset_conditioning,
generation_inputs=generation_inputs,
))
segment_generation_active = True
try:
@@ -1681,10 +1724,10 @@ class SessionController:
print(f"[GPU {gpu_id}] Unknown AV event: "
f"{type(event).__name__}")
if not step_task.done():
step_task.cancel()
else:
timings = await step_task
# A GPU command cannot be cancelled by cancelling its
# asyncio waiter. Await completion before releasing pinned
# asset files or making this GPU available to a new user.
timings = await step_task
finally:
segment_generation_active = False
if not step_task.done():
@@ -1808,3 +1851,5 @@ class SessionController:
await self.gpu_pool.release(client_id)
finally:
cleanup_session_init_image(session_init_image)
if generation_inputs is not None:
release_generation_inputs(generation_inputs)
+128 -2
View File
@@ -2,13 +2,14 @@ from __future__ import annotations
import importlib.util
from pathlib import Path
from types import ModuleType
import pytest
SERVER_DIR = Path(__file__).resolve().parents[1]
def _load_config_module():
def _load_config_module() -> ModuleType:
spec = importlib.util.spec_from_file_location(
"server_config_test_module",
SERVER_DIR / "config.py",
@@ -20,7 +21,7 @@ def _load_config_module():
return module
def _set_required_prompt_keys(monkeypatch):
def _set_required_prompt_keys(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CEREBRAS_API_KEY", "cerebras-key")
monkeypatch.setenv("GROQ_API_KEY", "groq-key")
@@ -138,15 +139,140 @@ def test_config_enables_prompt_safety_when_requested(monkeypatch):
def test_config_uses_five_minute_session_timeout(monkeypatch):
_set_required_prompt_keys(monkeypatch)
monkeypatch.delenv("DREAMVERSE_MODEL_ID", raising=False)
monkeypatch.delenv("DREAMVERSE_SESSION_TIMEOUT_SECONDS", raising=False)
monkeypatch.delenv("FASTVIDEO_SESSION_TIMEOUT_SECONDS", raising=False)
module = _load_config_module()
assert module.SESSION_TIMEOUT_SECONDS == 300
def test_config_uses_thirty_minute_cosmos25_session_timeout(monkeypatch):
_set_required_prompt_keys(monkeypatch)
monkeypatch.setenv("DREAMVERSE_MODEL_ID", "cosmos25-dfd")
monkeypatch.delenv("DREAMVERSE_SESSION_TIMEOUT_SECONDS", raising=False)
monkeypatch.delenv("FASTVIDEO_SESSION_TIMEOUT_SECONDS", raising=False)
module = _load_config_module()
assert module.SESSION_TIMEOUT_SECONDS == 1800
def test_config_allows_session_timeout_override(monkeypatch):
_set_required_prompt_keys(monkeypatch)
monkeypatch.setenv("DREAMVERSE_MODEL_ID", "cosmos25-dfd")
monkeypatch.delenv("DREAMVERSE_SESSION_TIMEOUT_SECONDS", raising=False)
monkeypatch.setenv("FASTVIDEO_SESSION_TIMEOUT_SECONDS", "900")
module = _load_config_module()
assert module.SESSION_TIMEOUT_SECONDS == 900
def test_config_prefers_dreamverse_session_timeout_over_alias(monkeypatch):
_set_required_prompt_keys(monkeypatch)
monkeypatch.setenv("DREAMVERSE_MODEL_ID", "cosmos25-dfd")
monkeypatch.setenv("DREAMVERSE_SESSION_TIMEOUT_SECONDS", "1200")
monkeypatch.setenv("FASTVIDEO_SESSION_TIMEOUT_SECONDS", "900")
module = _load_config_module()
assert module.SESSION_TIMEOUT_SECONDS == 1200
def test_config_rejects_invalid_prompt_provider(monkeypatch):
monkeypatch.setenv("FASTVIDEO_PROMPT_PROVIDER", "unsupported")
_set_required_prompt_keys(monkeypatch)
with pytest.raises(RuntimeError, match="Invalid FASTVIDEO_PROMPT_PROVIDER"):
_load_config_module()
def test_config_registers_vsa_datafree_fasth3_profile(monkeypatch):
"""The FastH3 registry entry owns the complete fixed Preview recipe."""
_set_required_prompt_keys(monkeypatch)
module = _load_config_module()
assert module.MODEL_REGISTRY["fast-h3"] == {
"name": "FastH3",
"generation_backend": "minimax_h3",
"default_sp_size": 4,
"model_path": "MiniMaxAI/MiniMax-H3",
"adapter_repo": "FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA",
"adapter_filename": "vsa-datafree/adapter_model.safetensors",
"attention_backend": "VIDEO_SPARSE_ATTN_H3",
"height": 768,
"width": 1344,
"num_frames": 124,
"num_inference_steps": 5,
"seed": 1000,
}
def test_config_uses_fasth3_sequence_parallel_default(monkeypatch):
"""Selecting FastH3 defaults DreamVerse to its four-GPU topology."""
_set_required_prompt_keys(monkeypatch)
monkeypatch.setenv("DREAMVERSE_MODEL_ID", "fast-h3")
monkeypatch.delenv("DREAMVERSE_SP_SIZE", raising=False)
module = _load_config_module()
assert module.ACTIVE_MODEL_ID == "fast-h3"
assert module.MODEL_CONFIG["generation_backend"] == "minimax_h3"
assert module.DREAMVERSE_SP_SIZE == 4
def test_full_h3_profile_has_no_preview_adapter_and_longer_session(monkeypatch):
monkeypatch.setenv("DREAMVERSE_MODEL_ID", "full-h3")
monkeypatch.delenv("DREAMVERSE_SP_SIZE", raising=False)
monkeypatch.delenv("DREAMVERSE_SESSION_TIMEOUT_SECONDS", raising=False)
monkeypatch.delenv("FASTVIDEO_SESSION_TIMEOUT_SECONDS", raising=False)
module = _load_config_module()
assert module.MODEL_CONFIG["full_checkpoint"] is True
assert "adapter_repo" not in module.MODEL_CONFIG
assert module.MODEL_CONFIG["num_inference_steps"] == 50
assert module.DREAMVERSE_SP_SIZE == 4
assert module.SESSION_TIMEOUT_SECONDS == 7200
def test_config_registers_cosmos25_dfd_profile(monkeypatch):
_set_required_prompt_keys(monkeypatch)
module = _load_config_module()
assert module.MODEL_REGISTRY["cosmos25-dfd"] == {
"name": "Cosmos Predict2.5 DFD",
"generation_backend": "cosmos25_dfd",
"default_sp_size": 1,
"model_path": "FastVideo/Cosmos-Predict2.5-2B-Distilled-TrigFlow",
"continuation_model_path": "FastVideo/Cosmos-Predict2.5-2B-DFD",
"attention_backend": "TORCH_SDPA",
"height": 704,
"width": 1280,
"bootstrap_num_frames": 77,
"continuation_num_frames": 81,
"fps": 24,
"num_inference_steps": 4,
"seed": 42,
"session_timeout_seconds": 1800,
}
def test_config_selects_cosmos25_package_roles(monkeypatch, tmp_path):
_set_required_prompt_keys(monkeypatch)
bootstrap_path = tmp_path / "cosmos25-t2w"
continuation_path = tmp_path / "cosmos25-dfd"
monkeypatch.setenv("DREAMVERSE_MODEL_ID", "cosmos25-dfd")
monkeypatch.setenv("DREAMVERSE_MODEL_PATH", str(bootstrap_path))
monkeypatch.setenv("DREAMVERSE_COSMOS25_DFD_MODEL_PATH", str(continuation_path))
monkeypatch.delenv("DREAMVERSE_SP_SIZE", raising=False)
module = _load_config_module()
assert module.ACTIVE_MODEL_ID == "cosmos25-dfd"
assert module.MODEL_CONFIG["generation_backend"] == "cosmos25_dfd"
assert module.MODEL_CONFIG["model_path"] == str(bootstrap_path)
assert module.MODEL_CONFIG["continuation_model_path"] == str(continuation_path)
assert module.DREAMVERSE_SP_SIZE == 1
@@ -0,0 +1,219 @@
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from dreamverse.cosmos25_dfd_generation import Cosmos25DFDGenerationBackend
from dreamverse.generation_inputs import GenerationInputs
COSMOS_CONFIG = {
"name": "Cosmos Predict2.5 DFD",
"generation_backend": "cosmos25_dfd",
"default_sp_size": 1,
"model_path": "/models/cosmos25-t2w",
"continuation_model_path": "/models/cosmos25-dfd",
"attention_backend": "TORCH_SDPA",
"height": 704,
"width": 1280,
"bootstrap_num_frames": 77,
"continuation_num_frames": 81,
"fps": 24,
"num_inference_steps": 4,
"seed": 42,
}
class _RecordingGenerator:
def __init__(self, pixel_value: int = 20) -> None:
self.pixel_value = pixel_value
self.calls: list[dict] = []
self.shutdown_calls = 0
def generate_video(self, prompt, sampling_param):
condition = sampling_param.pil_image
self.calls.append({
"prompt": prompt,
"sampling": sampling_param,
"conditioning_pixels": None if condition is None else np.asarray(condition).copy(),
})
frames = [
np.full((2, 3, 3), self.pixel_value, dtype=np.uint8)
for _ in range(sampling_param.num_frames)
]
frames[-1] = np.full((2, 3, 3), self.pixel_value + 1, dtype=np.uint8)
return {
"frames": frames,
"generation_time": 0.25,
}
def shutdown(self):
self.shutdown_calls += 1
@pytest.fixture
def backend(monkeypatch) -> Cosmos25DFDGenerationBackend:
instance = Cosmos25DFDGenerationBackend(gpu_id=0)
instance.model_config = dict(COSMOS_CONFIG)
instance.bootstrap_generator = _RecordingGenerator(pixel_value=20)
instance.continuation_generator = _RecordingGenerator(pixel_value=40)
monkeypatch.setattr("dreamverse.cosmos25_dfd_generation.torch.cuda.synchronize", lambda: None)
def fake_sampling_param(*, conditioned):
return SimpleNamespace(
negative_prompt="",
save_video=False,
return_frames=True,
height=704,
width=1280,
num_frames=81 if conditioned else 77,
fps=24,
num_inference_steps=4,
guidance_scale=1.0,
seed=42,
num_cond_frames=1 if conditioned else 0,
pil_image=None,
)
monkeypatch.setattr(instance, "_sampling_param", fake_sampling_param)
return instance
def test_initialize_loads_both_package_roles(monkeypatch):
loaded_paths = []
generators = [_RecordingGenerator(), _RecordingGenerator()]
backend = Cosmos25DFDGenerationBackend(gpu_id=0)
def fake_load(model_path):
loaded_paths.append(model_path)
return generators[len(loaded_paths) - 1]
monkeypatch.setattr(backend, "_load_generator", fake_load)
monkeypatch.setattr(backend, "_gpu_mem", lambda: "alloc=0.00GiB, reserved=0.00GiB")
monkeypatch.setattr("dreamverse.cosmos25_dfd_generation.gc.collect", lambda: 0)
monkeypatch.setattr("dreamverse.cosmos25_dfd_generation.torch.cuda.is_available", lambda: False)
monkeypatch.setenv("FASTVIDEO_ATTENTION_BACKEND", "test-attention")
monkeypatch.setenv("FASTVIDEO_INFERENCE_TORCH_COMPILE", "1")
backend.initialize(COSMOS_CONFIG)
assert loaded_paths == [
"/models/cosmos25-t2w",
"/models/cosmos25-dfd",
]
assert backend.bootstrap_generator is generators[0]
assert backend.continuation_generator is generators[1]
assert backend.model_config == COSMOS_CONFIG
assert os.environ["FASTVIDEO_ATTENTION_BACKEND"] == "TORCH_SDPA"
assert "FASTVIDEO_INFERENCE_TORCH_COMPILE" not in os.environ
def test_unconditioned_start_uses_t2w_and_retains_terminal_frame(backend):
result = backend.generate_step("first prompt", 1, None, True)
assert len(backend.bootstrap_generator.calls) == 1
assert backend.continuation_generator.calls == []
sampling = backend.bootstrap_generator.calls[0]["sampling"]
assert sampling.height == 704
assert sampling.width == 1280
assert sampling.num_frames == 77
assert sampling.fps == 24
assert sampling.num_inference_steps == 4
assert sampling.guidance_scale == 1.0
assert sampling.seed == 42
assert sampling.num_cond_frames == 0
assert sampling.pil_image is None
assert result.head_trim_frames == 0
assert result.head_trim_audio_frames == 0
assert result.audio_sample_rate == 24_000
assert result.audio.shape == (77_000, )
assert result.audio.count_nonzero() == 0
assert np.asarray(backend.continuation_image).tolist() == np.full((2, 3, 3), 21).tolist()
def test_retained_frame_uses_dfd_and_trims_repeated_boundary(backend):
backend.generate_step("first prompt", 1, None, True)
result = backend.generate_step("pivot right", 2, None, False)
assert len(backend.continuation_generator.calls) == 1
call = backend.continuation_generator.calls[0]
sampling = call["sampling"]
assert sampling.num_frames == 81
assert sampling.num_cond_frames == 1
assert call["conditioning_pixels"].tolist() == np.full((2, 3, 3), 21).tolist()
assert result.head_trim_frames == 1
assert result.head_trim_audio_frames == 1
assert result.audio.shape == (81_000, )
assert np.asarray(backend.continuation_image).tolist() == np.full((2, 3, 3), 41).tolist()
def test_initial_image_uses_dfd_without_stream_trim(backend, tmp_path: Path):
from PIL import Image
image_path = tmp_path / "initial.png"
Image.fromarray(np.full((2, 3, 3), 7, dtype=np.uint8)).save(image_path)
result = backend.generate_step("animate", 1, str(image_path), True)
assert backend.bootstrap_generator.calls == []
call = backend.continuation_generator.calls[0]
assert call["conditioning_pixels"].tolist() == np.full((2, 3, 3), 7).tolist()
assert result.head_trim_frames == 0
assert result.head_trim_audio_frames == 0
def test_generation_mode_api_accepts_text_only_and_rejects_conditioning_modes(backend):
result = backend.generate_step("first prompt", 1, None, True, generation_inputs=GenerationInputs(mode="t2va"))
assert len(backend.bootstrap_generator.calls) == 1
assert result.head_trim_frames == 0
with pytest.raises(ValueError, match="text generation only"):
backend.generate_step("pivot right", 2, None, False, generation_inputs=GenerationInputs(mode="fl2va"))
assert backend.continuation_generator.calls == []
def test_missing_later_continuation_fails_before_generation(backend):
with pytest.raises(RuntimeError, match="requires a retained continuation frame"):
backend.generate_step("later prompt", 2, None, False)
assert backend.bootstrap_generator.calls == []
assert backend.continuation_generator.calls == []
def test_reset_later_segment_uses_fresh_t2w_bootstrap(backend):
backend.generate_step("first prompt", 1, None, True)
result = backend.generate_step("new scene", 2, None, True)
assert len(backend.bootstrap_generator.calls) == 2
assert backend.continuation_generator.calls == []
assert result.head_trim_frames == 0
def test_warmup_exercises_bootstrap_and_dfd_paths(backend):
timings = backend.warmup("warmup prompt")
assert len(backend.bootstrap_generator.calls) == 1
assert len(backend.continuation_generator.calls) == 1
assert backend.continuation_image is None
assert "warmup_bootstrap_ms" in timings
assert "warmup_continuation_ms" in timings
assert "warmup_total_ms" in timings
def test_shutdown_releases_both_generators_and_conditioning(backend):
bootstrap = backend.bootstrap_generator
continuation = backend.continuation_generator
backend.generate_step("first prompt", 1, None, True)
backend.shutdown()
assert bootstrap.shutdown_calls == 1
assert continuation.shutdown_calls == 1
assert backend.bootstrap_generator is None
assert backend.continuation_generator is None
assert backend.continuation_image is None
@@ -0,0 +1,248 @@
"""Contract regressions independent of CUDA and model weights."""
import io
import asyncio
import shutil
import subprocess
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from PIL import Image
from dreamverse import assets, generation_inputs
from dreamverse.generation_inputs import resolve_generation_inputs
from dreamverse.routes import assets as asset_routes
from dreamverse.tests.test_mock_server import _FakeWebSocket
@pytest.fixture
def library(monkeypatch):
store = assets.AssetStore()
monkeypatch.setattr(asset_routes, "asset_store", store)
monkeypatch.setattr(generation_inputs, "asset_store", store)
app = FastAPI()
app.include_router(asset_routes.router)
with TestClient(app) as client:
yield store, client
def upload_image(client, color="red"):
image_bytes = io.BytesIO()
Image.new("RGB", (32, 32), color).save(image_bytes, format="PNG")
response = client.post("/assets", content=image_bytes.getvalue(),
headers={"Content-Type": "image/png", "X-Asset-Name": "frame.png"})
assert response.status_code == 201, response.text
return response.json()
def conditioning(asset, role):
return {"asset_id": asset["asset_id"], "role": role}
def test_assets_validate_content_and_support_head_range_and_delete(library):
store, client = library
asset = upload_image(client)
assert set(asset) == {"asset_id", "kind", "name", "mime_type", "size", "url"}
assert client.head(asset["url"]).status_code == 200
response = client.get(asset["url"], headers={"Range": "bytes=0-7"})
assert response.status_code == 206
assert response.content == b"\x89PNG\r\n\x1a\n"
assert client.post("/assets", content=b"not an image", headers={"Content-Type": "image/png"}).status_code == 400
assert client.post("/assets", content=b"<svg/>", headers={"Content-Type": "image/svg+xml"}).status_code == 415
assert client.post("/assets", content=b"", headers={"Content-Type": "image/png",
"Content-Length": str(assets.IMAGE_LIMIT + 1)}).status_code == 413
with pytest.raises(ValueError, match="Invalid asset ID"):
store.get("../../etc/passwd")
assert client.delete(asset["url"]).status_code == 204
assert client.head(asset["url"]).status_code == 404
def test_generation_pin_prevents_deletion_until_session_releases(library):
_, client = library
asset = upload_image(client)
inputs = resolve_generation_inputs({"generation_mode": "fl2va", "conditioning_assets": [
conditioning(asset, "first_frame")
]}, "full-h3")
generation_inputs.pin_generation_inputs(inputs)
generation_inputs.pin_generation_inputs(inputs)
assert client.delete(asset["url"]).status_code == 409
generation_inputs.release_generation_inputs(inputs)
assert client.delete(asset["url"]).status_code == 409
generation_inputs.release_generation_inputs(inputs)
assert client.delete(asset["url"]).status_code == 204
def test_legacy_init_remains_compatible_but_explicit_t2va_is_text_only(library):
_, client = library
assert resolve_generation_inputs({"initial_image": {"old": "payload"}}, "fast-ltx2").mode is None
assert resolve_generation_inputs({"generation_mode": "t2va"}, "fast-ltx2").mode == "t2va"
with pytest.raises(ValueError, match="legacy initial_image"):
resolve_generation_inputs({"generation_mode": "t2va", "initial_image": {}}, "full-h3")
asset = upload_image(client)
with pytest.raises(ValueError, match="text only"):
resolve_generation_inputs({"generation_mode": "t2va", "conditioning_assets": [
conditioning(asset, "reference")
]}, "full-h3")
@pytest.mark.parametrize("mode", ["unknown", None, 3, [], {}])
def test_unknown_mode_fails_before_assets_are_resolved(mode):
with pytest.raises(ValueError, match="Unknown generation mode"):
resolve_generation_inputs({"generation_mode": mode}, "full-h3")
@pytest.mark.parametrize("model_id", ["fast-h3", "fast-ltx2", "fast-ltx23"])
def test_preview_and_ltx_cannot_advertise_full_h3_modes(model_id):
with pytest.raises(ValueError, match="Full H3"):
resolve_generation_inputs({"generation_mode": "ref2va"}, model_id)
def test_fl2va_first_required_last_optional_and_roles_unique(library):
_, client = library
first = upload_image(client)
last = upload_image(client, "blue")
payload = {"generation_mode": "fl2va", "conditioning_assets": [conditioning(first, "first_frame")]}
inputs = resolve_generation_inputs(payload, "full-h3")
assert inputs.first_frame_path.endswith(".png")
assert inputs.last_frame_path is None
payload["conditioning_assets"].append(conditioning(last, "last_frame"))
assert resolve_generation_inputs(payload, "full-h3").last_frame_path is not None
payload["conditioning_assets"].append(conditioning(first, "first_frame"))
with pytest.raises(ValueError, match="exactly one first-frame"):
resolve_generation_inputs(payload, "full-h3")
with pytest.raises(ValueError, match="exactly one first-frame"):
resolve_generation_inputs({"generation_mode": "fl2va", "conditioning_assets": [
conditioning(last, "last_frame")
]}, "full-h3")
def test_ref_order_is_preserved_and_limits_are_enforced(library):
_, client = library
first, second = upload_image(client), upload_image(client, "blue")
refs = [conditioning(second, "reference"), conditioning(first, "reference")]
payload = {"generation_mode": "ref2va", "conditioning_assets": refs}
inputs = resolve_generation_inputs(payload, "full-h3")
assert [asset.asset_id for asset in inputs.references] == [second["asset_id"], first["asset_id"]]
with pytest.raises(ValueError, match="at most 9 image"):
resolve_generation_inputs({**payload, "conditioning_assets": refs * 5}, "full-h3")
with pytest.raises(ValueError, match="without keyframe roles"):
resolve_generation_inputs({**payload, "conditioning_assets": [conditioning(first, "first_frame")]}, "full-h3")
with pytest.raises(ValueError, match="at most 12"):
resolve_generation_inputs({**payload, "conditioning_assets": refs * 7}, "full-h3")
def test_ref_audio_requires_visual_reference(library, monkeypatch):
store, _ = library
monkeypatch.setattr(store, "get", lambda asset_id: assets.StoredAsset(asset_id, "audio", "/audio.wav", "audio",
"audio/wav", 100))
with pytest.raises(ValueError, match="audio alone"):
resolve_generation_inputs({"generation_mode": "ref2va", "conditioning_assets": [
{"asset_id": "a" * 32, "role": "reference"}
]}, "full-h3")
def test_ref_rejects_extreme_image_aspect_before_gpu(library):
_, client = library
content = io.BytesIO()
Image.new("RGB", (500, 50), "blue").save(content, format="PNG")
response = client.post("/assets", content=content.getvalue(), headers={"Content-Type": "image/png"})
assert response.status_code == 201
with pytest.raises(ValueError, match="aspect ratios"):
resolve_generation_inputs({"generation_mode": "ref2va", "conditioning_assets": [
conditioning(response.json(), "reference")
]}, "full-h3")
def test_ref_reports_undecodable_image_as_invalid_input(library, monkeypatch, tmp_path):
store, _ = library
broken = tmp_path / "broken.png"
broken.write_bytes(b"not an image")
monkeypatch.setattr(store, "get", lambda asset_id: assets.StoredAsset(asset_id, "image", str(broken), "broken.png",
"image/png", 11))
with pytest.raises(ValueError, match="could not be decoded"):
resolve_generation_inputs({"generation_mode": "ref2va", "conditioning_assets": [
{"asset_id": "a" * 32, "role": "reference"}
]}, "full-h3")
def test_audio_upload_rejects_surround_sound(library, monkeypatch):
import json
_, client = library
monkeypatch.setattr(assets.shutil, "which", lambda name: "/usr/bin/ffprobe")
info = {"format": {"format_name": "wav", "duration": "1"},
"streams": [{"codec_type": "audio", "channels": 6}]}
monkeypatch.setattr(assets.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(
[], 0, stdout=json.dumps(info).encode(), stderr=b""))
response = client.post("/assets", content=b"surround wav", headers={"Content-Type": "audio/wav"})
assert response.status_code == 400
assert "mono or stereo" in response.json()["detail"]
@pytest.mark.parametrize("mime,format_name", [("audio/x-m4a", "mov,mp4,m4a,3gp,3g2,mj2"), ("audio/x-flac", "flac")])
def test_legacy_audio_mime_aliases_are_accepted(library, monkeypatch, mime, format_name):
"""Browsers report x- variants for the M4A and FLAC formats the docs promise."""
import json
_, client = library
monkeypatch.setattr(assets.shutil, "which", lambda name: "/usr/bin/ffprobe")
info = {"format": {"format_name": format_name, "duration": "1"},
"streams": [{"codec_type": "audio", "channels": 2}]}
monkeypatch.setattr(assets.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(
[], 0, stdout=json.dumps(info).encode(), stderr=b""))
response = client.post("/assets", content=b"audio bytes", headers={"Content-Type": mime})
assert response.status_code == 201, response.text
assert response.json()["kind"] == "audio"
assert response.json()["mime_type"] == mime
@pytest.mark.parametrize("entries", [None, {}, "x", [{"path": "/etc/passwd", "role": "reference"}],
[{"asset_id": "x", "role": "unknown"}]])
def test_malformed_conditioning_is_rejected(entries):
with pytest.raises(ValueError):
resolve_generation_inputs({"generation_mode": "ref2va", "conditioning_assets": entries}, "full-h3")
@pytest.mark.parametrize("mode", ["t2va", "fl2va", "ref2va"])
def test_mock_streams_all_valid_modes_and_releases_assets(library, monkeypatch, mode):
from dreamverse import mock_server
_, client = library
monkeypatch.setattr(mock_server, "MOCK_SEGMENT_BYTES", b"mock-fmp4")
monkeypatch.setattr(mock_server, "LATENCY_MS", 1)
image = upload_image(client)
refs = [] if mode == "t2va" else [conditioning(image, "first_frame" if mode == "fl2va" else "reference")]
ws = _FakeWebSocket([
(0, {"type": "session_init_v2", "generation_mode": mode, "conditioning_assets": refs,
"curated_prompts": ["A bird flies over a lake."], "single_clip_mode": True,
"enhancement_enabled": False}),
(0.15, {"type": "leave"}),
])
asyncio.run(mock_server.websocket_endpoint(ws))
assert not [event for event in ws.sent_json if event["type"] == "error"]
assert any(event["type"] == "media_segment_complete" for event in ws.sent_json)
assert ws.sent_bytes
assert client.delete(image["url"]).status_code == 204
def test_mock_rejects_invalid_mode_before_gpu_assignment(library):
from dreamverse import mock_server
ws = _FakeWebSocket([(0, {"type": "session_init_v2", "generation_mode": "fl2va"})])
asyncio.run(mock_server.websocket_endpoint(ws))
assert not any(event["type"] == "gpu_assigned" for event in ws.sent_json)
errors = [event for event in ws.sent_json if event["type"] == "error"]
assert errors[0]["error_code"] == "invalid_generation_input"
assert "first-frame" in errors[0]["message"]
@pytest.mark.skipif(not shutil.which("ffmpeg") or not shutil.which("ffprobe"), reason="ffmpeg + ffprobe required")
@pytest.mark.parametrize("kind,mime,suffix", [("video", "video/mp4", ".mp4"), ("audio", "audio/wav", ".wav")])
def test_actual_video_and_audio_upload_validation(library, tmp_path, kind, mime, suffix):
_, client = library
media_path = tmp_path / f"sample{suffix}"
source = "testsrc2=size=64x64:rate=24" if kind == "video" else "sine=frequency=440:sample_rate=24000"
command = [shutil.which("ffmpeg"), "-v", "error", "-f", "lavfi", "-i", source, "-t", "0.5", str(media_path)]
subprocess.run(command, check=True, capture_output=True, timeout=30)
response = client.post("/assets", content=media_path.read_bytes(), headers={"Content-Type": mime})
assert response.status_code == 201, response.text
assert response.json()["kind"] == kind
response = client.post("/assets", content=b"#EXTM3U\nhttp://example.com/stream", headers={"Content-Type": mime})
assert response.status_code == 400
@@ -0,0 +1,214 @@
"""CPU contract tests; fake executors do not validate generated-media quality."""
from __future__ import annotations
import importlib.util
import pickle
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import Mock
import numpy as np
import pytest
from PIL import Image
from dreamverse.config import MODEL_REGISTRY
from dreamverse.generation_inputs import GenerationAsset, GenerationInputs
from dreamverse.generation_worker import VideoGenerationWorker
from dreamverse.minimax_h3_generation import MiniMaxH3GenerationBackend
from dreamverse.worker_ipc import UserStepPayload
@pytest.fixture
def fastvideo_api(monkeypatch):
"""Use the actual lightweight API schema with only GPU execution replaced."""
schema_path = Path(__file__).resolve().parents[4] / "fastvideo/api/schema.py"
spec = importlib.util.spec_from_file_location("dreamverse_test_api_schema", schema_path)
assert spec is not None and spec.loader is not None
schema = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, schema)
spec.loader.exec_module(schema)
package = ModuleType("fastvideo")
package.__path__ = []
package.VideoGenerator = SimpleNamespace(from_config=Mock())
monkeypatch.setitem(sys.modules, "fastvideo", package)
monkeypatch.setitem(sys.modules, "fastvideo.api", schema)
schema.MiniMaxH3Reference = lambda **kwargs: SimpleNamespace(**kwargs)
monkeypatch.setattr("dreamverse.minimax_h3_generation.torch.cuda.synchronize", lambda: None)
monkeypatch.setattr("dreamverse.minimax_h3_generation.torch.cuda.empty_cache", lambda: None)
return package.VideoGenerator.from_config
class RecordingGenerator:
def __init__(self):
self.requests = []
self.images = []
self.closed = False
def shutdown(self):
self.closed = True
def generate(self, request):
self.requests.append(request)
self.images.append(tuple(None if image is None else np.asarray(image).copy()
for image in (request.inputs.pil_image, request.inputs.last_image)))
return SimpleNamespace(
frames=[np.full((2, 3, 3), 7, dtype=np.uint8), np.full((2, 3, 3), 29, dtype=np.uint8)],
audio=np.zeros((2, 16), dtype=np.float32),
audio_sample_rate=44100,
generation_time=0.1,
)
def prepared_backend(monkeypatch):
backend = MiniMaxH3GenerationBackend(0)
backend.model_config = dict(MODEL_REGISTRY["full-h3"])
backend.generator = RecordingGenerator()
monkeypatch.setattr(backend, "_gpu_mem", lambda: "fake executor")
return backend
def test_ipc_preserves_immutable_ordered_references():
inputs = GenerationInputs("ref2va", (
GenerationAsset("second", "video", "/assets/second.mp4", "reference"),
GenerationAsset("first", "image", "/assets/first.png", "reference"),
))
payload = UserStepPayload("follow the references", 1, None, True, inputs)
restored = pickle.loads(pickle.dumps(payload))
assert restored == payload
assert [asset.asset_id for asset in restored.generation_inputs.references] == ["second", "first"]
def test_worker_passes_conditioning_to_selected_backend():
inputs = GenerationInputs("t2va")
worker = VideoGenerationWorker(0)
worker.backend = Mock()
worker.generate_step("prompt", 1, None, True, inputs)
worker.backend.generate_step.assert_called_once_with("prompt", 1, None, True, generation_inputs=inputs)
def test_full_h3_uses_full_weights_without_preview_lora(monkeypatch, fastvideo_api):
backend = prepared_backend(monkeypatch)
old_generator = backend.generator
fastvideo_api.return_value = RecordingGenerator()
monkeypatch.setattr("dreamverse.minimax_h3_generation.DREAMVERSE_SP_SIZE", 4)
backend.initialize(MODEL_REGISTRY["full-h3"])
config = fastvideo_api.call_args.args[0]
assert old_generator.closed
assert config.pipeline.components.lora_path is None
assert config.pipeline.components.override_pipeline_cls_name is None
assert config.engine.use_fsdp_inference
assert config.engine.num_gpus == 4
assert not config.pipeline.experimental["inference_torch_compile"]
def test_fl2va_maps_endpoints_only_on_initial_segment(monkeypatch, fastvideo_api, tmp_path):
first = tmp_path / "first.png"
last = tmp_path / "last.png"
Image.new("RGB", (3, 2), (10, 20, 30)).save(first)
Image.new("RGB", (3, 2), (40, 50, 60)).save(last)
inputs = GenerationInputs("fl2va", (
GenerationAsset("first", "image", str(first), "first_frame"),
GenerationAsset("last", "image", str(last), "last_frame"),
))
backend = prepared_backend(monkeypatch)
first_result = backend.generate_step("first", 1, None, True, inputs)
later_result = backend.generate_step("later", 2, None, False, inputs)
assert backend.generator.images[0][0][0, 0].tolist() == [10, 20, 30]
assert backend.generator.images[0][1][0, 0].tolist() == [40, 50, 60]
assert backend.generator.images[1][0][0, 0].tolist() == [29, 29, 29]
assert backend.generator.images[1][1] is None
assert first_result.head_trim_frames == 0
assert later_result.head_trim_frames == 1
assert backend.generator.requests[0].sampling.num_inference_steps == 50
def test_ref2va_switches_pipeline_and_preserves_reference_order(monkeypatch, fastvideo_api):
inputs = GenerationInputs("ref2va", (
GenerationAsset("video", "video", "/assets/reference.mp4", "reference"),
GenerationAsset("audio", "audio", "/assets/reference.wav", "reference"),
GenerationAsset("image", "image", "/assets/reference.png", "reference"),
))
backend = prepared_backend(monkeypatch)
base_generator = backend.generator
reference_generator = RecordingGenerator()
def load(config):
assert base_generator.closed, "Old executor must release memory before loading reference weights"
assert config.pipeline.components.override_pipeline_cls_name == "MiniMaxH3Ref2VAModularPipeline"
assert config.pipeline.workload_type == "i2v"
assert config.pipeline.components.lora_path is None
return reference_generator
fastvideo_api.side_effect = load
backend.generate_step("first", 1, None, True, inputs)
result = backend.generate_step("second", 2, None, False, inputs)
assert fastvideo_api.call_count == 1
for request in reference_generator.requests:
assert [(reference.media_type, reference.source) for reference in request.inputs.references] == [
("video", "/assets/reference.mp4"), ("audio", "/assets/reference.wav"), ("image", "/assets/reference.png")
]
assert request.inputs.pil_image is None
assert request.inputs.last_image is None
assert result.head_trim_frames == result.head_trim_audio_frames == 0
assert backend.continuation_image is None
fastvideo_api.side_effect = None
fastvideo_api.return_value = RecordingGenerator()
backend.generate_step("new project", 1, None, True, GenerationInputs("t2va"))
assert reference_generator.closed
config = fastvideo_api.call_args.args[0]
assert config.pipeline.components.override_pipeline_cls_name is None
assert backend.pipeline_mode == "base"
def test_ref2va_pipeline_switch_failure_drops_unloaded_executor(monkeypatch, fastvideo_api):
backend = prepared_backend(monkeypatch)
old_generator = backend.generator
fastvideo_api.side_effect = RuntimeError("checkpoint unavailable")
with pytest.raises(RuntimeError, match="checkpoint unavailable"):
backend.generate_step("prompt", 1, None, True, GenerationInputs("ref2va"))
assert old_generator.closed
assert backend.generator is None
def test_failed_pipeline_switch_reloads_on_the_next_step(monkeypatch, fastvideo_api):
"""A failed base<->ref2va switch must not strand the slot for later steps."""
backend = prepared_backend(monkeypatch)
fastvideo_api.side_effect = RuntimeError("checkpoint unavailable")
with pytest.raises(RuntimeError, match="checkpoint unavailable"):
backend.generate_step("prompt", 1, None, True, GenerationInputs("ref2va"))
fastvideo_api.side_effect = None
fastvideo_api.return_value = RecordingGenerator()
backend.generate_step("retry", 1, None, True, GenerationInputs("ref2va"))
assert fastvideo_api.call_count == 2
assert backend.pipeline_mode == "ref2va"
assert backend.generator is not None
def test_mode_cannot_switch_mid_project(monkeypatch, fastvideo_api):
backend = prepared_backend(monkeypatch)
with pytest.raises(ValueError, match="middle of a project"):
backend.generate_step("prompt", 2, None, False, GenerationInputs("ref2va"))
fastvideo_api.assert_not_called()
@pytest.mark.parametrize("mode", ["fl2va", "ref2va"])
def test_preview_rejects_unsupported_generation_modes(monkeypatch, fastvideo_api, mode):
backend = prepared_backend(monkeypatch)
backend.model_config = dict(MODEL_REGISTRY["fast-h3"])
with pytest.raises(ValueError, match="full-h3"):
backend.generate_step("prompt", 1, None, True, GenerationInputs(mode))
assert backend.generator.requests == []
def test_legacy_h3_continuation_is_preserved(monkeypatch, fastvideo_api):
backend = prepared_backend(monkeypatch)
backend.generate_step("first", 1, None, False)
result = backend.generate_step("second", 2, None, False)
assert backend.generator.requests[0].inputs.pil_image is None
assert backend.generator.images[1][0][0, 0].tolist() == [29, 29, 29]
assert result.head_trim_frames == 1
@@ -0,0 +1,278 @@
"""Session-mode validation and IPC handoff without a GPU worker process."""
from __future__ import annotations
import asyncio
import importlib.util
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import Mock
import pytest
from dreamverse.generation_inputs import GenerationAsset, GenerationInputs
from dreamverse.worker_ipc import MediaChunk, MediaComplete, MediaInit
@pytest.fixture
def controller_module(monkeypatch):
gpu_pool = ModuleType("dreamverse.gpu_pool")
gpu_pool.GPUSlot = object
monkeypatch.setitem(sys.modules, "dreamverse.gpu_pool", gpu_pool)
path = Path(__file__).resolve().parents[1] / "session/controller.py"
spec = importlib.util.spec_from_file_location("dreamverse_test_session_controller", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setattr(module, "ACTIVE_MODEL_ID", "full-h3")
monkeypatch.setattr(module, "pin_generation_inputs", Mock())
monkeypatch.setattr(module, "release_generation_inputs", Mock())
return module
class Socket:
def __init__(self):
self.incoming = asyncio.Queue()
self.outgoing = asyncio.Queue()
self.messages = []
self.closed = False
async def accept(self):
pass
async def receive_json(self):
return await self.incoming.get()
async def send_json(self, payload):
self.messages.append(payload)
await self.outgoing.put(payload)
async def send_bytes(self, payload):
pass
async def close(self, **kwargs):
self.closed = True
async def wait_for(self, kind):
while True:
payload = await asyncio.wait_for(self.outgoing.get(), 3)
if payload["type"] == kind:
return payload
class Slot:
def __init__(self):
self.shared_stream_buffer = None
self.queue = asyncio.Queue()
self.calls = []
async def join_user(self, *args, **kwargs):
pass
def register_stream_queue(self, client_id):
return self.queue
def unregister_stream_queue(self, client_id):
pass
async def user_step(self, client_id, **kwargs):
self.calls.append(kwargs)
segment_idx = kwargs["segment_idx"]
await self.queue.put(MediaInit(client_id, segment_idx, "test", "video/mp4", False))
await self.queue.put(MediaChunk(client_id, segment_idx, "test", chunk=b"test"))
await self.queue.put(MediaComplete(client_id, segment_idx, "test", 1))
return {"e2e_latency_ms": 1.0}
class Pool:
def __init__(self):
self.slot = Slot()
self.acquire_count = 0
def get_status(self):
return {"queue_size": 0, "available_gpus": 1, "total_gpus": 1}
async def acquire(self, *args):
self.acquire_count += 1
return 0, self.slot
async def release(self, *args):
pass
def start_controller(module, socket, pool):
enhancer = SimpleNamespace(
resolve_rewrite_model=lambda value: "test-model",
resolve_rewrite_system_prompt=lambda value: "test-system",
resolve_rewrite_temperature=lambda value: 1.0,
)
controller = module.SessionController(socket, pool, enhancer, None, None)
return asyncio.create_task(controller.run())
def test_invalid_initial_mode_does_not_acquire_gpu(controller_module):
async def scenario():
socket, pool = Socket(), Pool()
await socket.incoming.put({"type": "session_init_v2", "generation_mode": "unknown"})
await asyncio.wait_for(start_controller(controller_module, socket, pool), 3)
error = next(message for message in socket.messages if message["type"] == "error")
assert error["error_code"] == "invalid_generation_input"
assert pool.acquire_count == 0
assert socket.closed
controller_module.pin_generation_inputs.assert_not_called()
asyncio.run(scenario())
def test_new_project_replaces_conditioning_and_passes_it_to_gpu(controller_module, monkeypatch):
first = GenerationInputs("t2va")
second = GenerationInputs("fl2va", (GenerationAsset("first", "image", "/assets/first.png", "first_frame"),))
monkeypatch.setattr(controller_module, "resolve_generation_inputs", Mock(side_effect=[first, second]))
async def scenario():
socket, pool = Socket(), Pool()
await socket.incoming.put({
"type": "session_init_v2", "generation_mode": "t2va", "curated_prompts": ["first prompt"],
"enhancement_enabled": False,
})
task = start_controller(controller_module, socket, pool)
try:
await socket.wait_for("media_segment_complete")
await socket.incoming.put({"type": "end_project_keep_session"})
await socket.wait_for("project_idle")
assert first in [call.args[0] for call in controller_module.release_generation_inputs.call_args_list]
await socket.incoming.put({
"type": "project_init_v1", "generation_mode": "fl2va", "curated_prompts": ["second prompt"],
"enhancement_enabled": False,
})
await socket.wait_for("media_segment_complete")
assert [call["generation_inputs"] for call in pool.slot.calls] == [first, second]
assert pool.slot.calls[1]["segment_idx"] == 1
assert pool.slot.calls[1]["reset_conditioning"]
await socket.incoming.put({"type": "leave"})
await asyncio.wait_for(task, 3)
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert [call.args[0] for call in controller_module.pin_generation_inputs.call_args_list] == [first, second]
assert second in [call.args[0] for call in controller_module.release_generation_inputs.call_args_list]
asyncio.run(scenario())
@pytest.mark.parametrize("injection", [
{"initial_image": {"data_url": "not allowed"}},
{"generation_mode": "ref2va"},
{"conditioning_assets": []},
])
def test_simple_generate_cannot_replace_locked_inputs(controller_module, injection):
async def scenario():
socket, pool = Socket(), Pool()
await socket.incoming.put({
"type": "session_init_v2", "generation_mode": "t2va", "single_clip_mode": True,
"enhancement_enabled": False,
})
task = start_controller(controller_module, socket, pool)
try:
await socket.wait_for("gpu_assigned")
await socket.incoming.put({"type": "simple_generate", "prompt": "prompt", **injection})
error = await socket.wait_for("error")
assert error["error_code"] == "invalid_generation_input"
assert "project" in error["message"]
assert pool.slot.calls == []
await socket.incoming.put({"type": "leave"})
await asyncio.wait_for(task, 3)
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())
def test_disconnect_waits_for_worker_before_releasing_assets(controller_module, monkeypatch):
async def scenario():
socket, pool = Socket(), Pool()
worker_started = asyncio.Event()
worker_finished = asyncio.Event()
proceed = asyncio.Event()
async def slow_step(client_id, **kwargs):
worker_started.set()
await proceed.wait()
worker_finished.set()
return {"e2e_latency_ms": 1.0}
pool.slot.user_step = slow_step
await socket.incoming.put({
"type": "session_init_v2", "generation_mode": "t2va", "curated_prompts": ["prompt"],
"enhancement_enabled": False,
})
task = start_controller(controller_module, socket, pool)
try:
await asyncio.wait_for(worker_started.wait(), 3)
await socket.incoming.put({"type": "leave"})
await asyncio.sleep(0.07)
assert not task.done()
controller_module.release_generation_inputs.assert_not_called()
proceed.set()
await asyncio.wait_for(task, 3)
assert worker_finished.is_set()
controller_module.release_generation_inputs.assert_called_once()
finally:
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())
@pytest.fixture
def gpu_pool_module(monkeypatch):
streaming = ModuleType("dreamverse.av_streaming")
for name in ("StreamChunk", "StreamComplete", "StreamEvent", "StreamInit", "generate_stream_id", "stream_fmp4"):
setattr(streaming, name, object)
streaming.SHARED_STREAM_BUFFER_BYTES = 1024
streaming.USE_SHARED_STREAM_BUFFER = False
monkeypatch.setitem(sys.modules, "dreamverse.av_streaming", streaming)
path = Path(__file__).resolve().parents[1] / "gpu_pool.py"
spec = importlib.util.spec_from_file_location("dreamverse_test_gpu_pool", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, module)
spec.loader.exec_module(module)
monkeypatch.setattr(module, "pin_generation_inputs", Mock())
monkeypatch.setattr(module, "release_generation_inputs", Mock())
return module
def test_gpu_step_timeout_keeps_assets_pinned_until_late_worker_completion(gpu_pool_module):
from dreamverse.worker_ipc import StepComplete
async def scenario():
slot = gpu_pool_module.GPUSlot(0, "0")
inputs = GenerationInputs("ref2va", (GenerationAsset("ref", "image", "/assets/ref.png", "reference"),))
async def timeout(command, timeout):
assert command.payload.generation_inputs == inputs
raise asyncio.TimeoutError
slot._send_command_tagged = timeout
with pytest.raises(asyncio.TimeoutError):
await slot.user_step("user", "prompt", generation_inputs=inputs)
gpu_pool_module.pin_generation_inputs.assert_called_once_with(inputs)
gpu_pool_module.release_generation_inputs.assert_not_called()
def late_response(timeout):
slot._active = False
return StepComplete("user", 1, {})
slot.response_queue = SimpleNamespace(get=late_response)
slot._active = True
await slot._response_reader()
gpu_pool_module.release_generation_inputs.assert_called_once_with(inputs)
assert slot._step_asset_inputs == {}
asyncio.run(scenario())
@@ -0,0 +1,18 @@
from dreamverse.generation_worker import _create_generation_backend
from dreamverse.ltx2_generation import LTX2GenerationBackend
def test_create_generation_backend_ltx2_module_import():
backend = _create_generation_backend("ltx2", gpu_id=3)
assert isinstance(backend, LTX2GenerationBackend)
assert backend.gpu_id == 3
def test_create_generation_backend_cosmos25_dfd_module_import():
from dreamverse.cosmos25_dfd_generation import Cosmos25DFDGenerationBackend
backend = _create_generation_backend("cosmos25_dfd", gpu_id=2)
assert isinstance(backend, Cosmos25DFDGenerationBackend)
assert backend.gpu_id == 2
@@ -63,6 +63,14 @@ def test_get_available_gpus_defaults_to_first_visible_device(monkeypatch):
assert gpu_pool.get_available_gpus() == [3]
def test_get_available_gpus_defaults_to_active_model_sequence_parallel_size(monkeypatch):
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "0,1,2,3,4")
monkeypatch.delenv("FASTVIDEO_GPU_COUNT", raising=False)
monkeypatch.setattr(gpu_pool, "DREAMVERSE_SP_SIZE", 4)
assert gpu_pool.get_available_gpus() == [0, 1, 2, 3]
def test_get_available_gpus_rejects_invalid_gpu_count(monkeypatch):
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
monkeypatch.setenv("FASTVIDEO_GPU_COUNT", "zero")
@@ -71,6 +79,23 @@ def test_get_available_gpus_rejects_invalid_gpu_count(monkeypatch):
gpu_pool.get_available_gpus()
def test_join_user_failed_reload_marks_model_uninitialized(monkeypatch):
"""A failed model reload forces the next join to reload a model."""
slot = gpu_pool.GPUSlot(gpu_id=0, cuda_device="0")
slot.current_model_id = "fast-ltx2"
async def fake_send_command(command, timeout):
del command, timeout
return gpu_pool.WorkerError(user_id="__reload__", message="load failed")
monkeypatch.setattr(slot, "_send_command", fake_send_command)
with pytest.raises(RuntimeError, match="Model reload failed"):
asyncio.run(slot.join_user("client-id", model_id="fast-h3"))
assert slot.current_model_id is None
def test_send_command_raises_on_worker_death():
"""A worker that consumes a command and exits without replying must
surface as RuntimeError via sentinel detection, not after the long
@@ -92,9 +117,9 @@ def test_send_command_raises_on_worker_death():
ready = resp_q.get(timeout=30.0)
assert ready == "READY"
async def runner():
async def runner() -> None:
slot = gpu_pool.GPUSlot(gpu_id=0, cuda_device="0")
slot.process = proc
slot.process = proc # type: ignore[assignment]
slot.command_queue = cmd_q
slot.response_queue = resp_q
@@ -1,4 +1,6 @@
import ast
import subprocess
import sys
from pathlib import Path
ALLOWED_PREFIXES = (
@@ -17,11 +19,11 @@ FORBIDDEN_PREFIXES = (
)
ALLOWED_INTERNAL_IMPORTS = {
(
"video_generation.py",
"ltx2_generation.py",
"fastvideo.models.audio.ltx2_audio_processing",
),
(
"video_generation.py",
"ltx2_generation.py",
"fastvideo.models.loader.component_loader",
),
}
@@ -48,3 +50,39 @@ def test_dreamverse_server_imports_only_public_fastvideo_surfaces() -> None:
bad.append((str(path.relative_to(root)), getattr(node, "lineno", 0), name))
assert bad == [], f"Forbidden internal imports: {bad}"
def test_h3_reference_public_export_is_lazy_and_preserves_type_identity() -> None:
"""Only explicit reference usage should load H3's optional GPU dependencies."""
repo_root = Path(__file__).resolve().parents[4]
# Isolate the import graph: keep the real public API implementation/schema,
# substituting only the unrelated legacy sampling module and heavy H3 leaf.
script = r'''
import importlib
import sys
from pathlib import Path
from types import ModuleType
root = Path(sys.argv[1])
fastvideo = ModuleType("fastvideo")
fastvideo.__path__ = [str(root / "fastvideo")]
sys.modules["fastvideo"] = fastvideo
sampling = ModuleType("fastvideo.api.sampling_param")
sampling.SamplingParam = type("SamplingParam", (), {})
sys.modules[sampling.__name__] = sampling
api = importlib.import_module("fastvideo.api")
assert "MiniMaxH3Reference" in api.__all__
assert "MiniMaxH3Reference" not in vars(api)
assert not any(name.startswith("fastvideo.pipelines") for name in sys.modules)
internal = ModuleType("fastvideo.pipelines.basic.minimax_h3.reference")
internal.MiniMaxH3Reference = type("MiniMaxH3Reference", (), {})
sys.modules[internal.__name__] = internal
from fastvideo.api import MiniMaxH3Reference
assert MiniMaxH3Reference is internal.MiniMaxH3Reference
assert api.MiniMaxH3Reference is internal.MiniMaxH3Reference
assert not hasattr(api, "UnknownReference")
'''
result = subprocess.run([sys.executable, "-c", script, str(repo_root)], capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
@@ -0,0 +1,247 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
import numpy as np
import pytest
import dreamverse.generation_worker as generation_worker
from dreamverse.minimax_h3_generation import MiniMaxH3GenerationBackend
FASTH3_MODEL_CONFIG = {
"name": "FastH3",
"generation_backend": "minimax_h3",
"default_sp_size": 4,
"model_path": "MiniMaxAI/MiniMax-H3",
"adapter_repo": "FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA",
"adapter_filename": "vsa-datafree/adapter_model.safetensors",
"attention_backend": "VIDEO_SPARSE_ATTN_H3",
"height": 768,
"width": 1344,
"num_frames": 124,
"num_inference_steps": 5,
"seed": 1000,
}
class _RecordingGenerator:
"""Record typed requests and return small synchronized media fixtures."""
def __init__(self) -> None:
self.requests: list[Any] = []
self.conditioning_pixels: list[np.ndarray | None] = []
def generate(self, request):
"""Capture the request and return two tiny video frames with audio."""
self.requests.append(request)
conditioning_image = request.inputs.pil_image
self.conditioning_pixels.append(
None if conditioning_image is None else np.asarray(conditioning_image).copy())
frames = [
np.full((2, 3, 3), 10, dtype=np.uint8),
np.full((2, 3, 3), 20, dtype=np.uint8),
]
return SimpleNamespace(
frames=frames,
audio=np.zeros((2, 16), dtype=np.float32),
audio_sample_rate=44100,
generation_time=0.25,
)
def test_initialize_builds_vsa_datafree_fasth3_generator(monkeypatch):
"""Initialization translates the DreamVerse profile into typed FastVideo config."""
from fastvideo import VideoGenerator
captured = {}
fake_generator = SimpleNamespace(shutdown=lambda: None)
def fake_from_config(config):
captured["config"] = config
return fake_generator
def fake_download(**kwargs):
captured["download"] = kwargs
return f"/models/{kwargs['filename']}"
monkeypatch.setattr("huggingface_hub.hf_hub_download", fake_download)
monkeypatch.setattr(VideoGenerator, "from_config", fake_from_config)
monkeypatch.setattr("dreamverse.minimax_h3_generation.DREAMVERSE_SP_SIZE", 4)
monkeypatch.setenv("FASTVIDEO_ATTENTION_BACKEND", "test-attention")
monkeypatch.setenv("FASTVIDEO_FA4", "0")
monkeypatch.setenv("FASTVIDEO_MINIMAX_H3_FUSIONS", "0")
monkeypatch.setenv("FASTVIDEO_VSA_SM100A", "1")
monkeypatch.setenv("FASTVIDEO_INFERENCE_TORCH_COMPILE", "1")
backend = MiniMaxH3GenerationBackend(gpu_id=0)
monkeypatch.setattr(backend, "_gpu_mem", lambda: "alloc=0.00GiB, reserved=0.00GiB")
backend.initialize(FASTH3_MODEL_CONFIG)
config = captured["config"]
assert captured["download"] == {
"repo_id": "FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA",
"filename": "vsa-datafree/adapter_model.safetensors",
}
assert config.model_path == "MiniMaxAI/MiniMax-H3"
assert config.pipeline.components.lora_path.endswith("vsa-datafree/adapter_model.safetensors")
assert config.pipeline.components.lora_strength == 1.0
assert config.pipeline.experimental == {
"attention_backend": "VIDEO_SPARSE_ATTN_H3",
"inference_torch_compile": False,
"vae_parallel_decode": True,
"vae_parallel_decode_strategy": "gather",
"VSA_sparsity": 0.9,
"VSA_tile_size": 64,
}
assert config.engine.num_gpus == 4
assert config.engine.parallelism.tp_size == 1
assert config.engine.parallelism.sp_size == 4
assert config.engine.offload.dit is False
assert config.engine.offload.dit_layerwise is False
assert config.engine.offload.text_encoder is True
assert config.engine.offload.vae is True
assert config.engine.compile.vae_enabled is True
assert config.engine.use_fsdp_inference is False
assert os.environ["FASTVIDEO_ATTENTION_BACKEND"] == "VIDEO_SPARSE_ATTN_H3"
assert os.environ["FASTVIDEO_FA4"] == "1"
assert os.environ["FASTVIDEO_MINIMAX_H3_FUSIONS"] == "all"
assert os.environ["FASTVIDEO_VSA_SM100A"] == "0"
assert "FASTVIDEO_INFERENCE_TORCH_COMPILE" not in os.environ
def test_initialize_selects_declared_generation_backend(monkeypatch):
"""The GPU worker constructs the backend that the active model profile declares."""
from unittest.mock import Mock
selected_backend = Mock()
monkeypatch.setattr(
generation_worker,
"_create_generation_backend",
lambda backend_name, gpu_id: selected_backend,
)
worker = generation_worker.VideoGenerationWorker(gpu_id=3)
worker.initialize(FASTH3_MODEL_CONFIG)
assert worker.backend_name == "minimax_h3"
assert worker.backend is selected_backend
selected_backend.initialize.assert_called_once_with(FASTH3_MODEL_CONFIG)
def test_initialize_failure_clears_backend_ownership(monkeypatch):
"""A failed family change leaves the GPU worker explicitly uninitialized."""
ltx_backend = SimpleNamespace(initialize=lambda config: None, shutdown=lambda: None)
def fail_initialize(config):
del config
raise RuntimeError("load failed")
fasth3_backend = SimpleNamespace(
initialize=fail_initialize,
shutdown=lambda: None,
)
backends = {
"ltx2": ltx_backend,
"minimax_h3": fasth3_backend,
}
monkeypatch.setattr(
generation_worker,
"_create_generation_backend",
lambda backend_name, gpu_id: backends[backend_name],
)
worker = generation_worker.VideoGenerationWorker(gpu_id=3)
worker.initialize({"generation_backend": "ltx2"})
with pytest.raises(RuntimeError, match="load failed"):
worker.initialize(FASTH3_MODEL_CONFIG)
assert worker.backend is None
assert worker.backend_name is None
assert worker.model_config == {"generation_backend": "ltx2"}
def test_generate_step_uses_last_frame_for_continuation(monkeypatch):
"""A later segment receives the prior segment's last decoded frame."""
backend = MiniMaxH3GenerationBackend(gpu_id=0)
backend.model_config = dict(FASTH3_MODEL_CONFIG)
backend.generator = _RecordingGenerator()
monkeypatch.setattr("dreamverse.minimax_h3_generation.torch.cuda.synchronize", lambda: None)
first_result = backend.generate_step(
"first prompt",
segment_idx=1,
image_path=None,
reset_conditioning=True,
)
second_result = backend.generate_step(
"second prompt",
segment_idx=2,
image_path=None,
reset_conditioning=False,
)
first_request = backend.generator.requests[0]
assert first_request.inputs.pil_image is None
assert first_request.negative_prompt == ""
assert first_request.sampling.height == 768
assert first_request.sampling.width == 1344
assert first_request.sampling.num_frames == 124
assert first_request.sampling.num_inference_steps == 5
assert first_request.sampling.fps == 24
assert first_request.sampling.guidance_scale == 1.0
assert first_request.sampling.batch_cfg is False
assert first_request.sampling.seed == 1000
assert first_request.output.save_video is False
assert first_request.output.return_frames is True
assert backend.generator.conditioning_pixels[1].tolist() == np.full((2, 3, 3), 20).tolist()
assert first_result.head_trim_frames == 0
assert first_result.head_trim_audio_frames == 0
assert second_result.head_trim_frames == 1
assert second_result.head_trim_audio_frames == 1
assert second_result.audio_sample_rate == 44100
def test_generate_step_reset_uses_text_to_video_path(monkeypatch):
"""Resetting continuation produces an unconditioned text-to-video request."""
backend = MiniMaxH3GenerationBackend(gpu_id=0)
backend.model_config = dict(FASTH3_MODEL_CONFIG)
backend.generator = _RecordingGenerator()
monkeypatch.setattr("dreamverse.minimax_h3_generation.torch.cuda.synchronize", lambda: None)
backend.generate_step("first prompt", 1, None, True)
reset_result = backend.generate_step("reset prompt", 2, None, True)
assert backend.generator.requests[-1].inputs.pil_image is None
assert reset_result.head_trim_frames == 0
assert reset_result.head_trim_audio_frames == 0
def test_generate_step_missing_continuation_frame(monkeypatch):
"""A later segment fails when no reset or retained frame defines its input."""
backend = MiniMaxH3GenerationBackend(gpu_id=0)
backend.model_config = dict(FASTH3_MODEL_CONFIG)
backend.generator = _RecordingGenerator()
with pytest.raises(RuntimeError, match="requires a retained continuation frame"):
backend.generate_step("later prompt", 2, None, False)
assert backend.generator.requests == []
def test_warmup_exercises_text_and_first_frame_paths(monkeypatch):
"""Warmup covers both request shapes used by a DreamVerse session."""
backend = MiniMaxH3GenerationBackend(gpu_id=0)
backend.model_config = dict(FASTH3_MODEL_CONFIG)
backend.generator = _RecordingGenerator()
monkeypatch.setattr("dreamverse.minimax_h3_generation.torch.cuda.synchronize", lambda: None)
timings = backend.warmup("warmup prompt")
assert backend.generator.conditioning_pixels[0] is None
assert backend.generator.conditioning_pixels[1] is not None
assert backend.continuation_image is None
assert "warmup_text_to_video_ms" in timings
assert "warmup_first_frame_to_video_ms" in timings
@@ -331,11 +331,11 @@ def test_rewrite_prompt_sequence_accepts_numbered_prose_output():
]
def test_enhance_prompt_prefers_cerebras_before_groq_fallback():
def test_enhance_prompt_uses_groq_when_it_returns_first():
enhancer = _build_staged_enhancer(
cerebras_payload=_chat_payload_with_content('{"prompt":"Cerebras prompt"}'),
groq_payload=_chat_payload_with_content('{"prompt":"Groq prompt"}'),
cerebras_delay_s=0.01,
cerebras_delay_s=0.08,
groq_delay_s=0.01,
)
@@ -346,12 +346,12 @@ def test_enhance_prompt_prefers_cerebras_before_groq_fallback():
assert result.fallback_used is False
assert result.error is None
assert result.provider == "cerebras"
assert result.provider == "groq"
assert result.model == "gpt-test"
assert result.prompt == "Cerebras prompt"
assert result.prompt == "Groq prompt"
assert enhancer.get_provider_success_counts() == {
"cerebras": 1,
"groq": 0,
"cerebras": 0,
"groq": 1,
}
@@ -105,6 +105,7 @@ class _FakeSlot:
segment_idx: int,
reset_conditioning: bool,
image_path: str | None = None,
generation_inputs=None,
):
self.calls.append({
"client_id": client_id,
+3
View File
@@ -15,6 +15,8 @@ from __future__ import annotations
from dataclasses import dataclass
from dreamverse.generation_inputs import GenerationInputs
# ---- User-scoped events (carry user_id) ------------------------------------
@@ -147,6 +149,7 @@ class UserStepPayload:
segment_idx: int
image_path: str | None
reset_conditioning: bool
generation_inputs: GenerationInputs | None = None
@dataclass(frozen=True)
+7 -7
View File
@@ -70,7 +70,7 @@
<mxCell id="dispatcher" value="command dispatcher&#xa;&#xa;gpu_worker_process() branches on&#xa;CommandType; asserts payload type&#xa;&#xa;INIT / WARMUP / RELOAD_MODEL&#xa;USER_JOIN / USER_STEP / USER_LEAVE&#xa;SHUTDOWN" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=11;align=left;spacingLeft=10;spacingTop=8;fontStyle=1;" parent="1" vertex="1">
<mxGeometry x="120" y="1120" width="240" height="120" as="geometry"/>
</mxCell>
<mxCell id="do_step" value="VideoGenerationWorker.generate_step()&#xa;video_generation.py:380&#xa;&#xa;reads + updates ContinuationState,&#xa;calls generator" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;align=left;spacingLeft=10;spacingTop=8;fontStyle=1;" parent="1" vertex="1">
<mxCell id="do_step" value="VideoGenerationWorker.generate_step()&#xa;ltx2_generation.py:380&#xa;&#xa;reads + updates ContinuationState,&#xa;calls generator" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;align=left;spacingLeft=10;spacingTop=8;fontStyle=1;" parent="1" vertex="1">
<mxGeometry x="460" y="1120" width="240" height="120" as="geometry"/>
</mxCell>
<mxCell id="stream_av" value="stream_fmp4()&#xa;av_streaming.py:121&#xa;&#xa;trims overlap, pipes to ffmpeg,&#xa;publishes StreamInit / StreamChunk /&#xa;StreamComplete via callback" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#b1d8d7;strokeColor=#23445d;fontSize=11;align=left;spacingLeft=10;spacingTop=8;fontStyle=1;" parent="1" vertex="1">
@@ -79,13 +79,13 @@
<mxCell id="Ot8BU52QTIb4EhyRSe7I-2" value="" style="edgeStyle=none;html=1;" parent="1" source="generator" target="Ot8BU52QTIb4EhyRSe7I-1" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="generator" value="VideoGenerator (fastvideo)&#xa;&#xa;LTX2 DiT + refine upsampler&#xa;FP4 quant, torch.compile&#xa;&#xa;owned by VideoGenerationWorker&#xa;video_generation.py:211" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;" parent="1" vertex="1">
<mxCell id="generator" value="VideoGenerator (fastvideo)&#xa;&#xa;LTX2 DiT + refine upsampler&#xa;FP4 quant, torch.compile&#xa;&#xa;owned by VideoGenerationWorker&#xa;ltx2_generation.py:211" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="460" y="1300" width="240" height="100" as="geometry"/>
</mxCell>
<mxCell id="ffmpeg" value="ffmpeg subprocess&#xa;&#xa;libx264 / *_nvenc&#xa;fragmented mp4" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d79b00;fontSize=11;" parent="1" vertex="1">
<mxGeometry x="800" y="1300" width="260" height="100" as="geometry"/>
</mxCell>
<mxCell id="caches" value="ContinuationState&#xa;video_generation.py:89&#xa;&#xa;• video_images: list[PIL.Image]&#xa;• audio_latents: torch.Tensor (CPU)&#xa;&#xa;carried across segments" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;align=left;spacingLeft=10;spacingTop=8;" parent="1" vertex="1">
<mxCell id="caches" value="ContinuationState&#xa;ltx2_generation.py:89&#xa;&#xa;• video_images: list[PIL.Image]&#xa;• audio_latents: torch.Tensor (CPU)&#xa;&#xa;carried across segments" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;align=left;spacingLeft=10;spacingTop=8;" parent="1" vertex="1">
<mxGeometry x="120" y="1300" width="240" height="100" as="geometry"/>
</mxCell>
<mxCell id="e_cp" value="acquire" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;strokeColor=#6c8ebf;endArrow=classic;fontSize=11;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="1" source="client" target="pool" edge="1">
@@ -250,7 +250,7 @@
<mxPoint x="690" y="880"/>
</Array>
</mxCell>
<mxCell id="legend" value="Legend&#xa;&#xa;■ blue client / external&#xa;■ green main-process pool/slot&#xa; (methods — italic label)&#xa;■ yellow containers (routing state)&#xa;■ red IPC primitives (mp.Queue, mp.RawArray)&#xa;&#xa;Worker subprocess modules:&#xa;■ orange gpu_pool.py (dispatcher)&#xa;■ lavender video_generation.py&#xa;■ teal av_streaming.py&#xa;■ gray worker_ipc.py (shared types)&#xa;&#xa;Flow:&#xa; client → pool → slot&#xa; → _send_command(_tagged) → command_queue&#xa; → dispatcher → generate_step()&#xa; → stream_fmp4() → ffmpeg&#xa; → shared_buf + response_queue&#xa; → _response_reader → futures / stream_queues&#xa; → client awaits (via main.py AV loop)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;fontSize=11;align=left;spacingLeft=10;spacingTop=8;" parent="1" vertex="1">
<mxCell id="legend" value="Legend&#xa;&#xa;■ blue client / external&#xa;■ green main-process pool/slot&#xa; (methods — italic label)&#xa;■ yellow containers (routing state)&#xa;■ red IPC primitives (mp.Queue, mp.RawArray)&#xa;&#xa;Worker subprocess modules:&#xa;■ orange gpu_pool.py (dispatcher)&#xa;■ lavender ltx2_generation.py&#xa;■ teal av_streaming.py&#xa;■ gray worker_ipc.py (shared types)&#xa;&#xa;Flow:&#xa; client → pool → slot&#xa; → _send_command(_tagged) → command_queue&#xa; → dispatcher → generate_step()&#xa; → stream_fmp4() → ffmpeg&#xa; → shared_buf + response_queue&#xa; → _response_reader → futures / stream_queues&#xa; → client awaits (via main.py AV loop)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;fontSize=11;align=left;spacingLeft=10;spacingTop=8;" parent="1" vertex="1">
<mxGeometry x="39" y="-200" width="270" height="380" as="geometry"/>
</mxCell>
<mxCell id="Ot8BU52QTIb4EhyRSe7I-1" value="FastVideo video_generator" style="whiteSpace=wrap;html=1;fontSize=11;fillColor=#e1d5e7;strokeColor=#9673a6;rounded=1;" parent="1" vertex="1">
@@ -389,10 +389,10 @@
<mxCell id="cw2" value="from fastvideo.entrypoints.video_generator import VideoGenerator&#xa;from fastvideo.models.dits.ltx2 import DEFAULT_LTX2_AUDIO_*&#xa;&#xa;** Dreamverse reaches into fastvideo internals here **" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe0b2;strokeColor=#d79b00;fontSize=10;align=left;spacingLeft=8;fontFamily=monospace;" parent="1" vertex="1">
<mxGeometry x="675" y="695" width="550" height="60" as="geometry"/>
</mxCell>
<mxCell id="cw3" value="on Command(INIT):&#xa; VideoGenerationWorker.initialize() (video_generation.py:247)&#xa; maybe_download_model(model_id)&#xa; VideoGenerator.from_pretrained(path, FP4Config, PipelineConfig)&#xa; load audio VAE, resolve refine upsampler&#xa; resp_q.put(InitAck(success=True))" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d79b00;fontSize=10;align=left;spacingLeft=8;fontFamily=monospace;" parent="1" vertex="1">
<mxCell id="cw3" value="on Command(INIT):&#xa; VideoGenerationWorker.initialize() (ltx2_generation.py:247)&#xa; maybe_download_model(model_id)&#xa; VideoGenerator.from_pretrained(path, FP4Config, PipelineConfig)&#xa; load audio VAE, resolve refine upsampler&#xa; resp_q.put(InitAck(success=True))" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d79b00;fontSize=10;align=left;spacingLeft=8;fontFamily=monospace;" parent="1" vertex="1">
<mxGeometry x="675" y="765" width="550" height="95" as="geometry"/>
</mxCell>
<mxCell id="cw4" value="on Command(WARMUP) with WarmupPayload:&#xa; VideoGenerationWorker.warmup(payload.prompt) (video_generation.py:518)&#xa; two synthetic segments prime caches + torch.compile&#xa; resp_q.put(WarmupComplete(timings=...))" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d79b00;fontSize=10;align=left;spacingLeft=8;fontFamily=monospace;" parent="1" vertex="1">
<mxCell id="cw4" value="on Command(WARMUP) with WarmupPayload:&#xa; VideoGenerationWorker.warmup(payload.prompt) (ltx2_generation.py:518)&#xa; two synthetic segments prime caches + torch.compile&#xa; resp_q.put(WarmupComplete(timings=...))" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#d79b00;fontSize=10;align=left;spacingLeft=8;fontFamily=monospace;" parent="1" vertex="1">
<mxGeometry x="675" y="870" width="550" height="55" as="geometry"/>
</mxCell>
<mxCell id="cw5" value="enter main worker loop → waits for JOIN_USER / USER_STEP / LEAVE" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#c8e6c9;strokeColor=#388e3c;fontSize=11;fontStyle=1;fontFamily=monospace;" parent="1" vertex="1">
@@ -534,7 +534,7 @@
<mxPoint x="1040" y="1610" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="dm11a" value="10a. worker runs:&#xa;VideoGenerationWorker.generate_step()&#xa; (video_generation.py:380)&#xa; → generator.generate_video()&#xa; → updates ContinuationState&#xa;then stream_fmp4() (av_streaming.py:121)&#xa; → ffmpeg (rawvideo+wav → fmp4)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe0b2;strokeColor=#d79b00;fontSize=10;align=left;spacingLeft=8;fontFamily=monospace;" parent="1" vertex="1">
<mxCell id="dm11a" value="10a. worker runs:&#xa;VideoGenerationWorker.generate_step()&#xa; (ltx2_generation.py:380)&#xa; → generator.generate_video()&#xa; → updates ContinuationState&#xa;then stream_fmp4() (av_streaming.py:121)&#xa; → ffmpeg (rawvideo+wav → fmp4)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe0b2;strokeColor=#d79b00;fontSize=10;align=left;spacingLeft=8;fontFamily=monospace;" parent="1" vertex="1">
<mxGeometry x="955" y="1640" width="180" height="70" as="geometry"/>
</mxCell>
<mxCell id="dm11" value="10b. resp_q.put(MediaInit / MediaChunk / MediaComplete / StepComplete)" style="endArrow=classic;html=1;strokeColor=#b85450;fontSize=10;labelBackgroundColor=#ffffff;" parent="1" edge="1">
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

+107
View File
@@ -0,0 +1,107 @@
# Dreamverse on Slurm
Run Full H3 inside a one-node, four-GPU allocation. The maintained H3 examples
default to four GPUs; this is a starting configuration, not a measured minimum.
The full checkpoint supports T2VA, FL2VA, and Ref2VA. The FastH3 Preview profile
is a separate T2VA configuration.
`launch_backend.sh` checks that it is inside an `srun` step, preserves
`CUDA_VISIBLE_DEVICES`, and replaces itself with the backend process. It does
not allocate GPUs, kill existing processes, or source a personal credentials
file. The local `dreamverse-deploy` helper is not suitable for a shared Slurm
cluster because it kills processes by physical GPU and port.
## Prepare and allocate
Keep the checkout, weights, outputs, and logs on storage visible to the compute
node. Source installation is documented in the [GPU guide](../../../../docs/getting_started/installation/gpu.md).
On ARM64 GB200 use CUDA 13, a matching PyTorch build, and kernels built for
`sm_100`; the DGX Spark `sm_121` kernel image is not the GB200 image.
The repository's image workflow publishes an ARM64 GB200 variant under
`ghcr.io/hao-ai-lab/fastvideo/fastvideo-dev:py3.12-cuda13.0.0-sm100-latest`.
Resolve that tag to a digest for reproducible runs. If your compute nodes use
Pyxis/Enroot, pass the approved image or a prepared SquashFS file to
`srun --container-image`, with explicit mounts for your checkout and model cache.
The Dreamverse-specific Docker images are currently AMD64-only.
For the Slinky customer partition, a bounded allocation is:
```bash
salloc --account=customer --qos=normal --partition=hpc-rack-1 \
--nodes=1 --ntasks=1 --cpus-per-task=72 --gres=gpu:nvidia_gb200:4 \
--mem=800G --time=02:00:00 --job-name=dreamverse
srun --ntasks=1 --pty bash
```
Wait for Slurm to grant the allocation before entering the compute step. A
successful SSH login does not grant GPU resources. Inspect pending capacity
with `squeue -u "$USER" --start`; do not attach to another user's job.
The checkpoint includes duplicate release layouts. Download the diffusers
components needed by both base and reference pipelines, rather than the whole
repository (about 210 GB versus about 498 GB at revision
`42ed227ee7df40d41602854ae760620d6eb651fe`):
```bash
hf download MiniMaxAI/MiniMax-H3 \
--revision 42ed227ee7df40d41602854ae760620d6eb651fe \
--include model_index.json --include modular_model_index.json \
--include 'audio_scheduler/*' --include 'audio_vae/*' \
--include 'processor/*' --include 'scheduler/*' \
--include 'text_encoder/*' --include 'tokenizer/*' \
--include 'transformer/*' --include 'transformer_ref/*' --include 'vae/*' \
--local-dir /path/to/models/MiniMax-H3
```
The GPU environment needs `fastvideo[dreamverse]`, the Dreamverse workspace
package, and FFmpeg with H.264/AAC encoders. In a prepared FastVideo image,
install the checked-out code and its Dreamverse dependencies in that image's
Python environment. Keep its matching CUDA/PyTorch/kernel stack intact.
## Start and connect
From the checked-out repository inside the allocated step:
```bash
export DREAMVERSE_PYTHON=/path/to/environment/bin/python
export DREAMVERSE_MODEL_PATH=/path/to/models/MiniMax-H3
export FASTVIDEO_DREAMVERSE_HOME=/path/to/persistent/dreamverse-state
bash apps/dreamverse/scripts/slurm/launch_backend.sh
```
The default backend binds port 8009 on the private compute node. Connect through
the login node from your laptop, replacing `COMPUTE_NODE_IP` with the allocated
node's `NodeAddr` from `scontrol show node`:
```bash
ssh -N -L 8009:COMPUTE_NODE_IP:8009 USER@LOGIN_NODE
```
In another laptop terminal, run the frontend from your local checkout:
```bash
cd apps/dreamverse/web
BACKEND_HOST=127.0.0.1 BACKEND_PORT=8009 npm run dev
```
Open `http://localhost:5299`. `/healthz` reports the server process; `/readyz`
reports model readiness. Full H3 loads and generates more slowly than the
Preview adapter. Keep prompt enhancement disabled in the UI unless the
runtime has the selected provider's credentials.
## Verify and stop
Check all three modes with small, valid user-owned assets. Capture the selected
mode and assets, WebSocket errors or completion events, the generated video and
audio, and GPU memory usage. Also verify actionable validation errors and
backward compatibility with clients that omit `generation_mode`.
Use the frontend Playwright instructions in the
[Dreamverse development guide](../../../../docs/contributing/dreamverse-development.md)
against the forwarded backend. A mock-server demo validates UI and protocol
behavior; it is not evidence of GPU generation.
Stop the backend with Ctrl-C, exit the compute step, and release your allocation.
For a detached allocation, use `scancel YOUR_JOB_ID`. Cancel a pending demo job
when it is no longer needed; do not leave an unattended reservation queued.
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Run inside an existing Slurm step. Slurm owns the GPU visibility and lifetime.
set -euo pipefail
if [[ -z "${SLURM_JOB_ID:-}" || -z "${SLURM_STEP_ID:-}" ]]; then
echo "Run this launcher inside an allocated Slurm step (srun), not on the login node." >&2
exit 2
fi
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "${script_dir}/../../../.." && pwd)"
python_bin="${DREAMVERSE_PYTHON:-${repo_root}/.venv/bin/python}"
if [[ ! -x "${python_bin}" ]]; then
echo "Set DREAMVERSE_PYTHON to a Python environment with fastvideo[dreamverse] installed." >&2
exit 2
fi
export DREAMVERSE_MODEL_ID="${DREAMVERSE_MODEL_ID:-full-h3}"
export DREAMVERSE_SP_SIZE="${DREAMVERSE_SP_SIZE:-4}"
export FASTVIDEO_GPU_COUNT="${FASTVIDEO_GPU_COUNT:-${DREAMVERSE_SP_SIZE}}"
export FASTVIDEO_ENABLE_STARTUP_WARMUP="${FASTVIDEO_ENABLE_STARTUP_WARMUP:-0}"
export ENABLE_TORCH_COMPILE="${ENABLE_TORCH_COMPILE:-0}"
export STREAM_MODE="${STREAM_MODE:-av_fmp4}"
export PYTHONPATH="${repo_root}/apps/dreamverse:${repo_root}${PYTHONPATH:+:${PYTHONPATH}}"
export PYTHONUNBUFFERED=1
"${python_bin}" - <<'PY'
import os
import shutil
import torch
expected = int(os.environ["DREAMVERSE_SP_SIZE"])
visible = torch.cuda.device_count()
if expected < 1 or visible < expected:
raise SystemExit(f"The Slurm step exposes {visible} GPUs; DREAMVERSE_SP_SIZE requires {expected}.")
ffmpeg = os.environ.get("FASTVIDEO_FFMPEG_BIN", "ffmpeg")
if not shutil.which(ffmpeg):
raise SystemExit("FFmpeg is missing; install it in the compute environment or set FASTVIDEO_FFMPEG_BIN.")
print(f"Slurm job {os.environ['SLURM_JOB_ID']}: {visible} visible GPUs; using {expected} per worker")
for index in range(expected):
properties = torch.cuda.get_device_properties(index)
print(f" GPU {index}: {properties.name}, {properties.total_memory / 2**30:.1f} GiB")
PY
cd "${repo_root}"
exec "${python_bin}" -m dreamverse.server_entry \
--host "${DREAMVERSE_BIND_HOST:-0.0.0.0}" \
--port "${DREAMVERSE_BACKEND_PORT:-8009}" "$@"
@@ -0,0 +1,126 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "@playwright/test";
const imagePath = path.resolve("public/k2.png");
const framePrompt = "A paper fox walks through a sunlit forest, gentle birdsong.";
function makeAudio(sampleRate = 8000, seconds = 1): Buffer {
const sampleCount = sampleRate * seconds;
const bytes = Buffer.alloc(44 + sampleCount * 2);
bytes.write("RIFF", 0); bytes.writeUInt32LE(bytes.length - 8, 4); bytes.write("WAVEfmt ", 8);
bytes.writeUInt32LE(16, 16); bytes.writeUInt16LE(1, 20); bytes.writeUInt16LE(1, 22);
bytes.writeUInt32LE(sampleRate, 24); bytes.writeUInt32LE(sampleRate * 2, 28);
bytes.writeUInt16LE(2, 32); bytes.writeUInt16LE(16, 34); bytes.write("data", 36);
bytes.writeUInt32LE(sampleCount * 2, 40);
for (let i = 0; i < sampleCount; i++) bytes.writeInt16LE(Math.round(Math.sin(i * 440 * 2 * Math.PI / sampleRate) * 1000), 44 + i * 2);
return bytes;
}
test.describe("generation modes through the mock runtime", () => {
for (const mode of ["t2va", "fl2va", "ref2va"] as const) {
test(`${mode} sends validated assets and plays a clearly labeled sample`, async ({ page, request }, testInfo) => {
const response = await request.get("/generation-capabilities");
const capabilities = response.ok() ? await response.json() : {};
test.skip(capabilities.mock !== true, "This test uses the CPU mock runtime; it must not silently allocate a real GPU.");
const sent: Record<string, any>[] = [];
const received: Record<string, any>[] = [];
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => { if (typeof payload === "string") { try { sent.push(JSON.parse(payload)); } catch {} } });
socket.on("framereceived", ({ payload }) => { if (typeof payload === "string") { try { received.push(JSON.parse(payload)); } catch {} } });
});
await page.goto("/");
await expect(page.getByText(/Demo runtime · Sample playback only/)).toBeVisible();
const modeSelect = page.getByRole("combobox", { name: "Generation mode" });
const modeLabel = mode === "ref2va" ? "Ref2VA" : mode.toUpperCase();
await modeSelect.click();
await page.getByRole("option", { name: modeLabel, exact: true }).click();
await expect(modeSelect).toHaveText(modeLabel);
await page.getByLabel("Continuation prompt").fill(framePrompt);
const uploadedIds: string[] = [];
page.on("response", async (uploadResponse) => {
if (uploadResponse.request().method() === "POST" && uploadResponse.url().endsWith("/assets") && uploadResponse.ok()) {
const asset = await uploadResponse.json().catch(() => null);
if (asset?.asset_id) uploadedIds.push(asset.asset_id);
}
});
try {
if (mode === "fl2va") {
await expect(page.getByRole("button", { name: "Generate", exact: true })).toBeDisabled();
await page.locator('input[type="file"]').setInputFiles([
{ name: "first-frame.png", mimeType: "image/png", buffer: await readFile(imagePath) },
{ name: "last-frame.png", mimeType: "image/png", buffer: await readFile(imagePath) },
]);
await expect(page.getByRole("option", { name: "first-frame.png", exact: true }).first()).toBeAttached();
await page.getByRole("combobox", { name: "First frame", exact: true }).selectOption({ label: "first-frame.png" });
await expect(page.getByRole("button", { name: "Generate", exact: true })).toBeEnabled();
await page.getByRole("combobox", { name: "Last frame", exact: true }).selectOption({ label: "last-frame.png" });
}
if (mode === "ref2va") {
const video = execFileSync(process.env.FASTVIDEO_FFMPEG_BIN || "ffmpeg", ["-v", "error", "-f", "lavfi", "-i", "color=c=royalblue:s=64x64:r=8", "-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "frag_keyframe+empty_moov", "-f", "mp4", "pipe:1"]);
await page.locator('input[type="file"]').setInputFiles([
{ name: "subject.png", mimeType: "image/png", buffer: await readFile(imagePath) },
{ name: "motion.mp4", mimeType: "video/mp4", buffer: video },
{ name: "sound.wav", mimeType: "audio/wav", buffer: makeAudio() },
]);
await expect(page.getByRole("button", { name: "Add sound.wav as reference" })).toBeEnabled();
await page.getByRole("button", { name: "Add sound.wav as reference" }).click();
await expect(page.getByRole("button", { name: "Generate", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Add subject.png as reference" }).click();
await page.getByRole("button", { name: "Add motion.mp4 as reference" }).click();
await page.getByRole("button", { name: "Move sound.wav down" }).click();
const names = await page.getByRole("list", { name: "Ordered references" }).locator("li p.font-medium").allTextContents();
expect(names).toEqual(["subject.png", "sound.wav", "motion.mp4"]);
}
await page.screenshot({ path: testInfo.outputPath(`${mode}-inputs.png`), fullPage: true });
await page.getByRole("button", { name: "Generate", exact: true }).click();
await expect.poll(() => sent.find((item) => item.type === "session_init_v2")?.generation_mode).toBe(mode);
const init = sent.find((item) => item.type === "session_init_v2")!;
expect(init.conditioning_assets.map((item: any) => item.role)).toEqual(mode === "t2va" ? [] : mode === "fl2va" ? ["first_frame", "last_frame"] : ["reference", "reference", "reference"]);
if (mode === "ref2va") expect(init.conditioning_assets.map((item: any) => item.asset_id)).toEqual([uploadedIds[0], uploadedIds[2], uploadedIds[1]]);
await expect.poll(() => received.find((item) => item.type === "gpu_assigned")?.generation_mode).toBe(mode);
await expect.poll(() => received.some((item) => item.type === "media_segment_complete")).toBe(true);
await expect(page.getByText(/Demo runtime · Sample playback only/)).toBeVisible();
await expect(modeSelect).toHaveCount(0);
await expect.poll(async () => page.locator("video:visible").first().evaluate((element: HTMLVideoElement) => element.readyState)).toBeGreaterThanOrEqual(2);
await page.screenshot({ path: testInfo.outputPath(`${mode}-playback.png`), fullPage: true });
if (mode === "ref2va") {
await page.getByRole("button", { name: "Toggle sidebar" }).click();
await page.getByRole("button", { name: "New project", exact: true }).click();
await expect(modeSelect).toHaveText("T2VA");
await modeSelect.click();
await page.getByRole("option", { name: "FL2VA", exact: true }).click();
await expect(modeSelect).toHaveText("FL2VA");
await page.getByRole("combobox", { name: "First frame", exact: true }).selectOption({ label: "subject.png" });
await expect(page.getByRole("combobox", { name: "Last frame", exact: true })).toHaveValue("");
await page.getByLabel("Continuation prompt").fill("The paper fox explores a new scene.");
await page.getByRole("button", { name: "Generate", exact: true }).click();
await expect.poll(() => sent.find((item) => item.type === "project_init_v1")?.generation_mode).toBe("fl2va");
const secondProject = sent.find((item) => item.type === "project_init_v1")!;
expect(secondProject.conditioning_assets).toEqual([{ asset_id: uploadedIds[0], role: "first_frame" }]);
expect(sent.filter((item) => item.type === "session_init_v2")).toHaveLength(1);
await expect.poll(() => received.filter((item) => item.type === "media_segment_complete").length).toBeGreaterThan(1);
}
} finally {
await page.close();
for (const id of uploadedIds) await request.delete(`/assets/${id}`);
}
});
}
test("proxies a media upload larger than Next's default 10 MiB body limit", async ({ request }) => {
const response = await request.get("/generation-capabilities");
const capabilities = response.ok() ? await response.json() : {};
test.skip(capabilities.mock !== true, "Requires the local mock runtime.");
const audio = makeAudio(192000, 29);
expect(audio.length).toBeGreaterThan(10 * 1024 * 1024);
const upload = await request.post("/assets", {
headers: { "Content-Type": "audio/wav", "X-Asset-Name": "large-proxy-check.wav" },
data: audio,
});
expect(upload.status()).toBe(201);
const asset = await upload.json();
try { expect(asset.size).toBe(audio.length); } finally { await request.delete(`/assets/${asset.asset_id}`); }
});
});
+14
View File
@@ -9,6 +9,8 @@ const configDir = path.dirname(fileURLToPath(import.meta.url));
const staticExport = process.env.NEXT_OUTPUT_EXPORT === '1';
const nextConfig: NextConfig = {
// Next 15.5 name for the dev rewrite-proxy body limit; Next 16 renames it to `proxyClientMaxBodySize`.
experimental: { middlewareClientMaxBodySize: 100 * 1024 * 1024 },
...(staticExport ? { output: 'export' as const } : {}),
...(staticExport ? { images: { unoptimized: true } } : {}),
outputFileTracingRoot: path.join(configDir, '..', '..', '..'),
@@ -38,6 +40,18 @@ const nextConfig: NextConfig = {
source: '/router/:path*',
destination: `${backendUrl}/router/:path*`
},
{
source: '/generation-capabilities',
destination: `${backendUrl}/generation-capabilities`,
},
{
source: '/assets',
destination: `${backendUrl}/assets`,
},
{
source: '/assets/:path*',
destination: `${backendUrl}/assets/:path*`,
},
{
source: '/prompt-system-config',
destination: `${backendUrl}/prompt-system-config`,
@@ -589,6 +589,7 @@ describe.skip('App websocket integration', () => {
});
const initMessage = outbound.find((message) => message.type === 'session_init_v2');
expect(initMessage.generation_mode).toBe('t2va');
expect(initMessage.preset_id).toBe('test_preset');
expect(initMessage.curated_prompts).toEqual(['segment one', 'segment two']);
expect(initMessage.enhancement_enabled).toBe(true);
@@ -597,6 +598,42 @@ describe.skip('App websocket integration', () => {
expect(initMessage.initial_rollout_prompt).toBe('');
});
it('sends the selected generation mode and locks it after session start', async () => {
const outbound: any[] = [];
server.on('connection', (socket) => {
socket.on('message', (rawMessage) => {
outbound.push(JSON.parse(rawMessage as string));
});
});
const user = userEvent.setup();
render(<Page />);
const modeSelect = await screen.findByRole('combobox', { name: 'Generation mode' });
expect(modeSelect).toHaveTextContent('T2VA');
await user.click(modeSelect);
await user.click(await screen.findByRole('option', { name: 'FL2VA' }));
expect(modeSelect).toHaveTextContent('FL2VA');
expect(modeSelect).toHaveAttribute(
'title',
'First/last frames to video + audio. Start from a first frame image. Add an optional last frame to guide the ending.',
);
const generateButton = await screen.findByRole('button', { name: 'Generate' });
await waitFor(() => expect(generateButton).toBeEnabled());
await user.click(generateButton);
await waitFor(() => {
expect(outbound.some((message) => message.type === 'session_init_v2')).toBe(true);
});
const initMessage = outbound.find((message) => message.type === 'session_init_v2');
expect(initMessage.generation_mode).toBe('fl2va');
expect(screen.queryByRole('combobox', { name: 'Generation mode' }))
.not.toBeInTheDocument();
});
it('starts a streaming session from a custom initial prompt without using curated prompts', async () => {
const outbound: any[] = [];
server.on('connection', (socket) => {
+119 -8
View File
@@ -5,6 +5,7 @@ import { Download, Share2 } from "lucide-react";
import DevtoolsShell from "@/components/devtools/DevtoolsShell";
import MonitorPage from "@/components/MonitorPage";
import ChatBar from "@/components/ChatBar";
import AssetList from "@/components/AssetList";
import SessionTimeoutModal from "@/components/SessionTimeoutModal";
import Sidebar from "@/components/Sidebar";
import Header from "@/components/Header";
@@ -13,10 +14,13 @@ import Workspace from "@/components/Workspace";
import { saveProject, saveProjectMetadata, listProjects, loadProjectClips, deleteProject, pruneOldProjects, type StoredProject, type StoredClip } from "@/lib/projectStorage";
import { isInfrastructureError } from "@/lib/ws/reducer";
import { useStore } from "@/hooks/useStore";
import { useAssetLibrary } from "@/hooks/useAssetLibrary";
import { useGenerationCapabilities } from "@/hooks/useGenerationCapabilities";
import { resolveDevtoolsMode } from "@/lib/devtoolsMode";
import { createAvPipeline, DEFAULT_AV_MIME } from "@/lib/media/avPipeline";
import { remuxArchivedFmp4Segments } from "@/lib/media/fmp4Remux";
import { DEFAULT_CUSTOM_PRESET_ID, parseStoryPresets, sanitizePresetId } from "@/lib/presets";
import { DEFAULT_GENERATION_MODE, buildGenerationInitFields, validateGenerationInputs, type GenerationMode, type GenerationInitFields, type GenerationAsset } from "@/lib/generationMode";
import {
buildRewritePromptWindowSnapshot,
buildRewritePromptWindowSnapshotFromPrompts,
@@ -341,6 +345,19 @@ export default function Page() {
const [isMobileShareCapable, setIsMobileShareCapable] = useState(false);
const [videoMuted, setVideoMuted] = useState(true);
const [timeoutModalOpen, setTimeoutModalOpen] = useState(false);
const [generationMode, setGenerationMode] = useState<GenerationMode>(DEFAULT_GENERATION_MODE);
const assetLibrary = useAssetLibrary();
const { capabilities, capabilityNotice, refreshCapabilities } = useGenerationCapabilities();
const joiningRef = useRef(false);
const activeGenerationRef = useRef<{ fields: GenerationInitFields; assets: GenerationAsset[]; mock: boolean } | null>(null);
const generationInputError = validateGenerationInputs(generationMode, assetLibrary.conditioningAssets, assetLibrary.assets);
const generationSupported = capabilities.modes.includes(generationMode);
const generationInputsValid = !generationInputError && generationSupported && !assetLibrary.uploading;
function changeGenerationMode(mode: GenerationMode) {
if (sessionStore.get().sessionStarted || joiningRef.current || !capabilities.modes.includes(mode)) return;
setGenerationMode(mode);
assetLibrary.clearConditioning();
}
useEffect(() => {
setIsMobileShareCapable(typeof navigator.canShare === "function" && window.matchMedia("(pointer: coarse)").matches);
}, []);
@@ -391,7 +408,7 @@ export default function Page() {
// --- Derived values ---
const canStartSession = !projectResetPending && (canJoinSession || Boolean(normalizeInitialPrompt(livePromptDraft as string)));
const canStartSession = generationInputsValid && !projectResetPending && (canJoinSession || Boolean(normalizeInitialPrompt(livePromptDraft as string)));
const currentClipLabel = useMemo(() => {
if ((activeClip as Record<string, any>)?.label) return (activeClip as Record<string, any>).label;
@@ -747,7 +764,11 @@ export default function Page() {
function recoverFailedSessionStart(notice: string) {
const restoredDraft = normalizeInitialPrompt(pendingInitialPromptRef.current);
resetToLobbyState();
if (wsRef.current) {
detachAndCloseWebSocket(wsRef.current);
wsRef.current = null;
}
resetToLobbyState({ preserveSessionNotice: true });
clearPendingProjectPointers();
pendingInitialPromptRef.current = "";
sessionStore.patch({
@@ -1702,6 +1723,10 @@ export default function Page() {
function resetToLobbyState({ preserveSessionNotice = false, preservePlayback = false } = {}) {
setVideoMuted(true);
if (!preserveSessionNotice) {
setGenerationMode(DEFAULT_GENERATION_MODE);
assetLibrary.clearConditioning();
}
clearCountdownInterval();
pendingInitialPromptRef.current = "";
sessionStore.patch({
@@ -1736,6 +1761,8 @@ export default function Page() {
function resetToProjectLobbyState() {
setVideoMuted(true);
setGenerationMode(DEFAULT_GENERATION_MODE);
assetLibrary.clearConditioning();
pendingInitialPromptRef.current = "";
sessionStore.patch({
sessionStarted: false,
@@ -1764,6 +1791,7 @@ export default function Page() {
setSeedPrompts(segmentPrompts);
return {
type,
...(activeGenerationRef.current?.fields || buildGenerationInitFields(generationMode, assetLibrary.conditioningAssets, assetLibrary.assets)),
preset_id: getInitialPresetId(),
preset_label: getInitialPresetLabel(),
curated_prompts: segmentPrompts,
@@ -1812,6 +1840,12 @@ export default function Page() {
return;
}
if (decoded.kind !== "json") return;
if (decoded.data?.type === "error" && sessionStore.get().sessionStarted
&& (!sessionStore.get().gpuAssigned || decoded.data.error_code === "invalid_generation_input")) {
const message = typeof decoded.data.message === "string" ? decoded.data.message : "The generation inputs were rejected. Check the mode and selected assets.";
recoverFailedSessionStart(message);
return;
}
if (decoded.data?.type === "error" && isInfrastructureError(decoded.data)) {
const message = typeof decoded.data?.message === "string" && decoded.data.message.trim()
? decoded.data.message.trim()
@@ -1937,9 +1971,15 @@ export default function Page() {
}
}
function beginProjectLocally({ force = false } = {}) {
function beginProjectLocally({ force = false, mockRuntime = capabilities.mock === true } = {}) {
if (!force && !canStartSession) return;
if (!generationInputsValid) return false;
if (sessionStore.get().sessionStarted || sessionStore.get().projectResetPending) return false;
activeGenerationRef.current = {
fields: buildGenerationInitFields(generationMode, assetLibrary.conditioningAssets, assetLibrary.assets),
assets: assetLibrary.assets.filter((asset) => assetLibrary.conditioningAssets.some((item) => item.asset_id === asset.asset_id)),
mock: mockRuntime,
};
setTimeoutModalOpen(false);
// Unmute during the user gesture so iOS Safari permits audio playback.
setVideoMuted(false);
@@ -1999,12 +2039,43 @@ export default function Page() {
}
async function joinSession({ force = false } = {}) {
if (joiningRef.current || sessionStore.get().sessionStarted) return;
if (generationInputError || assetLibrary.uploading) {
showPreSessionNotice(generationInputError || "Wait for the asset upload to finish.");
return;
}
joiningRef.current = true;
try {
await startGenerationSession({ force });
} finally {
joiningRef.current = false;
}
}
async function startGenerationSession({ force = false } = {}) {
sessionStore.patch({ sessionNotice: "" });
streamStore.patch({ loadingAnimation: true });
const currentCapabilities = await refreshCapabilities();
if (!currentCapabilities.modes.includes(generationMode)) {
streamStore.patch({ loadingAnimation: false });
showPreSessionNotice(`${generationMode.toUpperCase()} is unavailable on this runtime. Connect a full H3 runtime or choose a supported mode.`);
return;
}
const assetProblem = await assetLibrary.verifySelectedAssets();
if (assetProblem) {
streamStore.patch({ loadingAnimation: false });
showPreSessionNotice(assetProblem);
return;
}
if (
wsRef.current
&& wsRef.current.readyState === WebSocket.OPEN
&& sessionStore.get().connected
) {
if (!beginProjectLocally({ force })) return;
if (!beginProjectLocally({ force, mockRuntime: currentCapabilities.mock === true })) {
streamStore.patch({ loadingAnimation: false });
return;
}
sendProjectInitMessage();
return;
}
@@ -2017,7 +2088,7 @@ export default function Page() {
showPreSessionNotice(probe.notice);
return;
}
if (!beginProjectLocally({ force })) {
if (!beginProjectLocally({ force, mockRuntime: currentCapabilities.mock === true })) {
streamStore.patch({ loadingAnimation: false });
sessionStore.patch({ connecting: false });
return;
@@ -2044,6 +2115,10 @@ export default function Page() {
createdAt: currentProjectCreatedAtRef.current || Date.now(),
lastThumbnail: currentThumbnail,
promptEvents: [...(rewriteStore.get().promptEvents as Record<string, unknown>[])],
generationMode: activeGenerationRef.current?.fields.generation_mode || DEFAULT_GENERATION_MODE,
conditioningAssets: activeGenerationRef.current?.fields.conditioning_assets || [],
assets: activeGenerationRef.current?.assets || [],
mock: activeGenerationRef.current?.mock === true,
};
const clips: StoredClip[] = (streamStore.get().completedClips as any[])
.filter((clip: any) => clip?.blob instanceof Blob)
@@ -2478,6 +2553,24 @@ export default function Page() {
// --- Render ---
const conditioningPanel = generationMode !== "t2va" && !sessionStarted && !sessionExpired ? (
<AssetList
mode={generationMode}
assets={assetLibrary.assets}
conditioning={assetLibrary.conditioningAssets}
locked={Boolean(loadingAnimation || projectResetPending)}
uploading={assetLibrary.uploading}
error={assetLibrary.assetError}
validationNotice={generationInputError}
onUpload={assetLibrary.uploadAssets}
onAssign={assetLibrary.assignAsset}
onRemove={assetLibrary.removeAsset}
onUnselect={assetLibrary.removeConditioning}
onMove={assetLibrary.moveConditioning}
onMissing={assetLibrary.checkAssetAvailability}
/>
) : null;
if (!runtimeReady) {
return null;
}
@@ -2501,13 +2594,17 @@ export default function Page() {
enhancementEnabled={enhancementEnabled as boolean}
autoExtensionEnabled={autoExtensionEnabled as boolean}
loopGenerationEnabled={loopGenerationEnabled as boolean}
canJoinSession={canJoinSession as boolean}
canJoinSession={canStartSession}
canSubmitContinuation={canSubmitContinuation}
editableMode={editableMode as boolean}
demoMode={demoMode as boolean}
editableCanJoin={editableCanJoin as boolean}
curatedPromptLimit={curatedPromptLimit as number}
maxCuratedPromptCount={maxCuratedPromptCount as number}
generationMode={generationMode}
supportedGenerationModes={capabilities.modes}
conditioningPanel={conditioningPanel}
onGenerationModeChange={changeGenerationMode}
onPresetChange={handlePresetSelectionChange}
onEnhancementToggle={handleEnhancementToggle}
onCuratedPromptLimitChange={handleCuratedPromptLimitChange}
@@ -2641,7 +2738,12 @@ export default function Page() {
/>
<Header timeLeft={headerTimeLeft} formatTime={formatTime} onToggleSidebar={() => setSidebarOpen((prev) => !prev)} />
<div className="relative flex flex-1 min-h-0 flex-col justify-center px-4 pb-2 sm:px-6 sm:pb-12">
<div className={cn(
"relative flex flex-1 min-h-0 flex-col px-4 pb-2 sm:px-6 sm:pb-12",
!isViewingMode && !showActiveProject && generationMode !== "t2va"
? "justify-start overflow-y-auto pt-4"
: "justify-center",
)}>
{isViewingMode && (
<>
{viewingSelectedClip && (
@@ -2680,6 +2782,8 @@ export default function Page() {
/>
</section>
<motion.div layout="position" className="mx-auto w-full max-w-2xl shrink-0" transition={{ type: "spring", stiffness: 200, damping: 25 }}>
{viewingProject?.project.mock && <p className="mb-2 text-center text-xs text-violet-600 dark:text-violet-300">Demo sample · This saved clip was not generated by an AI model.</p>}
{viewingProject?.project.generationMode && <p className="mb-3 text-center text-xs text-muted-foreground">{viewingProject.project.generationMode.toUpperCase()} · {viewingProject.project.conditioningAssets?.length || 0} saved references. Uploaded originals may expire; your saved video remains available.</p>}
<ChatBar sessionStarted={false} viewingReadOnly={true} onStartNewProject={handleStartNewProject} onBackFromViewing={closeViewingProject} />
</motion.div>
</>
@@ -2759,7 +2863,7 @@ export default function Page() {
</section>
<AnimatePresence>
{!showActiveProject && (
{!showActiveProject && generationMode === "t2va" && (
<motion.div
key="hero-tagline"
initial={{ opacity: 0 }}
@@ -2785,6 +2889,12 @@ export default function Page() {
sessionExpired={sessionExpired as boolean}
sessionNotice={sessionNotice as string}
projectResetPending={projectResetPending as boolean}
generationMode={generationMode}
supportedGenerationModes={capabilities.modes}
generationInputsValid={generationInputsValid}
capabilityNotice={!generationSupported ? `${generationMode.toUpperCase()} is unavailable on this runtime.` : capabilityNotice}
mockRuntime={capabilities.mock}
conditioningPanel={conditioningPanel}
onPresetGenerate={handlePresetGenerate}
onContinuationInput={handleLivePromptInput}
onContinuationKeydown={handleLivePromptKeydown}
@@ -2792,6 +2902,7 @@ export default function Page() {
onSubmitContinuation={submitLivePrompt}
onLeave={leaveSession}
onStartNewProject={handleStartNewProject}
onGenerationModeChange={changeGenerationMode}
onSpeechTranscript={handleLivePromptSpeechTranscript}
onSpeechInterimChange={handleLivePromptSpeechInterim}
/>
@@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import AssetList from "./AssetList";
import type { GenerationAsset } from "@/lib/generationMode";
const frame: GenerationAsset = { asset_id: "first", kind: "image", name: "frame.png", mime_type: "image/png", size: 2000, url: "/assets/first" };
const video: GenerationAsset = { asset_id: "video", kind: "video", name: "motion.mp4", mime_type: "video/mp4", size: 2000, url: "/assets/video" };
function props() {
return { assets: [frame, video], onUpload: vi.fn(), onAssign: vi.fn(), onRemove: vi.fn(), onUnselect: vi.fn(), onMove: vi.fn(), onMissing: vi.fn() };
}
describe("Asset List", () => {
it("uploads to the library and assigns images to endpoint roles", async () => {
const callbacks = props();
const user = userEvent.setup();
render(<AssetList {...callbacks} mode="fl2va" conditioning={[]} />);
await user.selectOptions(screen.getByRole("combobox", { name: "First frame" }), "first");
expect(callbacks.onAssign).toHaveBeenCalledWith("first", "first_frame");
expect(screen.getByRole("combobox", { name: "Last frame" })).toHaveValue("");
expect(screen.queryByRole("option", { name: "motion.mp4" })).not.toBeInTheDocument();
const file = new File(["image"], "new.png", { type: "image/png" });
await user.upload(screen.getByLabelText("Upload assets", { selector: "input" }), file);
expect(callbacks.onUpload).toHaveBeenCalledWith([file]);
});
it("exposes accessible ordering and removal controls for multimodal references", async () => {
const callbacks = props();
const user = userEvent.setup();
render(<AssetList {...callbacks} mode="ref2va" conditioning={[{ asset_id: "first", role: "reference" }, { asset_id: "video", role: "reference" }]} />);
await user.click(screen.getByRole("button", { name: "Move motion.mp4 up" }));
expect(callbacks.onMove).toHaveBeenCalledWith(1, 0);
await user.click(screen.getByRole("button", { name: "Unselect frame.png" }));
expect(callbacks.onUnselect).toHaveBeenCalledWith(0);
expect(screen.getByRole("button", { name: "Move frame.png up" })).toBeDisabled();
});
it("locks uploads and assignments while starting generation", () => {
render(<AssetList {...props()} mode="fl2va" conditioning={[]} locked />);
expect(screen.getByRole("button", { name: "Upload assets" })).toBeDisabled();
expect(screen.getByRole("combobox", { name: "First frame" })).toBeDisabled();
});
});
@@ -0,0 +1,154 @@
"use client";
import { useRef, useState } from "react";
import { ArrowDown, ArrowUp, AudioLines, Check, GripVertical, ImagePlus, Plus, Trash2, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { NativeSelect } from "@/components/ui/native-select";
import { cn } from "@/lib/utils";
import type { ConditioningAsset, ConditioningRole, GenerationAsset, GenerationMode } from "@/lib/generationMode";
interface AssetListProps {
mode: GenerationMode;
assets: GenerationAsset[];
conditioning: ConditioningAsset[];
locked?: boolean;
uploading?: boolean;
error?: string;
validationNotice?: string | null;
onUpload: (files: File[]) => void;
onAssign: (assetId: string, role: ConditioningRole) => void;
onRemove: (assetId: string) => void;
onUnselect: (index: number) => void;
onMove: (from: number, to: number) => void;
onMissing: (assetId: string) => void;
}
function AssetPreview({ asset, onMissing, compact = false }: {
asset: GenerationAsset;
onMissing: (assetId: string) => void;
compact?: boolean;
}) {
const [previewFailed, setPreviewFailed] = useState(false);
function previewError() {
setPreviewFailed(true);
onMissing(asset.asset_id);
}
const className = cn("h-full w-full object-cover", asset.missing && "opacity-25");
if (asset.missing) return <span className="p-2 text-center text-[10px] text-muted-foreground">Upload again</span>;
if (previewFailed) return <span className="p-2 text-center text-[10px] text-muted-foreground">Preview unavailable</span>;
if (asset.kind === "image") {
return <img src={asset.url} alt={asset.name} className={className} onError={previewError} />;
}
if (asset.kind === "video") {
return <video src={asset.url} aria-label={`Preview ${asset.name}`} className={className} muted playsInline controls={!compact} preload="metadata" onError={previewError} />;
}
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-violet-500/10 p-2 text-violet-500">
<AudioLines className="size-6" />
{!compact && <audio src={asset.url} aria-label={`Preview ${asset.name}`} controls preload="metadata" className="h-6 w-full min-w-0" onError={previewError} />}
</div>
);
}
/** A reusable library/picker. The parent asset store owns uploads and selection. */
export default function AssetList({
mode, assets, conditioning, locked = false, uploading = false, error = "", validationNotice,
onUpload, onAssign, onRemove, onUnselect, onMove, onMissing,
}: AssetListProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [libraryOpen, setLibraryOpen] = useState(true);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const disabled = locked || uploading;
const imageAssets = assets.filter((asset) => asset.kind === "image");
return (
<section aria-label="Asset List" className="overflow-hidden rounded-2xl border border-input bg-card/70 shadow-sm backdrop-blur-sm">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<h2 className="text-xs font-semibold tracking-wide">{mode === "fl2va" ? "Frame guidance" : "Reference sequence"}</h2>
<p className="mt-0.5 text-[11px] text-muted-foreground">{locked ? "Inputs are locked for this project." : mode === "fl2va" ? "Choose your opening image and, optionally, the ending." : "Arrange references in the order you want the model to read them."}</p>
</div>
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => inputRef.current?.click()} className="shrink-0 gap-1.5 rounded-full text-xs">
<Upload className="size-3.5" />{uploading ? "Uploading…" : "Upload assets"}
</Button>
<input ref={inputRef} type="file" aria-label="Upload assets" className="sr-only" multiple accept={mode === "fl2va" ? "image/*" : "image/*,video/*,audio/*"} disabled={disabled} onChange={(event) => {
const files = Array.from(event.target.files || []);
if (files.length) onUpload(files);
event.target.value = "";
}} />
</div>
<div className="max-h-[min(42vh,350px)] overflow-y-auto px-4 pb-3">
{mode === "fl2va" ? (
<div className="grid grid-cols-2 gap-3">
{(["first_frame", "last_frame"] as const).map((role) => {
const label = role === "first_frame" ? "First frame" : "Last frame";
const assetId = conditioning.find((item) => item.role === role)?.asset_id || "";
const asset = assets.find((item) => item.asset_id === assetId);
return (
<div key={role} className="overflow-hidden rounded-xl border border-input bg-background/40 p-2">
<div className="flex h-20 items-center justify-center overflow-hidden rounded-lg bg-muted/60 sm:h-24">
{asset ? <AssetPreview key={asset.asset_id} asset={asset} onMissing={onMissing} /> : <ImagePlus className="size-6 text-muted-foreground/45" />}
</div>
<label htmlFor={`asset-${role}`} className="mb-1 mt-2 block text-[11px] font-medium">{label} <span className="font-normal text-muted-foreground">{role === "first_frame" ? "· required" : "· optional"}</span></label>
<NativeSelect id={`asset-${role}`} aria-label={label} value={assetId} disabled={disabled} className="h-8 text-xs" onChange={(event) => onAssign(event.target.value, role)}>
<option value="">{imageAssets.length ? "Choose an image" : "Upload an image first"}</option>
{imageAssets.map((item) => <option key={item.asset_id} value={item.asset_id} disabled={item.missing}>{item.name}{item.missing ? " (upload again)" : ""}</option>)}
</NativeSelect>
</div>
);
})}
</div>
) : (
<>
{conditioning.length ? (
<ol aria-label="Ordered references" className="flex flex-col gap-2">
{conditioning.map((item, index) => {
const asset = assets.find((entry) => entry.asset_id === item.asset_id);
if (!asset) return null;
return (
<li key={`${item.asset_id}-${index}`} draggable={!disabled} onDragStart={() => setDragIndex(index)} onDragEnd={() => setDragIndex(null)} onDragOver={(event) => { if (!disabled && dragIndex !== null) event.preventDefault(); }} onDrop={(event) => { event.preventDefault(); if (!disabled && dragIndex !== null) onMove(dragIndex, index); setDragIndex(null); }} className={cn("flex items-center gap-2 rounded-xl border border-input bg-background/40 p-2", dragIndex === index && "opacity-50")}>
<GripVertical className="hidden size-3.5 shrink-0 text-muted-foreground/50 sm:block" aria-hidden />
<span className="w-4 text-center text-[11px] font-medium text-muted-foreground">{index + 1}</span>
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted"><AssetPreview asset={asset} onMissing={onMissing} compact /></div>
<div className="min-w-0 flex-1"><p className="truncate text-xs font-medium">{asset.name}</p><p className="text-[10px] capitalize text-muted-foreground">{asset.kind}{asset.missing ? " · unavailable" : ""}</p></div>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Move ${asset.name} up`} disabled={disabled || index === 0} onClick={() => onMove(index, index - 1)}><ArrowUp className="size-3.5" /></Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Move ${asset.name} down`} disabled={disabled || index === conditioning.length - 1} onClick={() => onMove(index, index + 1)}><ArrowDown className="size-3.5" /></Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Unselect ${asset.name}`} disabled={disabled} onClick={() => onUnselect(index)}><X className="size-3.5" /></Button>
</li>
);
})}
</ol>
) : (
<div className="flex items-center gap-3 rounded-xl border border-dashed border-input px-4 py-4 text-muted-foreground"><ImagePlus className="size-6 shrink-0 opacity-50" /><p className="text-xs">Add images, video, or audio from your asset library.<br /><span className="text-[11px] opacity-75">At least one image or video is required.</span></p></div>
)}
<p className="mt-2 text-[10px] text-muted-foreground">{conditioning.length}/12 selected · up to 9 images, 3 videos, 3 audio clips</p>
</>
)}
{assets.length > 0 && !locked && (
<div className="mt-3 border-t border-border/60 pt-2">
<button type="button" className="flex w-full items-center justify-between py-1 text-[11px] font-medium text-muted-foreground" aria-expanded={libraryOpen} onClick={() => setLibraryOpen(!libraryOpen)}><span>Asset library · {assets.length}</span><span>{libraryOpen ? "Hide" : "Show"}</span></button>
{libraryOpen && <div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{assets.map((asset) => {
const selected = conditioning.some((item) => item.asset_id === asset.asset_id);
return (
<div key={asset.asset_id} className={cn("overflow-hidden rounded-lg border bg-background/40", selected ? "border-sky-400/70" : "border-input")}>
<div className="flex h-20 items-center justify-center overflow-hidden bg-muted/50"><AssetPreview asset={asset} onMissing={onMissing} /></div>
<div className="flex items-center gap-1 p-1.5">
<div className="min-w-0 flex-1"><p title={asset.name} className="truncate text-[10px] font-medium">{asset.name}</p><p className="text-[9px] capitalize text-muted-foreground">{asset.missing ? "Upload again" : `${asset.kind} · ${(asset.size / 1024 / 1024).toFixed(1)} MB`}</p></div>
{mode === "ref2va" && <Button type="button" variant="ghost" size="icon-sm" className="size-7" aria-label={`Add ${asset.name} as reference`} disabled={disabled || selected || asset.missing || conditioning.length >= 12} onClick={() => onAssign(asset.asset_id, "reference")}>{selected ? <Check className="size-3.5 text-sky-500" /> : <Plus className="size-3.5" />}</Button>}
<Button type="button" variant="ghost" size="icon-sm" className="size-7 text-muted-foreground" aria-label={`Remove asset ${asset.name}`} disabled={disabled} onClick={() => onRemove(asset.asset_id)}><Trash2 className="size-3" /></Button>
</div>
</div>
);
})}
</div>}
</div>
)}
</div>
{!locked && <p className="px-4 pb-2 text-[10px] text-muted-foreground">Images ≤15 MiB / 16 MP{mode === "ref2va" ? " / 1:4–4:1 aspect ratio" : ""} · video/audio ≤100 MiB / 30 sec · video up to 4K · mono/stereo audio</p>}
{(error || validationNotice) && <p role={error ? "alert" : "status"} className={cn("border-t border-border/60 px-4 py-2 text-[11px]", error ? "bg-rose-500/5 text-rose-600 dark:text-rose-300" : "bg-amber-500/5 text-amber-700 dark:text-amber-300")}>{error || validationNotice}</p>}
</section>
);
}
@@ -0,0 +1,155 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import ChatBar from "./ChatBar";
// JSDOM does not implement the pointer/scroll APIs used by the Radix popup.
const domPolyfills = {
hasPointerCapture: () => false,
releasePointerCapture: () => {},
scrollIntoView: () => {},
};
const originalDescriptors = new Map<string, PropertyDescriptor | undefined>();
beforeAll(() => {
for (const [name, implementation] of Object.entries(domPolyfills)) {
originalDescriptors.set(name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name));
Object.defineProperty(HTMLElement.prototype, name, { configurable: true, value: implementation });
}
vi.stubGlobal("PointerEvent", MouseEvent);
});
afterAll(() => {
for (const [name, descriptor] of originalDescriptors) {
if (descriptor) Object.defineProperty(HTMLElement.prototype, name, descriptor);
else Reflect.deleteProperty(HTMLElement.prototype, name);
}
vi.unstubAllGlobals();
});
describe("ChatBar generation mode selection", () => {
it("places Mode and the prompt input inside the same composer", () => {
render(<ChatBar />);
const composer = screen.getByRole("group", { name: "Prompt composer" });
expect(within(composer).getByText("Mode", { exact: true })).toBeVisible();
expect(within(composer).getByRole("combobox", { name: "Generation mode" }))
.toBeVisible();
expect(within(composer).getByRole("textbox", { name: "Continuation prompt" }))
.toBeVisible();
expect(screen.queryByText("Generation mode", { exact: true }))
.not.toBeInTheDocument();
});
it("shows only mode abbreviations and keeps explanations in the tooltip", async () => {
const user = userEvent.setup();
render(<ChatBar />);
expect(screen.getByRole("combobox", { name: "Generation mode" }))
.toHaveAttribute("title", "Text to video + audio. Start with a text prompt; no reference asset is required.");
expect(screen.queryByText("Start with a text prompt; no reference asset is required."))
.not.toBeInTheDocument();
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
await user.click(screen.getByRole("combobox", { name: "Generation mode" }));
const menu = await screen.findByRole("listbox");
expect(within(menu).getAllByRole("option").map((option) => option.textContent))
.toEqual(["T2VA", "FL2VA", "Ref2VA"]);
});
it("defaults to T2VA and reports a selected mode", async () => {
const onGenerationModeChange = vi.fn();
const user = userEvent.setup();
render(
<ChatBar
canJoinSession
continuationDraft="A lighthouse in a storm"
onGenerationModeChange={onGenerationModeChange}
/>,
);
const modeSelect = screen.getByRole("combobox", { name: "Generation mode" });
expect(modeSelect).toHaveTextContent("T2VA");
await user.click(modeSelect);
await user.click(await screen.findByRole("option", { name: "Ref2VA" }));
expect(onGenerationModeChange).toHaveBeenCalledWith("ref2va");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("hides mode selection after generation starts", () => {
render(<ChatBar sessionStarted />);
expect(screen.queryByRole("combobox", { name: "Generation mode" }))
.not.toBeInTheDocument();
expect(within(screen.getByRole("group", { name: "Prompt composer" }))
.getByRole("textbox", { name: "Continuation prompt" })).toBeVisible();
});
it("disables mode selection and prompt editing while generation is busy", async () => {
const onGenerationModeChange = vi.fn();
const user = userEvent.setup();
render(<ChatBar isGenerating onGenerationModeChange={onGenerationModeChange} />);
const composer = screen.getByRole("group", { name: "Prompt composer" });
const modeSelect = within(composer).getByRole("combobox", { name: "Generation mode" });
expect(modeSelect).toBeDisabled();
expect(within(composer).getByRole("textbox", { name: "Continuation prompt" }))
.toBeDisabled();
await user.click(modeSelect);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(onGenerationModeChange).not.toHaveBeenCalled();
});
it("still submits the prompt with Enter from the combined composer", async () => {
const onGenerate = vi.fn();
const user = userEvent.setup();
render(<ChatBar canJoinSession continuationDraft="A lighthouse in a storm" onGenerate={onGenerate} />);
const input = within(screen.getByRole("group", { name: "Prompt composer" }))
.getByRole("textbox", { name: "Continuation prompt" });
await user.click(input);
await user.keyboard("{Enter}");
expect(onGenerate).toHaveBeenCalledTimes(1);
});
it("disables unsupported modes and labels mock playback", async () => {
const user = userEvent.setup();
render(<ChatBar supportedGenerationModes={["t2va"]} mockRuntime />);
expect(screen.getByText(/No AI model is generating/)).toBeInTheDocument();
await user.click(screen.getByRole("combobox", { name: "Generation mode" }));
expect(await screen.findByRole("option", { name: "FL2VA" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "Ref2VA" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "Ref2VA" }))
.toHaveAttribute("title", "References to video + audio (unavailable on this runtime)");
});
it("closes the menu with Escape and restores focus to Mode", async () => {
const onGenerationModeChange = vi.fn();
const user = userEvent.setup();
render(<ChatBar onGenerationModeChange={onGenerationModeChange} />);
const modeSelect = screen.getByRole("combobox", { name: "Generation mode" });
await user.click(modeSelect);
await screen.findByRole("listbox");
await user.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
await waitFor(() => expect(modeSelect).toHaveFocus());
expect(onGenerationModeChange).not.toHaveBeenCalled();
});
it("supports choosing a mode with the keyboard", async () => {
const onGenerationModeChange = vi.fn();
const user = userEvent.setup();
render(<ChatBar onGenerationModeChange={onGenerationModeChange} />);
await user.click(screen.getByRole("textbox", { name: "Continuation prompt" }));
await user.tab();
expect(screen.getByRole("combobox", { name: "Generation mode" })).toHaveFocus();
await user.keyboard("{ArrowDown}");
await waitFor(() => expect(screen.getByRole("option", { name: "T2VA" })).toHaveFocus());
await user.keyboard("{ArrowDown}");
await waitFor(() => expect(screen.getByRole("option", { name: "FL2VA" })).toHaveFocus());
await user.keyboard("{Enter}");
expect(onGenerationModeChange).toHaveBeenCalledWith("fl2va");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
+106 -27
View File
@@ -4,8 +4,16 @@ import React, { useRef, useState, useCallback, useEffect } from "react";
import Image from "next/image";
import { Film, ArrowUp, X, Loader2, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import LeaveSessionModal, { shouldShowLeaveWarning } from "@/components/LeaveSessionModal";
import SpeechToTextButton from "@/components/SpeechToTextButton";
import {
DEFAULT_GENERATION_MODE,
GENERATION_MODES,
getGenerationMode,
isGenerationMode,
type GenerationMode,
} from "@/lib/generationMode";
import { cn } from "@/lib/utils";
const PROMPT_MAX_LENGTH = 500;
@@ -22,6 +30,12 @@ interface Props {
sessionNotice?: string;
projectResetPending?: boolean;
viewingReadOnly?: boolean;
generationMode?: GenerationMode;
supportedGenerationModes?: readonly GenerationMode[];
generationInputsValid?: boolean;
capabilityNotice?: string;
mockRuntime?: boolean;
conditioningPanel?: React.ReactNode;
onPresetGenerate?: (presetId: string) => void;
onContinuationInput?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onContinuationKeydown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
@@ -30,6 +44,7 @@ interface Props {
onLeave?: () => void;
onStartNewProject?: () => void;
onBackFromViewing?: () => void;
onGenerationModeChange?: (mode: GenerationMode) => void;
onSpeechTranscript?: (text: string) => void;
onSpeechInterimChange?: (text: string) => void;
}
@@ -46,6 +61,12 @@ export default function ChatBar({
sessionNotice = "",
projectResetPending = false,
viewingReadOnly = false,
generationMode = DEFAULT_GENERATION_MODE,
supportedGenerationModes = GENERATION_MODES.map((mode) => mode.id),
generationInputsValid = true,
capabilityNotice = "",
mockRuntime = false,
conditioningPanel,
onPresetGenerate = () => {},
onContinuationInput = () => {},
onContinuationKeydown = () => {},
@@ -54,6 +75,7 @@ export default function ChatBar({
onLeave = () => {},
onStartNewProject = () => {},
onBackFromViewing = () => {},
onGenerationModeChange = () => {},
onSpeechTranscript,
onSpeechInterimChange,
}: Props) {
@@ -69,6 +91,7 @@ export default function ChatBar({
? "What video are you imagining?"
: "What do you want to edit?";
const actionLabel = !sessionStarted ? "Generate" : "Rewrite rollout";
const selectedGenerationMode = getGenerationMode(generationMode);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@@ -239,7 +262,7 @@ export default function ChatBar({
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-card/80 px-6 py-4 text-center shadow-md backdrop-blur-sm">
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-foreground">View-only project</p>
<p className="max-w-md text-xs text-muted-foreground">Project sessions are currently limited to 5 minutes. Start a new project to create more videos.</p>
<p className="max-w-md text-xs text-muted-foreground">This saved project is available for playback. Start a new project to create more videos.</p>
</div>
<div className="mt-1 flex items-center gap-2">
<Button onClick={onBackFromViewing} variant="outline" size="sm" className="gap-1.5 rounded-full px-4">
@@ -261,7 +284,7 @@ export default function ChatBar({
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-card/80 px-8 py-5 text-center shadow-md backdrop-blur-sm">
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-foreground">Session ended</p>
<p className="max-w-xs text-xs text-muted-foreground">Each project currently has a 5-minute session. Start a new project to continue creating videos.</p>
<p className="max-w-xs text-xs text-muted-foreground">The runtime session has ended. Your saved videos remain available. Start a new project to continue creating.</p>
</div>
<div className="mt-1 flex items-center gap-2">
<Button onClick={onStartNewProject} size="sm" className="rounded-full px-5">
@@ -280,7 +303,7 @@ export default function ChatBar({
return (
<section className="mx-auto flex w-full max-w-2xl shrink-0 flex-col gap-4">
{storyPresets.length > 0 && !sessionStarted && (
{storyPresets.length > 0 && !sessionStarted && generationMode === "t2va" && (
<div className={cn("relative transition-opacity duration-200", isGenerating && "pointer-events-none opacity-40")}>
<div
ref={scrollRef}
@@ -301,7 +324,7 @@ export default function ChatBar({
<button
key={preset.id}
type="button"
disabled={isGenerating}
disabled={isBusy || !generationInputsValid}
onClick={() => onPresetGenerate(preset.id)}
className="flex flex-col sm:flex-row items-start gap-1.5 shrink-0 rounded-xl border p-2.5 text-left backdrop-blur-sm transition-colors max-w-42 sm:max-w-[215px] border-input bg-card/80 text-muted-foreground hover:bg-slate-200/60 hover:border-slate-400 hover:text-slate-700 dark:bg-slate-800/80 dark:text-slate-300 dark:hover:bg-slate-700/50 dark:hover:border-slate-500 dark:hover:text-slate-200"
>
@@ -327,6 +350,12 @@ export default function ChatBar({
</div>
)}
{mockRuntime && (
<p role="status" className="rounded-xl border border-violet-500/25 bg-violet-500/10 px-4 py-2 text-center text-xs text-violet-700 dark:text-violet-300">
Demo runtime · Sample playback only. No AI model is generating this video.
</p>
)}
{sessionNotice && (
<div
className={cn(
@@ -346,9 +375,14 @@ export default function ChatBar({
</div>
)}
{sessionStarted && <p className="px-2 text-center text-[11px] text-muted-foreground">{selectedGenerationMode.label} · Mode and reference inputs are locked for this project.</p>}
{conditioningPanel}
<div
role="group"
aria-label="Prompt composer"
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-4xl border py-2.5 pl-5 pr-2.5 shadow-md backdrop-blur-sm transition-all duration-200",
"flex min-w-0 flex-col gap-2 rounded-3xl border p-2.5 shadow-md backdrop-blur-sm transition-all duration-200",
isBusy ? "border-input/60 bg-card/40" : "border-input bg-card/65",
)}
>
@@ -364,40 +398,85 @@ export default function ChatBar({
disabled={isBusy || sttBusy}
rows={1}
className={cn(
"min-w-0 flex-1 resize-none bg-transparent text-foreground outline-none placeholder:text-muted-foreground transition-opacity duration-200 scrollbar-thin leading-snug",
"w-full min-w-0 resize-none bg-transparent px-2 py-1 text-foreground outline-none placeholder:text-muted-foreground transition-opacity duration-200 scrollbar-thin leading-snug",
(isBusy || sttBusy) && "cursor-not-allowed opacity-50",
)}
/>
{onSpeechTranscript && <SpeechToTextButton disabled={isBusy} onTranscript={onSpeechTranscript} onInterimChange={onSpeechInterimChange} onBusyChange={setSttBusy} />}
{!sessionStarted ? (
<Button
aria-label={actionLabel}
title={actionLabel}
onClick={onGenerate}
disabled={!canJoinSession || isGenerating || !continuationDraft.trim()}
size="icon-sm"
className="shrink-0 rounded-full"
>
{showSpinner ? <Loader2 className="size-5 animate-spin" /> : <ArrowUp className="size-5" />}
</Button>
) : (
<>
<div className="flex min-w-0 items-center gap-1.5">
{!sessionStarted && (
<div className="flex shrink-0 items-center gap-1 pl-2">
<label htmlFor="generation-mode" className="cursor-pointer text-xs font-medium text-muted-foreground">
Mode
</label>
<Select
value={generationMode}
disabled={isBusy || sttBusy}
onValueChange={(value) => {
if (isGenerationMode(value)) onGenerationModeChange(value);
}}
>
<SelectTrigger
id="generation-mode"
aria-label="Generation mode"
title={`${selectedGenerationMode.name}. ${selectedGenerationMode.description}`}
className="h-8 w-24 cursor-pointer rounded-lg border-0 bg-transparent px-2 py-1 text-xs font-medium shadow-none hover:bg-muted/60 data-[state=open]:bg-muted/80 [&>svg]:size-3 [&>svg]:transition-transform [&[data-state=open]>svg]:rotate-180"
>
<SelectValue />
</SelectTrigger>
<SelectContent
side="bottom"
align="start"
sideOffset={6}
className="min-w-36 rounded-2xl border-input/70 bg-card/95 shadow-xl backdrop-blur-xl"
>
{GENERATION_MODES.map((mode) => (
<SelectItem
key={mode.id}
value={mode.id}
disabled={!supportedGenerationModes.includes(mode.id)}
title={supportedGenerationModes.includes(mode.id) ? mode.name : `${mode.name} (unavailable on this runtime)`}
className="cursor-pointer rounded-xl text-xs transition-colors data-[state=checked]:bg-muted/80 data-[state=checked]:font-semibold [&_svg]:size-3.5 [&_svg]:text-foreground"
>
{mode.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex-1" />
{onSpeechTranscript && <SpeechToTextButton disabled={isBusy} onTranscript={onSpeechTranscript} onInterimChange={onSpeechInterimChange} onBusyChange={setSttBusy} />}
{!sessionStarted ? (
<Button
aria-label={actionLabel}
title={actionLabel}
onClick={onSubmitContinuation}
disabled={!canSubmitContinuation || showSpinner || projectResetPending || !continuationDraft.trim()}
onClick={onGenerate}
disabled={!canJoinSession || isGenerating || !continuationDraft.trim()}
size="icon-sm"
className="shrink-0 rounded-full"
>
{showSpinner ? <Loader2 className="size-5 animate-spin" /> : <ArrowUp className="size-5" />}
</Button>
<Button variant="outline" aria-label="Leave" title="Leave" onClick={() => { if (shouldShowLeaveWarning()) setLeaveModalOpen(true); else onLeave(); }} disabled={isGenerating || projectResetPending} size="icon-sm" className="shrink-0 rounded-full">
<X className="size-5" />
</Button>
</>
)}
) : (
<>
<Button
aria-label={actionLabel}
title={actionLabel}
onClick={onSubmitContinuation}
disabled={!canSubmitContinuation || showSpinner || projectResetPending || !continuationDraft.trim()}
size="icon-sm"
className="shrink-0 rounded-full"
>
{showSpinner ? <Loader2 className="size-5 animate-spin" /> : <ArrowUp className="size-5" />}
</Button>
<Button variant="outline" aria-label="Leave" title="Leave" onClick={() => { if (shouldShowLeaveWarning()) setLeaveModalOpen(true); else onLeave(); }} disabled={isGenerating || projectResetPending} size="icon-sm" className="shrink-0 rounded-full">
<X className="size-5" />
</Button>
</>
)}
</div>
</div>
{!sessionStarted && capabilityNotice && <p className="px-2 text-[11px] text-amber-700 dark:text-amber-300">{capabilityNotice}</p>}
<p className="px-2 text-center text-[11px] text-muted-foreground">
LLM powered by{" "}
<a
@@ -33,7 +33,7 @@ export default function SessionTimeoutModal({
Session ended
</h2>
<p className="text-sm text-muted-foreground">
This project hit the current 5-minute session limit. Your latest video stays on screen, and the project is being kept in the archive so you can come back to it.
This project reached the runtime session limit. Your latest video stays on screen, and the project is being kept in the archive so you can come back to it.
</p>
</div>
<p className="text-sm text-muted-foreground">
@@ -17,6 +17,13 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
DEFAULT_GENERATION_MODE,
GENERATION_MODES,
getGenerationMode,
isGenerationMode,
type GenerationMode,
} from '@/lib/generationMode';
interface DevtoolsComposerProps {
connected?: boolean;
@@ -37,6 +44,10 @@ interface DevtoolsComposerProps {
loopGenerationEnabled?: boolean;
curatedPromptLimit?: number;
maxCuratedPromptCount?: number;
generationMode?: GenerationMode;
supportedGenerationModes?: readonly GenerationMode[];
conditioningPanel?: React.ReactNode;
onGenerationModeChange?: (mode: GenerationMode) => void;
rewriteWindowMode?: boolean;
rewritingSeedPrompts?: boolean;
autoExtensionTimeoutHint?: string;
@@ -74,6 +85,10 @@ export default function DevtoolsComposer({
loopGenerationEnabled = false,
curatedPromptLimit = 0,
maxCuratedPromptCount = 0,
generationMode = DEFAULT_GENERATION_MODE,
supportedGenerationModes = GENERATION_MODES.map((mode) => mode.id),
conditioningPanel,
onGenerationModeChange = () => {},
rewriteWindowMode = false,
rewritingSeedPrompts = false,
autoExtensionTimeoutHint = '',
@@ -92,6 +107,7 @@ export default function DevtoolsComposer({
onSpeechInterimChange,
}: DevtoolsComposerProps) {
const [sttBusy, setSttBusy] = useState(false);
const selectedGenerationMode = getGenerationMode(generationMode);
const submitButtonLabel = useMemo(
() =>
rewriteWindowMode
@@ -110,7 +126,8 @@ export default function DevtoolsComposer({
);
return (
<section>
<section className="space-y-4">
{conditioningPanel}
<Card>
<CardContent className="space-y-5 p-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
@@ -285,6 +302,48 @@ export default function DevtoolsComposer({
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="devtools-generation-mode">
Generation mode
</Label>
<Select
value={generationMode}
disabled={sessionStarted}
onValueChange={(value) => {
if (isGenerationMode(value)) {
onGenerationModeChange(value);
}
}}
>
<SelectTrigger
id="devtools-generation-mode"
aria-label="Generation mode"
title={selectedGenerationMode.name}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{GENERATION_MODES.map((mode) => (
<SelectItem
key={mode.id}
value={mode.id}
disabled={!supportedGenerationModes.includes(mode.id)}
title={
supportedGenerationModes.includes(mode.id)
? mode.name
: `${mode.name} (unavailable on this runtime)`
}
>
{mode.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{selectedGenerationMode.description}
</p>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="devtools-enhance-prompts"
@@ -35,6 +35,7 @@ describe('DevtoolsShell', () => {
expect(screen.getByText('Devtools Mode')).toBeInTheDocument();
expect(screen.getByText('Your video will appear here')).toBeInTheDocument();
expect(screen.getByLabelText('Story preset')).toBeInTheDocument();
expect(screen.getByLabelText('Generation mode')).toBeInTheDocument();
expect(screen.getByLabelText('Continuation prompt')).toBeDisabled();
expect(screen.getByText('Advanced controls')).toBeInTheDocument();
@@ -9,6 +9,7 @@ import VideoPlayer from '../VideoPlayer';
import RewriteInspector from '../rewrite/RewriteInspector';
import DevtoolsComposer from './DevtoolsComposer';
import DevtoolsDrawer from './DevtoolsDrawer';
import { DEFAULT_GENERATION_MODE, GENERATION_MODES, type GenerationMode } from '@/lib/generationMode';
interface DevtoolsShellProps {
connected?: boolean;
@@ -30,6 +31,11 @@ interface DevtoolsShellProps {
curatedPromptLimit?: number;
maxCuratedPromptCount?: number;
generationMode?: GenerationMode;
supportedGenerationModes?: readonly GenerationMode[];
conditioningPanel?: React.ReactNode;
onGenerationModeChange?: (mode: GenerationMode) => void;
onPresetChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
onEnhancementToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onCuratedPromptLimitChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
@@ -137,6 +143,11 @@ export default function DevtoolsShell({
curatedPromptLimit = 0,
maxCuratedPromptCount = 0,
generationMode = DEFAULT_GENERATION_MODE,
supportedGenerationModes = GENERATION_MODES.map((mode) => mode.id),
conditioningPanel,
onGenerationModeChange = () => {},
onPresetChange = () => {},
onEnhancementToggle = () => {},
onCuratedPromptLimitChange = () => {},
@@ -287,6 +298,10 @@ export default function DevtoolsShell({
loopGenerationEnabled={loopGenerationEnabled}
curatedPromptLimit={curatedPromptLimit}
maxCuratedPromptCount={maxCuratedPromptCount}
generationMode={generationMode}
supportedGenerationModes={supportedGenerationModes}
conditioningPanel={conditioningPanel}
onGenerationModeChange={onGenerationModeChange}
rewriteWindowMode={livePromptRewriteMode}
rewritingSeedPrompts={rewritingSeedPrompts}
autoExtensionTimeoutHint={autoExtensionTimeoutHint}
@@ -0,0 +1,55 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAssetLibrary } from "./useAssetLibrary";
const image = { asset_id: "asset-1", kind: "image", name: "frame.png", mime_type: "image/png", size: 5, url: "/assets/asset-1" };
describe("asset library lifecycle", () => {
beforeEach(() => localStorage.clear());
afterEach(() => vi.unstubAllGlobals());
it("uploads the raw file and keeps assignment state separate from uploaded assets", async () => {
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => image }));
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useAssetLibrary());
const file = new File(["image"], "first frame.png", { type: "image/png" });
await act(async () => { await result.current.uploadAssets([file]); });
expect(fetchMock).toHaveBeenCalledWith("/assets", expect.objectContaining({ body: file, headers: { "Content-Type": "image/png", "X-Asset-Name": "first%20frame.png" } }));
act(() => result.current.assignAsset("asset-1", "first_frame"));
expect(result.current.conditioningAssets).toEqual([{ asset_id: "asset-1", role: "first_frame" }]);
act(() => result.current.clearConditioning());
expect(result.current.assets).toHaveLength(1);
expect(result.current.conditioningAssets).toEqual([]);
});
it("identifies stale server assets without downloading their contents", async () => {
localStorage.setItem("dreamverse-asset-library-v1", JSON.stringify([image]));
const fetchMock = vi.fn(async () => ({ ok: false, status: 404 }));
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useAssetLibrary());
await waitFor(() => expect(result.current.assets).toHaveLength(1));
act(() => result.current.assignAsset("asset-1", "first_frame"));
let message: string | null = null;
await act(async () => { message = await result.current.verifySelectedAssets(); });
expect(message).toMatch(/Upload it again/);
expect(result.current.assets[0].missing).toBe(true);
expect(fetchMock).toHaveBeenCalledWith("/assets/asset-1", expect.objectContaining({ method: "HEAD" }));
});
it("deletes both the library entry and its selected references", async () => {
localStorage.setItem("dreamverse-asset-library-v1", JSON.stringify([image]));
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true })));
const { result } = renderHook(() => useAssetLibrary());
await waitFor(() => expect(result.current.assets).toHaveLength(1));
act(() => result.current.assignAsset("asset-1", "reference"));
await act(async () => { await result.current.removeAsset("asset-1"); });
expect(result.current.assets).toEqual([]);
expect(result.current.conditioningAssets).toEqual([]);
});
it("does not mark a valid upload missing when the browser cannot preview its codec", async () => {
localStorage.setItem("dreamverse-asset-library-v1", JSON.stringify([image]));
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, status: 200 })));
const { result } = renderHook(() => useAssetLibrary());
await waitFor(() => expect(result.current.assets).toHaveLength(1));
await act(async () => { await result.current.checkAssetAvailability("asset-1"); });
expect(result.current.assets[0].missing).not.toBe(true);
});
});
@@ -0,0 +1,136 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ConditioningAsset, ConditioningRole, GenerationAsset } from "@/lib/generationMode";
const LIBRARY_KEY = "dreamverse-asset-library-v1";
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
async function responseError(response: Response, fallback: string): Promise<Error> {
const payload = await response.json().catch(() => ({}));
return new Error(typeof payload.detail === "string" ? payload.detail : fallback);
}
function isAsset(value: unknown): value is GenerationAsset {
if (!value || typeof value !== "object") return false;
const asset = value as Partial<GenerationAsset>;
return typeof asset.asset_id === "string" && /^[a-zA-Z0-9_-]+$/.test(asset.asset_id)
&& ["image", "video", "audio"].includes(asset.kind || "")
&& typeof asset.name === "string" && typeof asset.mime_type === "string" && typeof asset.size === "number";
}
/** Asset ownership lives here so composers and other pickers share the same library. */
export function useAssetLibrary() {
const [assets, setAssets] = useState<GenerationAsset[]>([]);
const [conditioningAssets, setConditioningAssets] = useState<ConditioningAsset[]>([]);
const [uploading, setUploading] = useState(false);
const [assetError, setAssetError] = useState("");
const [hydrated, setHydrated] = useState(false);
const uploadingRef = useRef(false);
useEffect(() => {
try {
const saved = JSON.parse(localStorage.getItem(LIBRARY_KEY) || "[]");
if (Array.isArray(saved)) setAssets(saved.filter(isAsset).map((asset) => ({
...asset, url: `/assets/${asset.asset_id}`,
})));
} catch { /* Storage is optional; uploads still work in private browsing. */ }
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
try { localStorage.setItem(LIBRARY_KEY, JSON.stringify(assets)); } catch { /* Optional cache. */ }
}, [assets, hydrated]);
const uploadAssets = useCallback(async (files: File[]) => {
if (uploadingRef.current) return;
uploadingRef.current = true;
setUploading(true);
setAssetError("");
const errors: string[] = [];
for (const file of files) {
try {
if (!/^(image|video|audio)\//.test(file.type)) throw new Error(`${file.name}: choose an image, video, or audio file.`);
const maxBytes = file.type.startsWith("image/") ? 15 * 1024 * 1024 : MAX_UPLOAD_BYTES;
if (!file.size || file.size > maxBytes) throw new Error(`${file.name}: use a non-empty file up to ${maxBytes / 1024 / 1024} MiB.`);
const response = await fetch("/assets", {
method: "POST",
headers: { "Content-Type": file.type, "X-Asset-Name": encodeURIComponent(file.name) },
body: file,
});
if (!response.ok) throw await responseError(response, `Could not upload ${file.name}.`);
const asset: unknown = await response.json();
if (!isAsset(asset)) throw new Error("The server returned an invalid asset. Please retry the upload.");
setAssets((current) => [...current.filter((item) => item.asset_id !== asset.asset_id), {
...asset, url: `/assets/${asset.asset_id}`, missing: false,
}]);
} catch (error) {
errors.push(error instanceof Error ? error.message : `Could not upload ${file.name}.`);
}
}
setAssetError(errors.join(" "));
setUploading(false);
uploadingRef.current = false;
}, []);
const assignAsset = useCallback((assetId: string, role: ConditioningRole) => {
setConditioningAssets((current) => {
const next = role === "reference" ? current : current.filter((item) => item.role !== role);
if (!assetId || next.some((item) => item.asset_id === assetId && item.role === role)) return next;
return [...next, { asset_id: assetId, role }];
});
}, []);
const removeConditioning = useCallback((index: number) => {
setConditioningAssets((current) => current.filter((_, itemIndex) => itemIndex !== index));
}, []);
const moveConditioning = useCallback((from: number, to: number) => {
setConditioningAssets((current) => {
if (from < 0 || to < 0 || from >= current.length || to >= current.length) return current;
const next = [...current];
next.splice(to, 0, next.splice(from, 1)[0]);
return next;
});
}, []);
const clearConditioning = useCallback(() => setConditioningAssets([]), []);
const markAssetMissing = useCallback((assetId: string) => {
setAssets((current) => current.map((asset) => asset.asset_id === assetId ? { ...asset, missing: true } : asset));
}, []);
const checkAssetAvailability = useCallback(async (assetId: string) => {
try {
const response = await fetch(`/assets/${assetId}`, { method: "HEAD", signal: AbortSignal.timeout(4000) });
if (response.status === 404) markAssetMissing(assetId);
} catch { /* A browser preview failure alone does not mean the upload expired. */ }
}, [markAssetMissing]);
const removeAsset = useCallback(async (assetId: string) => {
setAssetError("");
try {
const response = await fetch(`/assets/${assetId}`, { method: "DELETE" });
if (!response.ok && response.status !== 404) throw await responseError(response, "Could not remove the asset. Retry when the backend is available.");
setAssets((current) => current.filter((asset) => asset.asset_id !== assetId));
setConditioningAssets((current) => current.filter((asset) => asset.asset_id !== assetId));
} catch (error) {
setAssetError(error instanceof Error ? error.message : "Could not remove the asset.");
}
}, []);
const verifySelectedAssets = useCallback(async (): Promise<string | null> => {
const selected = [...new Set(conditioningAssets.map((item) => item.asset_id))];
try {
for (const assetId of selected) {
const response = await fetch(`/assets/${assetId}`, { method: "HEAD", signal: AbortSignal.timeout(4000) });
if (response.status === 404) {
markAssetMissing(assetId);
return "A selected asset expired or was removed from the server. Upload it again, then select the new copy.";
}
if (!response.ok) return "Could not verify the selected assets. Check the backend and try again.";
}
return null;
} catch {
return "Could not verify the selected assets. Check the backend and try again.";
}
}, [conditioningAssets, markAssetMissing]);
return {
assets, conditioningAssets, uploading, assetError, uploadAssets, assignAsset,
removeConditioning, moveConditioning, clearConditioning, removeAsset, checkAssetAvailability, verifySelectedAssets,
};
}
@@ -0,0 +1,21 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useGenerationCapabilities } from "./useGenerationCapabilities";
describe("generation capabilities", () => {
afterEach(() => vi.unstubAllGlobals());
it("enables only the modes the runtime advertises and identifies mock playback", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ model_id: "mock", modes: ["t2va", "fl2va", "ref2va"], mock: true }) })));
const { result } = renderHook(() => useGenerationCapabilities());
await waitFor(() => expect(result.current.loadingCapabilities).toBe(false));
expect(result.current.capabilities.modes).toEqual(["t2va", "fl2va", "ref2va"]);
expect(result.current.capabilities.mock).toBe(true);
});
it("keeps old runtimes text-only when the capabilities endpoint is missing", async () => {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 404 })));
const { result } = renderHook(() => useGenerationCapabilities());
await waitFor(() => expect(result.current.loadingCapabilities).toBe(false));
expect(result.current.capabilities.modes).toEqual(["t2va"]);
expect(result.current.capabilityNotice).toMatch(/Text-only compatibility/);
});
});
@@ -0,0 +1,37 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { isGenerationMode, type GenerationCapabilities } from "@/lib/generationMode";
const LEGACY_CAPABILITIES: GenerationCapabilities = { model_id: "legacy", modes: ["t2va"] };
export function useGenerationCapabilities() {
const [capabilities, setCapabilities] = useState<GenerationCapabilities>(LEGACY_CAPABILITIES);
const [capabilityNotice, setCapabilityNotice] = useState("");
const [loadingCapabilities, setLoadingCapabilities] = useState(true);
const refreshCapabilities = useCallback(async () => {
try {
const response = await fetch("/generation-capabilities", { signal: AbortSignal.timeout(4000) });
if (!response.ok) throw new Error("Capabilities unavailable");
const payload = await response.json();
if (typeof payload.model_id !== "string" || !Array.isArray(payload.modes)
|| !payload.modes.every(isGenerationMode)) throw new Error("Invalid capabilities");
const next: GenerationCapabilities = {
model_id: payload.model_id,
modes: payload.modes,
mock: payload.mock === true,
};
setCapabilities(next);
setCapabilityNotice("");
return next;
} catch {
setCapabilities(LEGACY_CAPABILITIES);
setCapabilityNotice("Runtime capabilities unavailable. Text-only compatibility mode is available; check the backend to enable image and reference modes.");
return LEGACY_CAPABILITIES;
} finally {
setLoadingCapabilities(false);
}
}, []);
useEffect(() => { void refreshCapabilities(); }, [refreshCapabilities]);
return { capabilities, capabilityNotice, loadingCapabilities, refreshCapabilities };
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_GENERATION_MODE,
GENERATION_MODES,
getGenerationMode,
isGenerationMode,
buildGenerationInitFields,
validateGenerationInputs,
type GenerationAsset,
} from "./generationMode";
describe("generation modes", () => {
it("exposes stable wire IDs in the expected product order", () => {
expect(GENERATION_MODES.map((mode) => mode.id)).toEqual([
"t2va",
"fl2va",
"ref2va",
]);
expect(DEFAULT_GENERATION_MODE).toBe("t2va");
});
it("validates and resolves generation mode values", () => {
expect(isGenerationMode("ref2va")).toBe(true);
expect(isGenerationMode("unknown")).toBe(false);
expect(getGenerationMode("fl2va").label).toBe("FL2VA");
});
});
const image: GenerationAsset = { asset_id: "img", kind: "image", name: "frame.png", mime_type: "image/png", size: 12, url: "/assets/img" };
const audio: GenerationAsset = { asset_id: "sound", kind: "audio", name: "sound.wav", mime_type: "audio/wav", size: 12, url: "/assets/sound" };
describe("generation input contract", () => {
it("keeps text-only init valid and rejects accidental references", () => {
expect(buildGenerationInitFields("t2va", [], [])).toEqual({ generation_mode: "t2va", conditioning_assets: [] });
expect(() => buildGenerationInitFields("t2va", [{ asset_id: "img", role: "reference" }], [image])).toThrow("text only");
});
it("requires first frame but permits first-only or both endpoints", () => {
expect(validateGenerationInputs("fl2va", [], [])).toMatch(/first frame/);
const first = { asset_id: "img", role: "first_frame" } as const;
expect(validateGenerationInputs("fl2va", [first], [image])).toBeNull();
expect(validateGenerationInputs("fl2va", [first, { asset_id: "img", role: "last_frame" }], [image])).toBeNull();
expect(validateGenerationInputs("fl2va", [first, first], [image])).toMatch(/one first frame/);
expect(validateGenerationInputs("fl2va", [{ asset_id: "sound", role: "first_frame" }], [audio])).toMatch(/images only/);
});
it("requires a visual reference and preserves multimodal ordering without file bodies", () => {
expect(validateGenerationInputs("ref2va", [{ asset_id: "sound", role: "reference" }], [audio])).toMatch(/image or video/);
const items = [{ asset_id: "sound", role: "reference" }, { asset_id: "img", role: "reference" }] as const;
expect(buildGenerationInitFields("ref2va", items, [image, audio])).toEqual({ generation_mode: "ref2va", conditioning_assets: items });
});
it("rejects per-kind limits, total limits, and stale uploads", () => {
const images = Array.from({ length: 10 }, (_, index) => ({ ...image, asset_id: `img-${index}` }));
expect(validateGenerationInputs("ref2va", images.map((item) => ({ asset_id: item.asset_id, role: "reference" })), images)).toMatch(/at most 9 image/);
const refs = Array.from({ length: 13 }, () => ({ asset_id: "img", role: "reference" as const }));
expect(validateGenerationInputs("ref2va", refs, [image])).toMatch(/at most 12/);
expect(validateGenerationInputs("fl2va", [{ asset_id: "img", role: "first_frame" }], [{ ...image, missing: true }])).toMatch(/no longer on the server/);
});
});
@@ -0,0 +1,114 @@
export const GENERATION_MODES = [
{
id: "t2va",
label: "T2VA",
name: "Text to video + audio",
description: "Start with a text prompt; no reference asset is required.",
},
{
id: "fl2va",
label: "FL2VA",
name: "First/last frames to video + audio",
description: "Start from a first frame image. Add an optional last frame to guide the ending.",
},
{
id: "ref2va",
label: "Ref2VA",
name: "References to video + audio",
description: "Guide the result with ordered image, video, or audio references.",
},
] as const;
export type GenerationMode = (typeof GENERATION_MODES)[number]["id"];
export const DEFAULT_GENERATION_MODE: GenerationMode = "t2va";
export function isGenerationMode(value: unknown): value is GenerationMode {
return GENERATION_MODES.some((mode) => mode.id === value);
}
export function getGenerationMode(value: GenerationMode) {
return GENERATION_MODES.find((mode) => mode.id === value) ?? GENERATION_MODES[0];
}
export type AssetKind = "image" | "video" | "audio";
export type ConditioningRole = "first_frame" | "last_frame" | "reference";
/** Runtime-owned uploads; project metadata keeps references, never file contents. */
export interface GenerationAsset {
asset_id: string;
kind: AssetKind;
name: string;
mime_type: string;
size: number;
url: string;
missing?: boolean;
}
export interface ConditioningAsset {
asset_id: string;
role: ConditioningRole;
}
export interface GenerationCapabilities {
model_id: string;
modes: GenerationMode[];
mock?: boolean;
}
export interface GenerationInitFields {
generation_mode: GenerationMode;
conditioning_assets: ConditioningAsset[];
}
export const REFERENCE_LIMITS = { image: 9, video: 3, audio: 3, total: 12 } as const;
export function validateGenerationInputs(
mode: GenerationMode,
conditioning: readonly ConditioningAsset[],
assets: readonly GenerationAsset[],
): string | null {
if (mode === "t2va") {
return conditioning.length ? "T2VA uses text only. Remove the selected references." : null;
}
const resolved = conditioning.map((item) => assets.find((asset) => asset.asset_id === item.asset_id));
if (resolved.some((asset) => !asset || asset.missing)) {
return "A selected asset is no longer on the server. Upload it again and select the new copy.";
}
if (mode === "fl2va") {
if (!conditioning.some((item) => item.role === "first_frame")) return "Choose a first frame image to generate.";
if (conditioning.some((item) => item.role === "reference") || resolved.some((asset) => asset?.kind !== "image")) {
return "FL2VA accepts first and last frame images only.";
}
if (conditioning.filter((item) => item.role === "first_frame").length !== 1
|| conditioning.filter((item) => item.role === "last_frame").length > 1) {
return "Choose one first frame and at most one last frame.";
}
return null;
}
if (conditioning.some((item) => item.role !== "reference")) return "Ref2VA accepts ordered reference assets only.";
if (!resolved.some((asset) => asset?.kind === "image" || asset?.kind === "video")) {
return "Add at least one image or video reference. Audio alone is not enough.";
}
if (conditioning.length > REFERENCE_LIMITS.total) return "Use at most 12 reference assets in total.";
for (const kind of ["image", "video", "audio"] as const) {
if (resolved.filter((asset) => asset?.kind === kind).length > REFERENCE_LIMITS[kind]) {
return `Use at most ${REFERENCE_LIMITS[kind]} ${kind} references.`;
}
}
return null;
}
/** Shared by both session_init_v2 and project_init_v1. */
export function buildGenerationInitFields(
mode: GenerationMode,
conditioning: readonly ConditioningAsset[],
assets: readonly GenerationAsset[],
): GenerationInitFields {
const problem = validateGenerationInputs(mode, conditioning, assets);
if (problem) throw new Error(problem);
return {
generation_mode: mode,
conditioning_assets: conditioning.map(({ asset_id, role }) => ({ asset_id, role })),
};
}
@@ -1,3 +1,5 @@
import type { ConditioningAsset, GenerationAsset, GenerationMode } from "./generationMode";
const DB_NAME = "fastvideo-projects";
const DB_VERSION = 1;
const PROJECTS_STORE = "projects";
@@ -35,6 +37,11 @@ export interface StoredProject {
createdAt: number;
lastThumbnail: string | null;
promptEvents: Record<string, unknown>[];
/** Optional for projects created before generation modes were introduced. */
generationMode?: GenerationMode;
conditioningAssets?: ConditioningAsset[];
assets?: GenerationAsset[];
mock?: boolean;
}
export interface StoredClip {
+20 -4
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import contextlib
import logging
import json
import sqlite3
import threading
from pathlib import Path
@@ -46,8 +47,13 @@ DEFAULT_SETTINGS: dict[str, Any] = {
def _sqlite_row_get(row: sqlite3.Row, key: str, default: Any) -> Any:
"""Like dict.get for sqlite3.Row (Row has no .get on Python 3.10)."""
return row[key] if key in row else default # noqa: SIM401
"""Like dict.get for sqlite3.Row (Row has no .get on Python 3.10).
NOTE: `key in row` tests Row *values*, not column names, so the membership
check has to go through .keys() -- otherwise every lookup falls back to the
default and jobs restored from the database lose their stored fields.
"""
return row[key] if key in row.keys() else default # noqa: SIM401, SIM118
def _get_db_path(data_dir: Path) -> Path:
@@ -83,6 +89,9 @@ def _migrate_db(conn: sqlite3.Connection) -> None:
_add_column_if_missing(conn, "jobs", "fps", "INTEGER", "24")
_add_column_if_missing(conn, "jobs", "workload_type", "TEXT", "'t2v'")
_add_column_if_missing(conn, "jobs", "image_path", "TEXT", "''")
_add_column_if_missing(conn, "jobs", "name", "TEXT", "''")
_add_column_if_missing(conn, "jobs", "last_image_path", "TEXT", "''")
_add_column_if_missing(conn, "jobs", "references_json", "TEXT", "''")
_add_column_if_missing(conn, "jobs", "job_type", "TEXT", "'inference'")
_add_column_if_missing(conn, "jobs", "data_path", "TEXT", "''")
_add_column_if_missing(conn, "jobs", "max_train_steps", "INTEGER", "1000")
@@ -242,7 +251,8 @@ class Database:
self._execute(
"""
INSERT INTO jobs (
id, model_id, prompt, workload_type, image_path, job_type, status,
id, model_id, name, prompt, workload_type, image_path,
last_image_path, references_json, job_type, status,
created_at, started_at, finished_at, error, output_path, log_file_path,
num_inference_steps, num_frames, height, width, guidance_scale,
guidance_rescale, fps, seed, num_gpus, dit_cpu_offload,
@@ -254,14 +264,17 @@ class Database:
dmd_use_vsa, dmd_vsa_sparsity, dmd_denoising_steps,
real_score_guidance_scale,
generator_update_interval, real_score_model_path, fake_score_model_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
job["id"],
job["model_id"],
job.get("name", ""),
job["prompt"],
job.get("workload_type", "t2v"),
job.get("image_path", ""),
job.get("last_image_path", ""),
json.dumps(job.get("references") or []),
job.get("job_type", "inference"),
job["status"],
job["created_at"],
@@ -540,9 +553,12 @@ def _row_to_job(row: sqlite3.Row) -> dict[str, Any]:
result = {
"id": row["id"],
"model_id": row["model_id"],
"name": _sqlite_row_get(row, "name", "") or "",
"prompt": row["prompt"],
"workload_type": _sqlite_row_get(row, "workload_type", "t2v"),
"image_path": _sqlite_row_get(row, "image_path", "") or "",
"last_image_path": _sqlite_row_get(row, "last_image_path", "") or "",
"references": _sqlite_row_get(row, "references_json", "") or "",
"job_type": _sqlite_row_get(row, "job_type", "inference"),
"status": row["status"],
"created_at": row["created_at"],
@@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test';
import { skipWithoutMock } from './helpers';
test.describe('create job interactions', () => {
skipWithoutMock();
for (const jobType of ['inference', 'finetuning', 'distillation']) {
test(`${jobType} remains interactive after repeated dialog dismissals`, async ({ page }) => {
await page.goto(`/${jobType}`);
const trigger = page.getByRole('button', { name: 'Create Job', exact: true });
const dialog = page.getByRole('dialog');
// Exercise both dismissal paths and reopen without reloading the page.
for (const closeWithEscape of [false, true]) {
await trigger.click();
await page.getByRole('menuitem').first().click();
await expect(dialog).toBeVisible();
if (closeWithEscape) {
await page.keyboard.press('Escape');
} else {
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
}
await expect(dialog).toBeHidden();
await expect(page.locator('body')).toHaveCSS('pointer-events', 'auto');
await expect(trigger).toBeFocused();
}
await page.getByRole('link', { name: 'Datasets', exact: true }).click();
await expect(page).toHaveURL(/\/datasets$/);
});
}
test('preserves keyboard menu dismissal and dialog focus trapping', async ({ page }) => {
await page.goto('/inference');
const trigger = page.getByRole('button', { name: 'Create Job', exact: true });
await trigger.focus();
await page.keyboard.press('Enter');
const firstItem = page.getByRole('menuitem').first();
await expect(firstItem).toBeFocused();
await page.keyboard.press('Escape');
await expect(page.getByRole('menu')).toBeHidden();
await expect(trigger).toBeFocused();
await expect(page.locator('body')).toHaveCSS('pointer-events', 'auto');
await page.keyboard.press('Enter');
await expect(firstItem).toBeFocused();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByLabel('Name (optional)')).toBeFocused();
// Shift+Tab from the first field wraps to Close, then Tab wraps back.
await page.keyboard.press('Shift+Tab');
await expect(dialog.getByRole('button', { name: 'Close', exact: true })).toBeFocused();
await page.keyboard.press('Tab');
await expect(dialog.getByLabel('Name (optional)')).toBeFocused();
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
await expect(page.locator('body')).toHaveCSS('pointer-events', 'auto');
});
});
+18 -2
View File
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { skipWithoutMock } from './helpers';
import { API_BASE, skipWithoutMock } from './helpers';
/**
* Create-job flow: open the Create Job modal on /inference, fill the prompt
@@ -10,7 +10,8 @@ import { skipWithoutMock } from './helpers';
test.describe('create inference job', () => {
skipWithoutMock();
test('creates a T2V job and shows it in the queue', async ({ page }) => {
test('creates a T2V job and starts it without refreshing', async ({ page, request }) => {
await request.put(`${API_BASE}/settings`, { data: { autoStartJob: false } });
await page.goto('/inference');
// The trigger opens a real menu on click, so this path works for touch,
@@ -38,5 +39,20 @@ test.describe('create inference job', () => {
// Modal closes and the queue refreshes with the newly created job.
await expect(dialog).toBeHidden();
await expect(page.getByText(prompt)).toBeVisible();
await expect(page.locator('body')).toHaveCSS('pointer-events', 'auto');
const card = page.getByRole('article').filter({ hasText: prompt });
await expect(card.getByText('pending', { exact: true })).toBeVisible();
const started = page.waitForResponse((response) =>
response.url().startsWith(`${API_BASE}/jobs/`) &&
response.url().endsWith('/start') &&
response.request().method() === 'POST',
);
await card.getByRole('button', { name: 'Start', exact: true }).click();
expect((await started).ok()).toBe(true);
await expect(card.getByText('running', { exact: true })).toBeVisible();
await page.getByRole('link', { name: 'Datasets', exact: true }).click();
await expect(page).toHaveURL(/\/datasets$/);
});
});
+239 -58
View File
@@ -10,7 +10,9 @@ from __future__ import annotations
import atexit
import collections
import contextlib
import copy
import enum
import json
import logging
import logging.handlers
import multiprocessing as mp
@@ -123,10 +125,13 @@ class LogBufferHandler(logging.Handler):
class Job:
id: str
model_id: str
prompt: str
name: str = ""
prompt: str = ""
workload_type: str = "t2v"
job_type: str = "inference"
image_path: str = ""
last_image_path: str = ""
references: list[dict[str, Any]] = field(default_factory=list)
status: JobStatus = JobStatus.PENDING
created_at: float = field(default_factory=time.time)
started_at: float | None = None
@@ -145,6 +150,7 @@ class Job:
negative_prompt: str = ""
num_gpus: int = 1
dit_cpu_offload: bool = False
dit_layerwise_offload: bool = False
text_encoder_cpu_offload: bool = False
vae_cpu_offload: bool = False
image_encoder_cpu_offload: bool = False
@@ -180,10 +186,13 @@ class Job:
return {
"id": self.id,
"model_id": self.model_id,
"name": self.name,
"prompt": self.prompt,
"workload_type": self.workload_type,
"job_type": self.job_type,
"image_path": self.image_path,
"last_image_path": self.last_image_path,
"references": self.references,
"status": self.status.value,
"created_at": self.created_at,
"started_at": self.started_at,
@@ -202,6 +211,7 @@ class Job:
"negative_prompt": self.negative_prompt,
"num_gpus": self.num_gpus,
"dit_cpu_offload": self.dit_cpu_offload,
"dit_layerwise_offload": self.dit_layerwise_offload,
"text_encoder_cpu_offload": self.text_encoder_cpu_offload,
"vae_cpu_offload": self.vae_cpu_offload,
"image_encoder_cpu_offload": self.image_encoder_cpu_offload,
@@ -232,6 +242,75 @@ class Job:
}
MINIMAX_H3_REF2VA_PIPELINE = "MiniMaxH3Ref2VAModularPipeline"
def _build_h3_references(raw: list[dict[str, Any]]) -> list[Any]:
"""Turn the API's reference dicts into MiniMaxH3Reference objects.
Imported lazily so the API server starts without pulling in fastvideo.
"""
from fastvideo.pipelines.basic.minimax_h3 import MiniMaxH3Reference
built = []
for i, ref in enumerate(raw):
source = (ref or {}).get("source")
if not source:
raise ValueError(f"reference {i} has no source")
if not os.path.isfile(source):
raise ValueError(f"reference {i} source not found: {source}")
kwargs: dict[str, Any] = {
"source": source,
"media_type": (ref.get("media_type") or "image"),
}
for opt in ("soundtrack", "fps", "sample_rate"):
if ref.get(opt) not in (None, ""):
kwargs[opt] = ref[opt]
built.append(MiniMaxH3Reference(**kwargs))
return built
JOB_LOG_FILENAME = "out.log"
def _job_log_path(output_dir: str, job_id: str) -> str:
"""Each job's log lives beside its outputs: <output_dir>/<job_id>/out.log."""
return os.path.join(output_dir, job_id, JOB_LOG_FILENAME)
def _decode_references(value: Any) -> list[dict[str, Any]]:
"""Reference lists round-trip through the DB as JSON text."""
if not value:
return []
if isinstance(value, list):
return list(value)
try:
decoded = json.loads(value)
except (TypeError, ValueError):
logger.warning("Could not decode stored references: %r", value)
return []
return list(decoded) if isinstance(decoded, list) else []
def _generator_is_alive(generator: Any) -> bool:
"""True if the generator's worker processes are all still running.
A cached VideoGenerator holds a MultiprocExecutor whose workers are separate
processes; nothing notices when they exit. Probing `proc.is_alive()` is what
the executor itself uses during shutdown. Anything unexpected in the object
graph is treated as alive so a probe failure can never wedge the cache.
"""
executor = getattr(generator, "executor", None)
workers = getattr(executor, "workers", None)
if not workers:
return True
try:
return all(w.proc.is_alive() for w in workers)
except Exception:
logger.debug("Worker liveness probe failed", exc_info=True)
return True
class JobRunner:
"""Manages video generation jobs, their execution, and generator caching."""
@@ -276,7 +355,7 @@ class JobRunner:
"""Populate job's log buffer from its log file if it exists."""
path = job.log_file_path
if not path:
path = os.path.join(self.log_dir, f"{job.id}.log")
path = _job_log_path(self.output_dir, job.id)
if not os.path.isfile(path):
return
try:
@@ -314,10 +393,13 @@ class JobRunner:
job = Job(
id=row["id"],
model_id=row["model_id"],
name=row.get("name", "") or "",
prompt=row["prompt"],
workload_type=row.get("workload_type", "t2v"),
job_type=row.get("job_type", "inference"),
image_path=row.get("image_path", "") or "",
last_image_path=row.get("last_image_path", "") or "",
references=_decode_references(row.get("references")),
data_path=row.get("data_path", "") or "",
max_train_steps=row.get("max_train_steps", 1000),
train_batch_size=row.get("train_batch_size", 1),
@@ -350,6 +432,7 @@ class JobRunner:
negative_prompt=row.get("negative_prompt", "") or "",
num_gpus=row.get("num_gpus", 1),
dit_cpu_offload=row.get("dit_cpu_offload", False),
dit_layerwise_offload=row.get("dit_layerwise_offload", False),
text_encoder_cpu_offload=row.get("text_encoder_cpu_offload", False),
vae_cpu_offload=row.get("vae_cpu_offload", False),
image_encoder_cpu_offload=row.get("image_encoder_cpu_offload", False),
@@ -394,9 +477,12 @@ class JobRunner:
job_id: str,
model_id: str,
prompt: str,
name: str = "",
workload_type: str = "t2v",
job_type: str = "inference",
image_path: str = "",
last_image_path: str = "",
references: list[dict[str, Any]] | None = None,
data_path: str = "",
max_train_steps: int = 1000,
train_batch_size: int = 1,
@@ -422,6 +508,7 @@ class JobRunner:
num_gpus: int = 1,
negative_prompt: str = "",
dit_cpu_offload: bool = False,
dit_layerwise_offload: bool = False,
text_encoder_cpu_offload: bool = False,
vae_cpu_offload: bool = False,
image_encoder_cpu_offload: bool = False,
@@ -435,10 +522,13 @@ class JobRunner:
job = Job(
id=job_id,
model_id=model_id,
name=(name or "").strip(),
prompt=prompt.strip(),
workload_type=workload_type or "t2v",
job_type=job_type or "inference",
image_path=image_path or "",
last_image_path=last_image_path or "",
references=list(references or []),
data_path=data_path or "",
max_train_steps=max_train_steps,
train_batch_size=train_batch_size,
@@ -464,6 +554,7 @@ class JobRunner:
negative_prompt=negative_prompt or "",
num_gpus=num_gpus,
dit_cpu_offload=dit_cpu_offload,
dit_layerwise_offload=dit_layerwise_offload,
text_encoder_cpu_offload=text_encoder_cpu_offload,
vae_cpu_offload=vae_cpu_offload,
image_encoder_cpu_offload=image_encoder_cpu_offload,
@@ -521,6 +612,84 @@ class JobRunner:
logger.info("Deleted job %s", job.id)
return True
CONFIG_FIELDS: tuple[str, ...] = (
"model_id",
"name",
"prompt",
"workload_type",
"job_type",
"image_path",
"last_image_path",
"references",
"negative_prompt",
"num_inference_steps",
"num_frames",
"height",
"width",
"guidance_scale",
"guidance_rescale",
"fps",
"seed",
"num_gpus",
"dit_cpu_offload",
"dit_layerwise_offload",
"text_encoder_cpu_offload",
"vae_cpu_offload",
"image_encoder_cpu_offload",
"use_fsdp_inference",
"enable_torch_compile",
"vsa_sparsity",
"tp_size",
"sp_size",
"data_path",
"max_train_steps",
"train_batch_size",
"learning_rate",
"num_latent_t",
"validation_dataset_file",
"lora_rank",
"dmd_use_vsa",
"dmd_vsa_sparsity",
"dmd_denoising_steps",
"real_score_guidance_scale",
"generator_update_interval",
"real_score_model_path",
"fake_score_model_path",
)
def duplicate_job(self, job_id: str, new_job_id: str) -> Job:
"""Create a new pending job with an existing job's configuration.
Runtime state (status, timings, logs, outputs) is not carried over.
"""
with self._jobs_lock:
source = self._jobs.get(job_id)
if source is None:
raise ValueError(f"Job {job_id} not found")
config = {f: copy.deepcopy(getattr(source, f)) for f in self.CONFIG_FIELDS}
return self.create_job(job_id=new_job_id, **config)
#: Editable exactly when startable: the same set start_job() accepts.
EDITABLE_STATUSES = (JobStatus.PENDING, JobStatus.FAILED, JobStatus.STOPPED)
def update_job_config(self, job_id: str, updates: dict[str, Any]) -> Job:
"""Edit the configuration of a job that has not produced a result."""
with self._jobs_lock:
job = self._jobs.get(job_id)
if job is None:
raise ValueError(f"Job {job_id} not found")
if job.status not in self.EDITABLE_STATUSES:
allowed = ", ".join(s.value for s in self.EDITABLE_STATUSES)
raise ValueError(f"Job is {job.status.value}; only {allowed} jobs can be edited. "
"Duplicate it instead.")
unknown = set(updates) - set(self.CONFIG_FIELDS)
if unknown:
raise ValueError(f"Not editable: {', '.join(sorted(unknown))}")
for field_name, value in updates.items():
setattr(job, field_name, value)
self._save_job(job)
return job
def start_job(self, job_id: str) -> Job:
"""Start (or restart) a pending / stopped / failed job.
@@ -623,6 +792,8 @@ class JobRunner:
workload_type: str,
num_gpus: int,
dit_cpu_offload: bool = False,
dit_layerwise_offload: bool = False,
override_pipeline_cls_name: str | None = None,
text_encoder_cpu_offload: bool = False,
vae_cpu_offload: bool = False,
image_encoder_cpu_offload: bool = False,
@@ -638,6 +809,10 @@ class JobRunner:
workload_type,
num_gpus,
dit_cpu_offload,
dit_layerwise_offload,
# Ref2VA loads different DiT weights (transformer_ref), so the
# override must key the cache or a t2v/i2v generator gets reused.
override_pipeline_cls_name,
text_encoder_cpu_offload,
vae_cpu_offload,
image_encoder_cpu_offload,
@@ -650,8 +825,21 @@ class JobRunner:
# Generators are cached by model_id and configuration parameters
with self._generators_lock:
if cache_key in self._generators:
return self._generators[cache_key]
cached = self._generators.get(cache_key)
if cached is not None:
if _generator_is_alive(cached):
return cached
# Workers can exit while a generator sits idle in the cache;
# reusing it fails every later job with the same config.
logger.warning(
"Cached generator for %s has dead workers; reloading.",
model_id,
)
self._generators.pop(cache_key, None)
try:
cached.shutdown()
except Exception:
logger.debug("Shutdown of the dead generator failed", exc_info=True)
# Import lazily so starting the server is fast even without a GPU.
from fastvideo import VideoGenerator
@@ -677,6 +865,11 @@ class JobRunner:
gen = VideoGenerator.from_pretrained(
model_id,
workload_type=workload_type,
num_gpus=num_gpus,
dit_layerwise_offload=dit_layerwise_offload,
**({
"override_pipeline_cls_name": override_pipeline_cls_name
} if override_pipeline_cls_name else {}),
dit_cpu_offload=dit_cpu_offload,
text_encoder_cpu_offload=text_encoder_cpu_offload,
vae_cpu_offload=vae_cpu_offload,
@@ -706,10 +899,9 @@ class JobRunner:
def _run_training_job(self, job: Job):
"""Run a finetuning, distillation, or LoRA job via subprocess."""
buf = job._log_buf
os.makedirs(self.log_dir, exist_ok=True)
job.log_file_path = os.path.join(self.log_dir, f"{job.id}.log")
job_output_dir = os.path.join(self.output_dir, job.id)
os.makedirs(job_output_dir, exist_ok=True)
job.log_file_path = _job_log_path(self.output_dir, job.id)
if not job.data_path or not os.path.isdir(job.data_path):
job.status = JobStatus.FAILED
@@ -827,8 +1019,8 @@ class JobRunner:
def _run_inference_job(self, job: Job):
buf = job._log_buf
os.makedirs(self.log_dir, exist_ok=True)
job.log_file_path = os.path.join(self.log_dir, f"{job.id}.log")
os.makedirs(os.path.join(self.output_dir, job.id), exist_ok=True)
job.log_file_path = _job_log_path(self.output_dir, job.id)
# Add file handler to persist logs
file_handler = logging.FileHandler(job.log_file_path, mode='w', encoding='utf-8')
@@ -875,62 +1067,45 @@ class JobRunner:
buf.phase = "loading model"
logger.info("Loading model...")
# Run generator creation in a background thread so we
# can poll _stop_event while the (potentially slow)
# model download / load is in progress.
_gen_result: list[Any] = []
_gen_error: list[BaseException] = []
# The generator MUST be created on this thread: building it spawns
# the executor's worker processes, and they are torn down if the
# creating thread exits. Running it in a helper thread (to poll
# _stop_event during load) made every collective_rpc fail with
# ConnectionResetError.
if job._stop_event.is_set():
job.status = JobStatus.STOPPED
job.finished_at = time.time()
self._save_job(job)
logger.warning("Job %s stopped before model loading", job.id)
buf.phase = "stopped"
return
def _load_generator() -> None:
try:
gen = self._get_or_create_generator(
job.model_id,
job.workload_type,
job.num_gpus,
dit_cpu_offload=job.dit_cpu_offload,
text_encoder_cpu_offload=(job.text_encoder_cpu_offload),
vae_cpu_offload=job.vae_cpu_offload,
image_encoder_cpu_offload=(job.image_encoder_cpu_offload),
use_fsdp_inference=job.use_fsdp_inference,
enable_torch_compile=(job.enable_torch_compile),
vsa_sparsity=job.vsa_sparsity,
tp_size=job.tp_size,
sp_size=job.sp_size,
log_queue=log_queue,
)
_gen_result.append(gen)
except BaseException as exc:
_gen_error.append(exc)
loader = threading.Thread(
target=_load_generator,
daemon=True,
generator = self._get_or_create_generator(
job.model_id,
job.workload_type,
job.num_gpus,
dit_cpu_offload=job.dit_cpu_offload,
dit_layerwise_offload=job.dit_layerwise_offload,
override_pipeline_cls_name=(MINIMAX_H3_REF2VA_PIPELINE if job.references else None),
text_encoder_cpu_offload=(job.text_encoder_cpu_offload),
vae_cpu_offload=job.vae_cpu_offload,
image_encoder_cpu_offload=(job.image_encoder_cpu_offload),
use_fsdp_inference=job.use_fsdp_inference,
enable_torch_compile=(job.enable_torch_compile),
vsa_sparsity=job.vsa_sparsity,
tp_size=job.tp_size,
sp_size=job.sp_size,
log_queue=log_queue,
)
loader.start()
while loader.is_alive():
if job._stop_event.is_set():
job.status = JobStatus.STOPPED
job.finished_at = time.time()
self._save_job(job)
logger.warning(
"Job %s stopped during model loading",
job.id,
)
buf.phase = "stopped"
return
loader.join(timeout=0.5)
if _gen_error:
raise _gen_error[0]
generator = _gen_result[0]
buf.phase = "generating"
logger.info("Starting generation for job %s (model=%s)", job.id, job.model_id)
# Without a name FastVideo derives the filename from the prompt.
safe_name = re.sub(r'[\\/:*?"<>|]+', "", job.name).strip().strip(".")
output_target = (os.path.join(job_output_dir, f"{safe_name[:80]}.mp4") if safe_name else job_output_dir)
gen_kwargs: dict[str, Any] = {
"prompt": job.prompt,
"output_path": job_output_dir,
"output_path": output_target,
"save_video": True,
"num_inference_steps": job.num_inference_steps,
"num_frames": job.num_frames,
@@ -945,6 +1120,12 @@ class JobRunner:
}
if job.image_path:
gen_kwargs["image_path"] = job.image_path
if job.references:
gen_kwargs["references"] = _build_h3_references(job.references)
if job.last_image_path:
# _prepare_fl2va requires a PIL image, not a path.
from PIL import Image as _PILImage
gen_kwargs["last_image"] = _PILImage.open(job.last_image_path)
generator.generate_video(**gen_kwargs)
buf.phase = "saving"
@@ -977,7 +1158,7 @@ class JobRunner:
except Exception as exception:
error_msg = str(exception)
logger.error("Critical error in job thread: %s", error_msg)
logger.exception("Critical error in job thread: %s", error_msg)
job.status = JobStatus.FAILED
job.error = f"Critical error ({type(exception).__name__}): {error_msg}"
job.finished_at = time.time()
@@ -1,15 +1,20 @@
# SPDX-License-Identifier: Apache-2.0
"""Request model for creating a job."""
from typing import Any
from pydantic import BaseModel
class CreateJobRequest(BaseModel):
model_id: str
name: str = ""
prompt: str
workload_type: str = "t2v"
job_type: str = "inference"
image_path: str = ""
last_image_path: str = ""
references: list[dict[str, Any]] | None = None
data_path: str = ""
max_train_steps: int = 1000
train_batch_size: int = 1
@@ -28,6 +33,7 @@ class CreateJobRequest(BaseModel):
seed: int = 1024
num_gpus: int = 1
dit_cpu_offload: bool = False
dit_layerwise_offload: bool = False
text_encoder_cpu_offload: bool = False
vae_cpu_offload: bool = False
image_encoder_cpu_offload: bool = False
+868 -722
View File
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -17,20 +17,11 @@
"start:all": "concurrently --kill-others-on-fail \"npm:start:api\" \"npm:start:web\""
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"next": "15.5.18",
"radix-ui": "^1.6.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sonner": "^2.0.7",
+92 -2
View File
@@ -18,6 +18,7 @@ import argparse
import contextlib
import logging
import os
import re
import shutil
import signal
import time
@@ -116,7 +117,30 @@ def list_models(workload_type: str | None = None) -> list[dict[str, Any]]:
return _available_models
def _safe_upload_name(filename: str | None, ext: str) -> str:
"""A filesystem-safe version of the client's filename, keeping it readable.
Uploads live under a per-file uuid directory, so the basename does not have
to be unique -- only safe. Keeping the original name means the path stays
self-describing wherever it travels: the database, job logs, and payloads
copied back out to the API.
"""
stem = os.path.basename(filename or "").rsplit(".", 1)[0]
stem = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._-")
return f"{stem[:80] or 'upload'}{ext}"
def _upload_destination(ext: str, filename: str | None) -> str:
"""<upload_dir>/<uuid4>/<safe original name><ext>"""
directory = os.path.join(upload_dir, uuid.uuid4().hex)
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, _safe_upload_name(filename, ext))
ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
ALLOWED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".m4a", ".ogg"}
ALLOWED_MEDIA_EXTENSIONS = (ALLOWED_IMAGE_EXTENSIONS | ALLOWED_VIDEO_EXTENSIONS | ALLOWED_AUDIO_EXTENSIONS)
@app.post("/api/upload-image")
@@ -136,8 +160,7 @@ async def upload_image(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
f"{', '.join(ALLOWED_IMAGE_EXTENSIONS)}"),
)
os.makedirs(upload_dir, exist_ok=True)
unique_name = f"{uuid.uuid4().hex}{ext}"
dest_path = os.path.join(upload_dir, unique_name)
dest_path = _upload_destination(ext, file.filename)
try:
contents = await file.read()
with open(dest_path, "wb") as f:
@@ -150,6 +173,47 @@ async def upload_image(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
return {"path": os.path.abspath(dest_path)}
@app.post("/api/upload-media")
async def upload_media(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
"""Upload an image, video or audio file for Ref2VA references.
Returns the absolute path plus the media_type MiniMax-H3 expects, so the
caller does not have to re-derive it from the extension.
"""
global upload_dir # noqa: PLW0603
if not upload_dir:
raise HTTPException(
status_code=503,
detail="Upload directory not configured",
)
ext = Path(file.filename or "").suffix.lower()
if ext not in ALLOWED_MEDIA_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=(f"Invalid file type. Allowed: "
f"{', '.join(sorted(ALLOWED_MEDIA_EXTENSIONS))}"),
)
if ext in ALLOWED_VIDEO_EXTENSIONS:
media_type = "video"
elif ext in ALLOWED_AUDIO_EXTENSIONS:
media_type = "audio"
else:
media_type = "image"
os.makedirs(upload_dir, exist_ok=True)
dest_path = _upload_destination(ext, file.filename)
try:
contents = await file.read()
with open(dest_path, "wb") as f:
f.write(contents)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to save upload: {e}",
) from e
return {"path": os.path.abspath(dest_path), "media_type": media_type}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
@@ -282,10 +346,13 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
job = job_runner.create_job(
job_id=str(uuid.uuid4()),
model_id=req.model_id,
name=req.name or "",
prompt=req.prompt,
workload_type=req.workload_type or "t2v",
job_type=job_type,
image_path=req.image_path or "",
last_image_path=req.last_image_path or "",
references=req.references or [],
data_path=data_path,
max_train_steps=req.max_train_steps,
train_batch_size=req.train_batch_size,
@@ -304,6 +371,7 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
seed=req.seed,
num_gpus=req.num_gpus,
dit_cpu_offload=req.dit_cpu_offload,
dit_layerwise_offload=req.dit_layerwise_offload,
text_encoder_cpu_offload=req.text_encoder_cpu_offload,
vae_cpu_offload=req.vae_cpu_offload,
image_encoder_cpu_offload=req.image_encoder_cpu_offload,
@@ -336,6 +404,28 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
return job.to_dict()
@app.post("/api/jobs/{job_id}/duplicate", status_code=201)
def duplicate_job(job_id: str) -> dict[str, Any]:
"""Create a new pending job with the same configuration as an existing one."""
try:
job = job_runner.duplicate_job(job_id, str(uuid.uuid4()))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return job.to_dict()
@app.patch("/api/jobs/{job_id}")
def update_job(job_id: str, updates: dict[str, Any]) -> dict[str, Any]:
"""Edit a pending job's configuration. Started jobs cannot be edited."""
try:
job = job_runner.update_job_config(job_id, updates)
except ValueError as e:
detail = str(e)
status = 404 if "not found" in detail else 400
raise HTTPException(status_code=status, detail=detail) from e
return job.to_dict()
@app.post("/api/jobs/{job_id}/start")
def start_job(job_id: str) -> dict[str, Any]:
"""Start (or restart) a pending / stopped / failed job."""
@@ -1,24 +1,26 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import CreateJobButton from './CreateJobButton';
import { getDatasets, getModels } from '@/lib/api';
vi.mock('./CreateJobModal', () => ({
default: ({
isOpen,
workloadType,
}: {
isOpen: boolean;
workloadType: string;
}) =>
isOpen ? (
<div role="dialog" data-workload-type={workloadType}>
Create job form
</div>
) : null,
vi.mock('@/lib/api', () => ({
createJob: vi.fn(),
getModels: vi.fn(),
getDatasets: vi.fn(),
uploadImage: vi.fn(),
getSettings: vi.fn(),
updateSettings: vi.fn(),
}));
beforeEach(() => {
vi.mocked(getModels).mockResolvedValue([
{ id: 'wan/t2v-1.3b', label: 'Wan T2V' },
]);
vi.mocked(getDatasets).mockResolvedValue([]);
});
describe('CreateJobButton', () => {
it('opens the workload menu on click and selects an item', async () => {
const user = userEvent.setup();
@@ -27,10 +29,9 @@ describe('CreateJobButton', () => {
await user.click(screen.getByRole('button', { name: 'Create Job' }));
await user.click(screen.getByRole('menuitem', { name: /I2V/i }));
expect(screen.getByRole('dialog')).toHaveAttribute(
'data-workload-type',
'i2v',
);
expect(
screen.getByRole('dialog', { name: 'New Inference Job (I2V)' }),
).toBeInTheDocument();
});
it('opens and operates the workload menu from the keyboard', async () => {
@@ -45,9 +46,42 @@ describe('CreateJobButton', () => {
expect(firstItem).toHaveFocus();
await user.keyboard('{Enter}');
expect(screen.getByRole('dialog')).toHaveAttribute(
'data-workload-type',
't2v',
expect(
screen.getByRole('dialog', { name: 'New Inference Job (T2V)' }),
).toBeInTheDocument();
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(document.body.style.pointerEvents).not.toBe('none'),
);
expect(trigger).toHaveFocus();
});
it.each(['inference', 'finetuning', 'distillation'] as const)(
'restores page interaction after closing the real %s dialog',
async (jobType) => {
const user = userEvent.setup();
render(<CreateJobButton jobType={jobType} />);
const trigger = screen.getByRole('button', { name: 'Create Job' });
// Keep the real Dialog mounted: mocking it hides conflicting Radix layers.
for (let attempt = 0; attempt < 2; attempt++) {
await user.click(trigger);
await user.click(screen.getAllByRole('menuitem')[0]);
const dialog = screen.getByRole('dialog');
await user.click(
within(dialog).getByRole('button', { name: 'Close' }),
);
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(document.body.style.pointerEvents).not.toBe('none'),
);
expect(trigger).toHaveFocus();
}
},
);
});
@@ -2,7 +2,7 @@
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { DropdownMenu } from 'radix-ui';
import CreateJobModal from '@/components/jobs/CreateJobModal';
import { Button } from '@/components/ui/button';
@@ -16,6 +16,7 @@ interface CreateJobButtonProps {
export default function CreateJobButton({ jobType }: CreateJobButtonProps) {
const options = WORKLOAD_OPTIONS[jobType] ?? [];
const triggerRef = React.useRef<HTMLButtonElement>(null);
const [modalOpen, setModalOpen] = React.useState(false);
const [workloadType, setWorkloadType] = React.useState(
@@ -36,7 +37,7 @@ export default function CreateJobButton({ jobType }: CreateJobButtonProps) {
<>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button type="button" className="gap-1.5">
<Button ref={triggerRef} type="button" className="gap-1.5">
Create Job
<ChevronDown className="size-3.5 opacity-85" aria-hidden />
</Button>
@@ -66,6 +67,11 @@ export default function CreateJobButton({ jobType }: CreateJobButtonProps) {
<CreateJobModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onCloseAutoFocus={(event) => {
// This dialog opens from a menu item, so it has no DialogTrigger.
event.preventDefault();
triggerRef.current?.focus();
}}
onSuccess={handleSuccess}
jobType={jobType}
workloadType={workloadType}
@@ -22,30 +22,60 @@ import { useStore } from '@/hooks/useStore';
import { defaultOptionsStore } from '@/stores/defaultOptions';
import {
createJob,
updateJob,
getDatasets,
getModels,
uploadImage,
uploadMedia,
type CreateJobRequest,
type Model,
} from '@/lib/api';
import { getDefaultModelForWorkload } from '@/lib/defaultOptions';
import { WORKLOAD_OPTIONS } from '@/lib/jobConfig';
import type { JobType } from '@/lib/types';
import {
H3_MAX_REFERENCES,
labelReferences,
referencePromptSeed,
validateReferences,
type H3Reference,
} from '@/lib/h3References';
import {
EMPTY_H3_PROMPT_FIELDS,
H3_PROMPT_SECTIONS,
H3_SECTION_HINTS,
H3_SECTION_LABELS,
isEmptyPromptFields,
parseH3Prompt,
serializeH3Prompt,
type H3PromptFields,
} from '@/lib/h3Prompt';
import { jobToFormFields, type JobLike } from '@/lib/jobToFields';
export interface CreateJobModalProps {
isOpen: boolean;
onClose: () => void;
onCloseAutoFocus?: React.ComponentProps<
typeof DialogContent
>['onCloseAutoFocus'];
onSuccess: () => void;
jobType: JobType;
workloadType: string;
/** When set, the modal edits this pending job instead of creating a new one. */
editingJob?: JobLike | null;
/** Show the configuration without allowing changes (started/finished jobs). */
readOnly?: boolean;
}
export default function CreateJobModal({
isOpen,
onClose,
onCloseAutoFocus,
onSuccess,
jobType,
workloadType,
editingJob,
readOnly = false,
}: CreateJobModalProps) {
const { options } = useStore(defaultOptionsStore);
@@ -55,8 +85,22 @@ export default function CreateJobModal({
const [models, setModels] = React.useState<Model[]>([]);
const [modelId, setModelId] = React.useState('');
const [name, setName] = React.useState('');
const [prompt, setPrompt] = React.useState('');
const [imagePath, setImagePath] = React.useState('');
const [lastImagePath, setLastImagePath] = React.useState('');
const [references, setReferences] = React.useState<H3Reference[]>([]);
const [isUploadingReference, setIsUploadingReference] = React.useState(false);
const [referenceError, setReferenceError] = React.useState<string | null>(null);
const [promptFields, setPromptFields] = React.useState<H3PromptFields>(
EMPTY_H3_PROMPT_FIELDS,
);
const [useGuidedPrompt, setUseGuidedPrompt] = React.useState(true);
const [lastImageFileName, setLastImageFileName] = React.useState('');
const [isUploadingLastImage, setIsUploadingLastImage] = React.useState(false);
const [lastImageUploadError, setLastImageUploadError] = React.useState<
string | null
>(null);
const [imageFileName, setImageFileName] = React.useState('');
const [isUploadingImage, setIsUploadingImage] = React.useState(false);
const [negativePrompt, setNegativePrompt] = React.useState('');
@@ -70,12 +114,50 @@ export default function CreateJobModal({
const [seed, setSeed] = React.useState(1024);
const [numGpus, setNumGpus] = React.useState(1);
const [ditCpuOffload, setDitCpuOffload] = React.useState(false);
const [ditLayerwiseOffload, setDitLayerwiseOffload] = React.useState(false);
const [textEncoderCpuOffload, setTextEncoderCpuOffload] =
React.useState(false);
const [vaeCpuOffload, setVaeCpuOffload] = React.useState(false);
const [imageEncoderCpuOffload, setImageEncoderCpuOffload] =
React.useState(false);
const [useFsdpInference, setUseFsdpInference] = React.useState(false);
// H3 is the only registered model with an end frame or references.
const supportsLastImage = modelId.toLowerCase().includes('minimax-h3');
const usingReferences = supportsLastImage && references.length > 0;
// JobCard re-renders on every job-list poll, so `editingJob` is a fresh
// object each time. Effects must depend on these, never on the object.
const editingJobId = editingJob?.id ?? null;
const editingJobModelId = editingJob?.model_id ?? null;
// Layerwise offload and FSDP compete for the DiT weights and FastVideoArgs
// silently picks a winner (fastvideo_args.py:859); resolve it visibly here.
// dit_cpu_offload is deliberately not interlocked -- it is a modifier, not a
// competing strategy.
const handleDitLayerwiseOffloadChange = React.useCallback((next: boolean) => {
setDitLayerwiseOffload(next);
if (next) {
setUseFsdpInference(false);
}
}, []);
const handleUseFsdpInferenceChange = React.useCallback((next: boolean) => {
setUseFsdpInference(next);
if (next) {
setDitLayerwiseOffload(false);
}
}, []);
const handleNumGpusChange = React.useCallback((next: number) => {
setNumGpus(next);
if (next > 1) {
// Dropping back to one GPU leaves FSDP alone: single-GPU FSDP is a
// valid way to reach its CPU offload (docs/inference/offloading.md).
setUseFsdpInference(true);
setDitLayerwiseOffload(false);
}
}, []);
const [enableTorchCompile, setEnableTorchCompile] = React.useState(false);
const [vsaSparsity, setVsaSparsity] = React.useState(0);
const [tpSize, setTpSize] = React.useState(-1);
@@ -126,6 +208,47 @@ export default function CreateJobModal({
const justOpened = isOpen && !justOpenedRef.current;
justOpenedRef.current = isOpen;
if (!justOpened) return;
if (editingJob) {
// Must not fall through to the defaults below: a partially-seeded
// form silently edits values the user never saw.
const f = jobToFormFields(editingJob);
setModelId(f.modelId);
setName(f.name);
setPrompt(f.prompt);
setNegativePrompt(f.negativePrompt);
setImagePath(f.imagePath);
setImageFileName(f.imagePath.split('/').pop() ?? '');
setLastImagePath(f.lastImagePath);
setLastImageFileName(f.lastImagePath.split('/').pop() ?? '');
setReferences(f.references);
setPromptFields(f.promptFields ?? EMPTY_H3_PROMPT_FIELDS);
setUseGuidedPrompt(f.promptFields !== null);
setNumInferenceSteps(f.numInferenceSteps);
setNumFrames(f.numFrames);
setHeight(f.height);
setWidth(f.width);
setGuidanceScale(f.guidanceScale);
setGuidanceRescale(f.guidanceRescale);
setFps(f.fps);
setSeed(f.seed);
setNumGpus(f.numGpus);
setDitCpuOffload(f.ditCpuOffload);
setDitLayerwiseOffload(f.ditLayerwiseOffload);
setTextEncoderCpuOffload(f.textEncoderCpuOffload);
setVaeCpuOffload(f.vaeCpuOffload);
setImageEncoderCpuOffload(f.imageEncoderCpuOffload);
setUseFsdpInference(f.useFsdpInference);
setEnableTorchCompile(f.enableTorchCompile);
setVsaSparsity(f.vsaSparsity);
setTpSize(f.tpSize);
setSpSize(f.spSize);
setReferenceError(null);
setModelLoadError(null);
setImageUploadError(null);
setLastImageUploadError(null);
setSubmitError(null);
return;
}
const opts = options;
setNumInferenceSteps(opts.numInferenceSteps);
setNumFrames(workloadType === 't2i' ? 1 : opts.numFrames);
@@ -137,6 +260,7 @@ export default function CreateJobModal({
setSeed(opts.seed);
setNumGpus(opts.numGpus);
setDitCpuOffload(opts.ditCpuOffload);
setDitLayerwiseOffload(opts.ditLayerwiseOffload ?? false);
setTextEncoderCpuOffload(opts.textEncoderCpuOffload);
setVaeCpuOffload(opts.vaeCpuOffload);
setImageEncoderCpuOffload(opts.imageEncoderCpuOffload);
@@ -151,8 +275,16 @@ export default function CreateJobModal({
inferenceWorkload as 't2v' | 'i2v' | 't2i',
),
);
setName('');
setImagePath('');
setImageFileName('');
setLastImagePath('');
setLastImageFileName('');
setLastImageUploadError(null);
setReferences([]);
setReferenceError(null);
setPromptFields(EMPTY_H3_PROMPT_FIELDS);
setUseGuidedPrompt(true);
setSelectedDatasetId('');
setSelectedValidationDatasetId('');
setModelLoadError(null);
@@ -168,7 +300,7 @@ export default function CreateJobModal({
setRealScoreModelPath('');
setFakeScoreModelPath('');
}
}, [isOpen, workloadType, inferenceWorkload, options]);
}, [isOpen, workloadType, inferenceWorkload, options, editingJobId]);
// Load the models available for this workload.
React.useEffect(() => {
@@ -188,7 +320,16 @@ export default function CreateJobModal({
opts,
inferenceWorkload as 't2v' | 'i2v' | 't2i',
);
const chosen = ids.includes(defaultId) ? defaultId : (list[0]?.id ?? '');
// When editing, the job's own model wins over the workload default --
// this resolves after the seeding effect, so choosing a default here
// would silently swap the model out from under the user.
const editedId = editingJobModelId;
const chosen =
editedId && ids.includes(editedId)
? editedId
: ids.includes(defaultId)
? defaultId
: (list[0]?.id ?? '');
setModelId(chosen);
if (workloadType === 'dmd_t2v') {
setRealScoreModelPath(chosen);
@@ -210,7 +351,7 @@ export default function CreateJobModal({
return () => {
stale = true;
};
}, [isOpen, inferenceWorkload, workloadType]);
}, [isOpen, inferenceWorkload, workloadType, editingJobModelId]);
// Training jobs need a dataset; load the ready datasets when relevant.
React.useEffect(() => {
@@ -262,6 +403,106 @@ export default function CreateJobModal({
}
}
async function handleLastImageChange(
e: React.ChangeEvent<HTMLInputElement>,
) {
const file = e.target.files?.[0];
if (!file) {
setLastImagePath('');
setLastImageFileName('');
setLastImageUploadError(null);
return;
}
setIsUploadingLastImage(true);
setLastImageFileName(file.name);
setLastImageUploadError(null);
try {
const { path } = await uploadImage(file);
setLastImagePath(path);
} catch (error) {
console.error('Failed to upload end image:', error);
setLastImagePath('');
setLastImageFileName('');
setLastImageUploadError(
error instanceof Error
? `${error.message}. Choose the image again to retry.`
: 'The image could not be uploaded. Choose it again to retry.',
);
} finally {
setIsUploadingLastImage(false);
}
}
async function handleAddReference(
e: React.ChangeEvent<HTMLInputElement>,
) {
const file = e.target.files?.[0];
e.target.value = ''; // allow re-picking the same file
if (!file) return;
setIsUploadingReference(true);
setReferenceError(null);
try {
const { path, media_type } = await uploadMedia(file);
const next: H3Reference[] = [
...references,
{
id: `${Date.now()}-${file.name}`,
source: path,
media_type,
fileName: file.name,
},
];
setReferences(next);
setReferenceError(validateReferences(next));
} catch (error) {
console.error('Failed to upload reference:', error);
setReferenceError(
error instanceof Error ? error.message : 'The file could not be uploaded.',
);
} finally {
setIsUploadingReference(false);
}
}
function removeReference(id: string) {
const next = references.filter((r) => r.id !== id);
setReferences(next);
setReferenceError(validateReferences(next));
}
function seedPromptFields() {
setPromptFields({
...EMPTY_H3_PROMPT_FIELDS,
...referencePromptSeed(references),
});
setUseGuidedPrompt(true);
}
function setPromptField(section: string, value: string) {
setPromptFields((prev) => ({ ...prev, [section]: value }));
}
// Switching between the guided fields and the raw editor keeps whatever was
// typed: serialize on the way out, parse back on the way in.
function toggleGuidedPrompt() {
if (useGuidedPrompt) {
if (!isEmptyPromptFields(promptFields)) {
setPrompt(serializeH3Prompt(promptFields));
}
setUseGuidedPrompt(false);
} else {
const parsed = parseH3Prompt(prompt);
if (parsed) setPromptFields(parsed);
setUseGuidedPrompt(true);
}
}
function clearLastImage() {
setLastImagePath('');
setLastImageFileName('');
setLastImageUploadError(null);
}
function clearImage() {
setImagePath('');
setImageFileName('');
@@ -271,7 +512,16 @@ export default function CreateJobModal({
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (isInference && workloadType === 'i2v' && !imagePath) return;
if (isInference && workloadType === 'i2v' && !imagePath && !usingReferences)
return;
if (usingReferences && validateReferences(references)) return;
if (
usingReferences &&
useGuidedPrompt &&
isEmptyPromptFields(promptFields) &&
!prompt.trim()
)
return;
// Send the dataset id; the backend resolves it to the on-disk media dir.
const effectiveDataPath = selectedDatasetId ?? '';
if (!isInference && !selectedDatasetId) return;
@@ -285,14 +535,35 @@ export default function CreateJobModal({
try {
const payload: CreateJobRequest = {
model_id: modelId,
prompt,
name: name.trim(),
prompt:
usingReferences && useGuidedPrompt && !isEmptyPromptFields(promptFields)
? serializeH3Prompt(promptFields)
: prompt,
workload_type: workloadType,
job_type: effectiveJobType,
...(isInference
? {
...(workloadType === 'i2v' && imagePath
// Ref2VA and the FL2VA keyframes are mutually exclusive:
// _prepare_ref2va rejects image_path/last_image_path outright
// when references are present.
...(workloadType === 'i2v' && !usingReferences && imagePath
? { image_path: imagePath }
: {}),
...(workloadType === 'i2v' &&
supportsLastImage &&
!usingReferences &&
lastImagePath
? { last_image_path: lastImagePath }
: {}),
...(workloadType === 'i2v' && supportsLastImage && references.length
? {
references: references.map((r) => ({
source: r.source,
media_type: r.media_type,
})),
}
: {}),
negative_prompt: negativePrompt,
num_inference_steps: numInferenceSteps,
num_frames: numFrames,
@@ -304,6 +575,7 @@ export default function CreateJobModal({
seed,
num_gpus: numGpus,
dit_cpu_offload: ditCpuOffload,
dit_layerwise_offload: ditLayerwiseOffload,
text_encoder_cpu_offload: textEncoderCpuOffload,
vae_cpu_offload: vaeCpuOffload,
image_encoder_cpu_offload: imageEncoderCpuOffload,
@@ -334,7 +606,14 @@ export default function CreateJobModal({
: {}),
}),
};
await createJob(payload);
if (editingJob) {
await updateJob(
editingJob.id,
payload as unknown as Record<string, unknown>,
);
} else {
await createJob(payload);
}
onSuccess();
onClose();
} catch (err) {
@@ -356,9 +635,9 @@ export default function CreateJobModal({
const workloadLabel =
WORKLOAD_OPTIONS[jobType]?.find((o) => o.type === workloadType)?.label ?? '';
const title = `New ${jobType.charAt(0).toUpperCase() + jobType.slice(1)} Job${
workloadLabel ? ` (${workloadLabel})` : ''
}`;
const title = `${readOnly ? 'View' : editingJob ? 'Edit' : 'New'} ${
jobType.charAt(0).toUpperCase() + jobType.slice(1)
} Job${workloadLabel ? ` (${workloadLabel})` : ''}`;
return (
<Dialog
@@ -369,6 +648,7 @@ export default function CreateJobModal({
>
<DialogContent
className="max-h-[90vh] w-[90vw] max-w-[850px] overflow-y-auto"
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={(e) => {
if (isSubmitting) e.preventDefault();
}}
@@ -385,6 +665,23 @@ export default function CreateJobModal({
autoComplete="off"
className="flex flex-col gap-3.5"
>
{/* disabled cascades to every control inside; display:contents
keeps the parent's flex layout. */}
<fieldset
disabled={readOnly}
style={{ display: 'contents' }}
className="contents"
>
<FieldRow htmlFor="modal-name" label="Name (optional)">
<Input
id="modal-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Shown on the job card and used for the output filename"
disabled={isSubmitting}
/>
</FieldRow>
<FieldRow htmlFor="modal-modelId" label="Model">
<NativeSelect
id="modal-modelId"
@@ -429,12 +726,12 @@ export default function CreateJobModal({
type="file"
accept=".png,.jpg,.jpeg,.webp,.bmp"
onChange={handleImageChange}
disabled={isSubmitting || isUploadingImage}
disabled={isSubmitting || isUploadingImage || usingReferences}
aria-describedby={
imageUploadError ? 'modal-image-error' : undefined
}
aria-invalid={imageUploadError ? true : undefined}
required
required={!usingReferences}
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
/>
{imageFileName && (
@@ -462,24 +759,169 @@ export default function CreateJobModal({
</FieldRow>
)}
<FieldRow
htmlFor="modal-prompt"
label={isInference ? 'Prompt' : 'Description'}
>
<Textarea
id="modal-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={isInference ? 3 : 2}
placeholder={
isInference
? 'A curious raccoon peers through a vibrant field of yellow sunflowers…'
: 'Brief description of this training job…'
}
required
disabled={isSubmitting}
/>
</FieldRow>
{isInference && workloadType === 'i2v' && supportsLastImage && (
<FieldRow htmlFor="modal-last-image" label="End Frame (optional)">
<Input
id="modal-last-image"
type="file"
accept=".png,.jpg,.jpeg,.webp,.bmp"
onChange={handleLastImageChange}
disabled={isSubmitting || isUploadingLastImage}
aria-describedby={
lastImageUploadError ? 'modal-last-image-error' : undefined
}
aria-invalid={lastImageUploadError ? true : undefined}
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
/>
{lastImageFileName && (
<span className="mt-0.5 text-xs text-muted-foreground">
{isUploadingLastImage ? 'Uploading…' : lastImageFileName} ·{' '}
<button
type="button"
onClick={clearLastImage}
disabled={isSubmitting || isUploadingLastImage}
className="text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Clear
</button>
</span>
)}
{lastImageUploadError && (
<p
id="modal-last-image-error"
role="alert"
className="text-sm text-destructive"
>
{lastImageUploadError}
</p>
)}
</FieldRow>
)}
{isInference && workloadType === 'i2v' && supportsLastImage && (
<FieldRow htmlFor="modal-reference" label="References (Ref2VA)">
<Input
id="modal-reference"
type="file"
accept=".png,.jpg,.jpeg,.webp,.bmp,.mp4,.mov,.mkv,.webm,.avi,.wav,.mp3,.flac,.m4a,.ogg"
onChange={handleAddReference}
disabled={
isSubmitting ||
isUploadingReference ||
references.length >= H3_MAX_REFERENCES
}
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
/>
{isUploadingReference && (
<span className="mt-0.5 text-xs text-muted-foreground">
Uploading…
</span>
)}
{references.length > 0 && (
<ul className="mt-1 flex list-none flex-col gap-1 p-0">
{references.map((reference, index) => (
<li
key={reference.id}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<code className="font-mono text-accent-blue">
{labelReferences(references)[index]}
</code>
<span className="truncate">{reference.fileName}</span>
<button
type="button"
onClick={() => removeReference(reference.id)}
disabled={isSubmitting}
className="ml-auto text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Remove
</button>
</li>
))}
</ul>
)}
{references.length > 0 && (
<button
type="button"
onClick={seedPromptFields}
disabled={isSubmitting}
className="mt-1 self-start text-xs text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Fill prompt sections from references
</button>
)}
{referenceError && (
<p role="alert" className="mt-0.5 text-xs text-destructive">
{referenceError}
</p>
)}
<span className="mt-0.5 text-xs text-muted-foreground">
Ref2VA replaces the keyframes: up to 9 images, 3 videos, 3 audio
(12 total). Audio needs at least one image or video.
</span>
</FieldRow>
)}
{usingReferences && useGuidedPrompt ? (
/* Six-section format from the model's reference prompt guide. */
<>
{H3_PROMPT_SECTIONS.map((section) => (
<FieldRow
key={section}
htmlFor={`modal-prompt-${section}`}
label={H3_SECTION_LABELS[section]}
>
<Textarea
id={`modal-prompt-${section}`}
value={promptFields[section]}
onChange={(e) => setPromptField(section, e.target.value)}
rows={section === 'detailed_description' ? 5 : 2}
placeholder={H3_SECTION_HINTS[section]}
disabled={isSubmitting}
/>
</FieldRow>
))}
<button
type="button"
onClick={toggleGuidedPrompt}
disabled={isSubmitting}
className="self-start text-xs text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Edit as raw prompt
</button>
</>
) : (
<>
<FieldRow
htmlFor="modal-prompt"
label={isInference ? 'Prompt' : 'Description'}
>
<Textarea
id="modal-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={isInference ? 3 : 2}
placeholder={
isInference
? 'A curious raccoon peers through a vibrant field of yellow sunflowers…'
: 'Brief description of this training job…'
}
required={!(usingReferences && useGuidedPrompt)}
disabled={isSubmitting}
/>
</FieldRow>
{usingReferences && (
<button
type="button"
onClick={toggleGuidedPrompt}
disabled={isSubmitting}
className="self-start text-xs text-accent-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
Edit as prompt sections
</button>
)}
</>
)}
{isInference && (
<FieldRow htmlFor="modal-negative-prompt" label="Negative Prompt">
@@ -849,6 +1291,13 @@ export default function CreateJobModal({
onChange={setDitCpuOffload}
disabled={isSubmitting}
/>
<ToggleRow
id="modal-dit-layerwise-offload"
label="DiT Layerwise Offload"
checked={ditLayerwiseOffload}
onChange={handleDitLayerwiseOffloadChange}
disabled={isSubmitting}
/>
<ToggleRow
id="modal-text-encoder-cpu-offload"
label="Text Encoder CPU Offload"
@@ -860,7 +1309,7 @@ export default function CreateJobModal({
id="modal-use-fsdp-inference"
label="Use FSDP Inference"
checked={useFsdpInference}
onChange={setUseFsdpInference}
onChange={handleUseFsdpInferenceChange}
disabled={isSubmitting}
/>
<ToggleRow
@@ -891,7 +1340,7 @@ export default function CreateJobModal({
max={8}
step={1}
value={numGpus}
onChange={setNumGpus}
onChange={handleNumGpusChange}
disabled={isSubmitting}
/>
<NumberRow
@@ -906,6 +1355,8 @@ export default function CreateJobModal({
</details>
)}
</fieldset>
<div className="flex flex-col items-start gap-2">
{submitError && (
<p role="alert" className="text-sm text-destructive">
@@ -914,14 +1365,22 @@ export default function CreateJobModal({
)}
<Button
type="submit"
hidden={readOnly}
disabled={
readOnly ||
isSubmitting ||
isUploadingImage ||
!!modelLoadError ||
!!datasetLoadError
}
>
{isSubmitting ? 'Creating…' : 'Create Job'}
{isSubmitting
? editingJob
? 'Saving…'
: 'Creating…'
: editingJob
? 'Save Changes'
: 'Create Job'}
</Button>
</div>
</form>
@@ -3,11 +3,13 @@
import * as React from 'react';
import { Timer } from 'lucide-react';
import CreateJobModal from '@/components/jobs/CreateJobModal';
import { Badge, type BadgeProps } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useStore } from '@/hooks/useStore';
import {
deleteJob,
duplicateJob,
downloadJobVideo,
startJob,
stopJob,
@@ -62,6 +64,8 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
const isSelected = activeJobId === job.id;
const [isLoading, setIsLoading] = React.useState(false);
const [isEditing, setIsEditing] = React.useState(false);
const [isViewing, setIsViewing] = React.useState(false);
const [currentTime, setCurrentTime] = React.useState(() => Date.now());
const elapsedTime = computeElapsed(job, currentTime);
@@ -103,6 +107,21 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
}
}
async function handleDuplicate(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isLoading) return;
setIsLoading(true);
try {
await duplicateJob(job.id);
onJobUpdated?.();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to duplicate job');
} finally {
setIsLoading(false);
}
}
async function handleDelete(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
@@ -156,16 +175,23 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
>
<span className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[0.95rem] font-semibold text-foreground">
{job.model_id}
{job.name?.trim() || job.model_id}
</span>
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>
{job.status}
</Badge>
</span>
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
{job.prompt}
{job.name?.trim() ? `${job.model_id} · ${job.prompt}` : job.prompt}
</span>
<span className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{/* Short job id; logs and output dirs are keyed on the full UUID. */}
<span
className="font-mono text-muted-foreground/80"
title={job.id}
>
{job.id.slice(0, 8)}
</span>
{job.job_type === 'inference' ? (
<>
<span>{job.num_frames} frames</span>
@@ -226,6 +252,51 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
Download Video
</Button>
)}
{!(
job.status === 'pending' ||
job.status === 'failed' ||
job.status === 'stopped'
) && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsViewing(true);
}}
disabled={isLoading}
title="View this job's configuration"
>
View
</Button>
)}
{(job.status === 'pending' ||
job.status === 'failed' ||
job.status === 'stopped') && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsEditing(true);
}}
disabled={isLoading}
title="Edit this job's configuration"
>
Edit
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handleDuplicate}
disabled={isLoading}
title="Create a new pending job with this configuration"
>
Duplicate
</Button>
<Button
size="sm"
variant="destructive"
@@ -235,6 +306,30 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
Delete
</Button>
</div>
{isViewing && (
<CreateJobModal
isOpen
readOnly
editingJob={job}
jobType={(job.job_type ?? 'inference') as never}
workloadType={job.workload_type ?? 't2v'}
onClose={() => setIsViewing(false)}
onSuccess={() => setIsViewing(false)}
/>
)}
{isEditing && (
<CreateJobModal
isOpen
editingJob={job}
jobType={(job.job_type ?? 'inference') as never}
workloadType={job.workload_type ?? 't2v'}
onClose={() => setIsEditing(false)}
onSuccess={() => {
setIsEditing(false);
onJobUpdated?.();
}}
/>
)}
</article>
);
}
@@ -153,9 +153,16 @@ export default function JobDetailsSidebar({
}}
>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="m-0 text-base font-semibold text-foreground">
Job Details
</h2>
<div className="min-w-0">
<h2 className="m-0 text-base font-semibold text-foreground">
Job Details
</h2>
{/* Full job id: keys ~/h3_studio_logs/<id>.log, the output directory
and every API route, so make it selectable for copy/paste. */}
<code className="mt-0.5 block select-all truncate font-mono text-xs text-muted-foreground">
{job.id}
</code>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
@@ -1,5 +1,7 @@
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Button } from './button';
import { Input } from './input';
@@ -8,6 +10,24 @@ import { Slider } from './slider';
import { Switch } from './switch';
describe('shared control accessibility', () => {
it('forwards refs and click handlers to the asChild button', async () => {
const user = userEvent.setup();
const ref = React.createRef<HTMLButtonElement>();
const onClick = vi.fn();
render(
<Button asChild ref={ref} onClick={onClick}>
<button type="button">Slotted action</button>
</Button>,
);
const button = screen.getByRole('button', { name: 'Slotted action' });
expect(screen.getAllByRole('button')).toHaveLength(1);
expect(ref.current).toBe(button);
await user.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
expect(button).toHaveFocus();
});
it('keeps button, input, and select targets at least 44px tall', () => {
render(
<>
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { Slot } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
@@ -37,7 +37,7 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const Comp = asChild ? Slot.Root : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
});
Button.displayName = "Button";
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Dialog as DialogPrimitive } from 'radix-ui';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Label as LabelPrimitive } from 'radix-ui';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Select as SelectPrimitive } from 'radix-ui';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { Separator as SeparatorPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { Slider as SliderPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { Switch as SwitchPrimitives } from 'radix-ui';
import { cn } from '@/lib/utils';
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { Tabs as TabsPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
+61
View File
@@ -56,6 +56,8 @@ export function getJobVideoUrl(jobId: string): string {
export interface CreateJobRequest {
model_id: string;
/** Optional label; the card and output filename fall back to the prompt. */
name?: string;
prompt: string;
workload_type?: string;
job_type?: JobType;
@@ -152,6 +154,65 @@ export async function updateSettings(
return response.json();
}
export type MediaType = "image" | "video" | "audio";
/**
* Upload an image, video or audio file for a MiniMax-H3 Ref2VA reference.
* The server derives media_type from the extension and returns it, so callers
* do not have to duplicate that mapping.
*/
/** Create a new pending job with the same configuration as an existing one. */
export async function duplicateJob(jobId: string): Promise<{ id: string }> {
const baseApiUrl = getApiBaseUrl();
const response = await fetch(`${baseApiUrl}/jobs/${jobId}/duplicate`, {
method: "POST",
});
if (!response.ok) {
const err = await response
.json()
.catch(() => ({ detail: "Duplicate failed" }));
throw new Error(err.detail || "Duplicate failed");
}
return response.json();
}
/** Edit a pending job's configuration. Started jobs are rejected by the API. */
export async function updateJob(
jobId: string,
updates: Record<string, unknown>,
): Promise<unknown> {
const baseApiUrl = getApiBaseUrl();
const response = await fetch(`${baseApiUrl}/jobs/${jobId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: "Update failed" }));
throw new Error(err.detail || "Update failed");
}
return response.json();
}
export async function uploadMedia(
file: File,
): Promise<{ path: string; media_type: MediaType }> {
const baseApiUrl = getApiBaseUrl();
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${baseApiUrl}/upload-media`, {
method: "POST",
body: formData,
});
if (!response.ok) {
const err = await response
.json()
.catch(() => ({ detail: "Upload failed" }));
throw new Error(err.detail || "Upload failed");
}
return response.json();
}
export async function uploadImage(file: File): Promise<{ path: string }> {
const baseApiUrl = getApiBaseUrl();
const formData = new FormData();
@@ -16,6 +16,7 @@ export interface DefaultOptions {
seed: number;
numGpus: number;
ditCpuOffload: boolean;
ditLayerwiseOffload: boolean;
textEncoderCpuOffload: boolean;
vaeCpuOffload: boolean;
imageEncoderCpuOffload: boolean;
@@ -44,6 +45,7 @@ export const DEFAULT_OPTIONS: DefaultOptions = {
seed: 1024,
numGpus: 1,
ditCpuOffload: false,
ditLayerwiseOffload: false,
textEncoderCpuOffload: false,
vaeCpuOffload: false,
imageEncoderCpuOffload: false,

Some files were not shown because too many files have changed in this diff Show More