Directly-constructed GenerationRequests marked every schema default as explicit (normalize_generation_request bound the full serialization when _fastvideo_explicit_paths was absent), so request_to_sampling_param overwrote model preset values with schema defaults: FastWan DMD ran 3->50 steps, TurboDiffusion 4->50, stable-audio 100 steps/CFG7 -> 50/CFG1, and 1.3B Wan examples rendered 720x1280x125f with CFG off. Fix at the root: SamplingConfig fields now default to None = "inherit the model preset". None leaves are never bound as explicit paths — for directly-constructed requests (normalize_generation_request) and for parsed YAML/JSON (parse_config treats explicit null as unset, so widening the types cannot let nulls stomp presets either). Dicts emptied by the pruning are dropped too, so None-valued extensions cannot resurface via the live-attribute read in explicit_request_updates. The _SCHEMA_DEFAULT_UPDATES tolerance hack is deleted (an explicitly-set field unsupported by a model's SamplingParam now always fails loudly), and return_state is skipped in the update loop since it is already translated to return_continuation_state. Consumers of raw (pre-resolution) sampling fields are guarded: the streaming server validates operator-pinned default_request.sampling before the multi-minute model boot (run_server) and in build_app; its segment negative_prompt merge preserves an explicit "" clear; the mock server supplies concrete fallbacks; generate_async resolves the preset so its advertised total_steps matches the actual run; the ray-serve gradio demo passes "" to keep its no-negative-prompt default. Stale schema-default claims in video_api/ServeConfig/openai.md docs updated. Tests: test_preset_inheritance.py locks the contract (bare request inherits preset; partial override; explicit value equal to an old schema default wins; negative_prompt None-inherits/""-clears; parsed nulls inherit; None-valued extensions are dropped; unsupported explicit field raises) plus a build_app pin-validation test.
130 lines
4.0 KiB
Python
130 lines
4.0 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Mock streaming server — a frontend dev aid.
|
|
|
|
Boots the same FastAPI app the real streaming server uses, but backs
|
|
it with :class:`InProcessGpuPool` wrapping a synthetic generator that
|
|
emits pre-baked RGB frames. No GPU or model weights required.
|
|
|
|
Use cases:
|
|
|
|
* Frontend development without a real model loaded.
|
|
* Integration tests that exercise the WS protocol end-to-end.
|
|
* Reproducing protocol bugs locally.
|
|
|
|
Launch: ``python -m fastvideo.entrypoints.streaming.mock_server``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
|
|
from fastvideo.api.schema import (
|
|
ContinuationState,
|
|
GenerationRequest,
|
|
GeneratorConfig,
|
|
SamplingConfig,
|
|
ServeConfig,
|
|
StreamingConfig,
|
|
)
|
|
from fastvideo.entrypoints.streaming.server import build_app
|
|
|
|
|
|
@dataclass
|
|
class MockGenerator:
|
|
"""Generator stand-in that returns synthetic gradient frames.
|
|
|
|
Each call produces one segment worth of frames whose pixels vary by
|
|
a constant derived from the request seed and segment index. Latency
|
|
is configurable via ``sleep_ms`` so the caller can exercise slow-
|
|
generate scenarios without spinning a GPU.
|
|
"""
|
|
|
|
sleep_ms: float = 0.0
|
|
|
|
def generate(self, request: GenerationRequest) -> dict[str, Any]:
|
|
if self.sleep_ms:
|
|
time.sleep(self.sleep_ms / 1000.0)
|
|
# Requests carry None for unset sampling fields (normally resolved
|
|
# against the model preset); the mock has no model, so fall back
|
|
# to fixed values here.
|
|
width = max(16, request.sampling.width or 16)
|
|
height = max(16, request.sampling.height or 16)
|
|
num_frames = max(1, request.sampling.num_frames or 1)
|
|
seed = request.sampling.seed if request.sampling.seed is not None else 1024
|
|
frames = [_gradient_frame(height, width, idx, seed=seed) for idx in range(num_frames)]
|
|
state = ContinuationState(
|
|
kind="ltx2.v1",
|
|
payload={
|
|
"schema_version": 1,
|
|
"segment_index": 0,
|
|
"source_prompt": request.prompt,
|
|
},
|
|
)
|
|
return {
|
|
"frames": frames,
|
|
"audio_sample_rate": 24000,
|
|
"state": state,
|
|
}
|
|
|
|
|
|
def _gradient_frame(height: int, width: int, idx: int, *, seed: int) -> np.ndarray:
|
|
base = (idx * 17 + seed * 3) % 256
|
|
row = np.linspace(base, (base + 64) % 256, width, dtype=np.uint8)
|
|
frame = np.tile(row, (height, 1))
|
|
stacked = np.stack([frame, np.roll(frame, 8, axis=1), np.roll(frame, 16, axis=1)], axis=-1)
|
|
return stacked.astype(np.uint8)
|
|
|
|
|
|
def build_mock_app(*, sleep_ms: float = 0.0):
|
|
"""Build a FastAPI app backed by :class:`MockGenerator`."""
|
|
serve_config = ServeConfig(
|
|
generator=GeneratorConfig(model_path="/models/mock"),
|
|
streaming=StreamingConfig(
|
|
session_timeout_seconds=120,
|
|
generation_segment_cap=6,
|
|
),
|
|
)
|
|
serve_config.default_request.sampling = SamplingConfig(
|
|
num_frames=24,
|
|
height=256,
|
|
width=256,
|
|
fps=24,
|
|
num_inference_steps=1,
|
|
# The mock renders frames from the seed directly (no model, no
|
|
# preset resolution), so it must be concrete here.
|
|
seed=1024,
|
|
)
|
|
return build_app(serve_config, MockGenerator(sleep_ms=sleep_ms))
|
|
|
|
|
|
def main() -> None: # pragma: no cover - CLI entry
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--host", default="127.0.0.1")
|
|
parser.add_argument("--port", type=int, default=8000)
|
|
parser.add_argument(
|
|
"--sleep-ms",
|
|
type=float,
|
|
default=0.0,
|
|
help="Per-segment artificial latency for testing slow paths",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
import uvicorn
|
|
|
|
app = build_mock_app(sleep_ms=args.sleep_ms)
|
|
uvicorn.run(app, host=args.host, port=args.port)
|
|
|
|
|
|
__all__ = [
|
|
"MockGenerator",
|
|
"build_mock_app",
|
|
"main",
|
|
]
|
|
|
|
if __name__ == "__main__": # pragma: no cover - CLI entry
|
|
main()
|