Compare commits

...
21 Commits
Author SHA1 Message Date
SolitaryThinker 59cf9d6fa2 [fix] dreamverse: steering session-flow, mock parity, and init-image validation
Steering protocol/UI fixes that interlock via one contract change — the
frontend now sends initial_rollout_prompt_id (its own prompt id for the
opening scene) in session-init and reset payloads, and both backend pre-seed
sites use it, so prompt lifecycle events (enhancing/ready/fallback) finally
match the frontend's records instead of no-oping against a backend uuid.

- controller: suppress prompt_sources_blocked while a submission is queued or
  enhancing (fixes the blank player at typed-prompt session start); segment
  prompt logging is now opt-in via DREAMVERSE_SEGMENT_PROMPT_LOG, written off
  the event loop, warning on failure (drops the hardcoded author-machine path
  and bare except).
- web: 'Generating next scene' overlay driven by explicit generation state
  instead of waitingForSegmentPrompt prop edges, so auto_extension_updated
  while idle can't strand it over the video; steering scene history is no
  longer truncated by the 24-event prompt feed cap and keeps stable numbers
  for 30+ scene sessions.
- mock server: manual_continuation_mode parity (no rewrite-flow wait, honors
  initial_rollout_prompt_id, no segment cap in manual mode) so the GPU-less
  dev backend works with the steering-only frontend.
- init image: client-side type/size validation on picker and paste paths
  (png/jpeg/webp, 15MB) with a visible error, and ws_max_size raised to 32MiB
  on both servers so a legitimate ~15MB image reaches the backend's own
  validation instead of tripping uvicorn's 16MiB frame cap.
2026-07-16 20:39:32 -07:00
SolitaryThinker 0c90c328c5 [fix] ltx2: drop unreachable video_position_offset_sec kwargs fallback
video_position_offset_sec is a declared parameter of forward, so a caller's
keyword binds to it and never lands in **kwargs — the added
kwargs.get("video_position_offset_sec", 0.0) block was unreachable dead code
with a false comment, and it rebound the local to 0.0. The pre-existing
application of the offset is the only live path; behavior is unchanged.
2026-07-16 20:39:32 -07:00
SolitaryThinker cfb54a3b2a [fix] dreamverse: env-tunable session timeout, restore warmup watchdog, derive SP size in launch script
- SESSION_TIMEOUT_SECONDS: default back to 300 and now reads the
  DREAMVERSE_SESSION_TIMEOUT_SECONDS env var that launch-dreamverse.sh was
  already exporting (previously nothing read it, and the hardcoded 1800 made
  every deployment hold idle GPU-pool slots 6x longer). Fixes the stale
  five-minute-timeout test and adds an override test.
- STARTUP_WARMUP_TIMEOUT_SECONDS: default back to 2400 so the warmup watchdog
  works again; launch-dreamverse.sh exports the existing
  FASTVIDEO_STARTUP_WARMUP_TIMEOUT_SECONDS override (24000) for the slow
  GB200 max-autotune boot.
- launch-dreamverse.sh: derive the DREAMVERSE_SP_SIZE default from the number
  of visible GPUs so the documented CUDA_VISIBLE_DEVICES=0 invocation no
  longer crashes gpu_pool with 'Not enough GPUs'; explicit env still wins.
  setup-dreamverse-env.sh's printed instruction now matches. gpu_pool raises
  a friendly error for non-integer CUDA_VISIBLE_DEVICES entries.
- video_generation.py: drop unused ParallelismConfig import (F401).
2026-07-16 20:39:13 -07:00
SolitaryThinker deb51f1dcc [fix] dreamverse: return last outer JSON object when parsing enhancer replies
The free-form scanner attempted raw_decode at every '{', including braces
inside an already-decoded object, so 'return the last decodable object'
(deliberate: chain-of-thought models emit draft JSON before the final answer)
actually returned the innermost/trailing nested fragment — e.g.
{"next_prompt": ..., "style": {"mood": "noir"}} parsed to {"mood": "noir"}
and enhancement fell back, failing steering requests.

Scan with a position cursor instead: skip past the consumed span of every
successfully decoded object, and skip malformed/truncated spans wholesale via
a balanced-brace scan so their nested fragments can't displace an earlier
complete object. Regression tests cover nested values, sequential drafts,
truncated tails, and the nested rollout shape.
2026-07-16 20:38:57 -07:00
alexzms 79a5930340 [fix] dreamverse: drop steering hint line that overlapped the hero title on mobile
The two-line 'Drive each scene yourself...' hint grew the vertically-centered
landing content, pushing the preset cards up into the absolutely-positioned
hero title on short mobile viewports. Removing it restores the clean spacing.
2026-07-15 23:20:23 +00:00
alexzms 3a0ce7c20d [misc] dreamverse: bump steering generating progress bar to 10.5s 2026-07-15 23:20:23 +00:00
alexzms 0b006a9e46 [feat] dreamverse: robust steering 'Generating next scene' overlay
Drive the generating indicator off whether the new segment has actually
landed (buffered timeline grows past the boundary captured at generation
start) instead of bare playback events, so scrubbing back and replaying to
the end keeps it visible and it clears promptly once frames arrive. Gate the
overlay on playbackReachedEnd like 'Segment complete', and bump the progress
bar ETA to 8s.
2026-07-15 23:20:23 +00:00
alexzms fb9acac514 [feat] dreamverse: steering-only UI — drop auto-rollout mode selector
Remove the pre-session Auto rollout / Steering segmented control and make
manual continuation (steering) the sole mode: default it on in the session
store and keep it on across project/lobby resets. Leaves a short steering
hint in its place.
2026-07-15 23:20:22 +00:00
kevin314 2d78957219 6+2 2026-07-15 23:20:22 +00:00
kevin314 63991d2017 Test 2026-07-15 23:20:22 +00:00
alexzms b6eafbea50 [fix] dreamverse: recover steering after a blocked or failed prompt
When a steering prompt is refused by the enhancer (e.g. content-policy) or enhancement
fails for all providers, the backend enqueues nothing and won't re-emit prompt_sources_
blocked, leaving the UI stuck on the generating overlay. On prompt/fallback_used and
session/error in steering, return to the 'describe the next scene' state, drop the failed
scene from the history (steeringFailed), and surface a retry notice; clear the notice on
the next submit.
2026-07-15 23:20:22 +00:00
alexzms 7b84041642 [feat] dreamverse: steering scene history of per-segment user prompts
Steering mode now shows an elegant list of each scene's prompt above the player. The
text comes from the user's own words, captured stably at submit time as rawText (the
backend later overwrites text/source with the enhanced prompt, so those are never read);
a preset's opening scene with no user prompt falls back to promptHistory. The redundant
ChatBar 'Segment complete' banner is dropped (the video overlay already says it, and it
was squeezing the list), and a ResizeObserver re-pins the list to the latest scene when
the area resizes.
2026-07-15 23:20:22 +00:00
kevin314 48801c29c7 Add image input UI 2026-07-15 23:20:22 +00:00
alexzms cb8be0d3f3 [feat] dreamverse: main-UI steering mode switch + first-segment-only seeding
Add a user-facing Auto rollout / Steering segmented control to the main composer (not just
devtools), wired to manualContinuationMode and authoritative at session start. In steering
mode a preset seeds only its first segment and auto/loop are forced off, so the backend waits
for the user to describe each subsequent scene by hand.
2026-07-15 23:20:22 +00:00
alexzms 1bc7ff79e6 [ui] dreamverse: logo links home, replace Join Waitlist with Blog
The FastVideo logo now navigates to the app home (most intuitive), and the
Join-Waitlist buttons (header desktop/mobile + session-ended card) become a Blog
link pointing at the Dreamverse blog.
2026-07-15 23:20:21 +00:00
alexzms 76fbe472ae [feat] dreamverse: allow video download at any time during playback
handleDownloadVideo already remuxes live (including in-progress) segments, but the button
was gated on a finalized clip blob. Surface it as soon as playback starts (avPlaybackStarted)
so the user can grab the in-progress video at any point — important for unbounded steering
sessions.
2026-07-15 23:20:21 +00:00
alexzms eba74c43ef [feat] dreamverse: graceful segment-complete & generating overlays in steering playback
When a segment finishes in steering mode the player no longer spins. Instead it shows a
soft 'Segment complete' prompt over the frozen last frame (gated on the playhead actually
reaching the buffered end, and hidden again when the user scrubs back). After the user
submits the next scene, a ~4.5s progress bar covers the generation latency so the wait has
a visible ETA.
2026-07-15 23:20:21 +00:00
alexzms 7744a74c13 [feat] dreamverse: steering-mode toggle in devtools composer
Add a 'Steering mode' checkbox to the devtools composer and thread the
manualContinuationEnabled / onManualContinuationToggle props through DevtoolsShell.
2026-07-15 23:20:21 +00:00
alexzms d746b8b956 [feat] dreamverse: unlimited segments in steering mode
Steering (manual continuation) lets the user drive the rollout segment-by-segment
indefinitely. Treat it like single-clip mode for the generation cap:
_resolve_generation_segment_cap returns 0 (unlimited) and the cap-reached guard is
skipped when manual_continuation_mode is on.
2026-07-15 23:20:21 +00:00
alexzms 65f12605d8 [bugfix] ltx2: apply video_position_offset_sec RoPE offset
The DiT forward swallowed video_position_offset_sec via **kwargs, so multi-segment
rollouts never advanced the temporal RoPE phase between segments, causing ~1s audio/
video desync at each seam. Add the offset to the temporal position coords (mirrors
hao-ai-lab/FastVideo#1422).
2026-07-15 23:20:21 +00:00
kevin314 1e1ac08cd0 Add manual continuation 2026-07-15 23:20:21 +00:00
32 changed files with 1435 additions and 107 deletions
+5 -5
View File
@@ -67,7 +67,7 @@ MODEL_REGISTRY = {
},
}
DEFAULT_MODEL_ID = "fast-ltx2"
DEFAULT_MODEL_ID = "fast-ltx23"
ACTIVE_MODEL_ID = (os.getenv("DREAMVERSE_MODEL_ID", "").strip() or DEFAULT_MODEL_ID)
if ACTIVE_MODEL_ID not in MODEL_REGISTRY:
@@ -76,14 +76,11 @@ 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
NUM_INFERENCE_STEPS = 6
JPEG_QUALITY = 100
BATCH_SIZE = 3
@@ -168,6 +165,9 @@ def _optional_env(*names: str) -> str | None:
return None
# Generation limits
SESSION_TIMEOUT_SECONDS = _env_int("DREAMVERSE_SESSION_TIMEOUT_SECONDS", 300)
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)
+5 -1
View File
@@ -1023,7 +1023,11 @@ def get_available_gpus() -> list[int]:
"""Get list of available GPU IDs from environment or auto-detect."""
cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES", "")
if cuda_visible:
visible_gpu_ids = [int(x.strip()) for x in cuda_visible.split(",") if x.strip()]
try:
visible_gpu_ids = [int(x.strip()) for x in cuda_visible.split(",") if x.strip()]
except ValueError as exc:
raise RuntimeError("CUDA_VISIBLE_DEVICES must be a comma-separated list of integer GPU "
f"indices (got {cuda_visible!r}); GPU UUIDs are not supported.") from exc
return _limit_gpu_ids(visible_gpu_ids)
# Auto-detect available GPUs
+3 -1
View File
@@ -206,7 +206,9 @@ def cli() -> None:
args = parser.parse_args()
_install_heartbeat_log_filter()
uvicorn.run(app, host=args.host, port=args.port)
# A 15MB init image (session_init_image.MAX_SESSION_INIT_IMAGE_BYTES) is ~20MB
# as a base64 ws message, above uvicorn's default 16MiB frame cap.
uvicorn.run(app, host=args.host, port=args.port, ws_max_size=32 * 1024 * 1024)
if __name__ == "__main__":
+47 -21
View File
@@ -30,11 +30,11 @@ from fastapi.middleware.cors import CORSMiddleware
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.config import FRONTEND_STATIC_DIR_CANDIDATES, GENERATION_SEGMENT_CAP, SESSION_TIMEOUT_SECONDS
from dreamverse.session_init_image import cleanup_session_init_image, persist_session_init_image
from dreamverse.utils import _resolve_generation_segment_cap
LATENCY_MS = 200
SESSION_TIMEOUT_SECONDS = 300
MOCK_FRAME_WIDTH = 640
MOCK_FRAME_HEIGHT = 352
MOCK_FPS = 24
@@ -334,6 +334,7 @@ async def websocket_endpoint(websocket: WebSocket):
auto_extension_enabled = bool(init_data.get("auto_extension_enabled", False))
loop_generation_enabled = bool(init_data.get("loop_generation_enabled", False))
single_clip_mode = bool(init_data.get("single_clip_mode", False))
manual_continuation_mode = bool(init_data.get("manual_continuation_mode", False))
generation_paused = False
if init_type == "session_init_v2":
@@ -344,7 +345,8 @@ async def websocket_endpoint(websocket: WebSocket):
incoming_prompts = []
curated_prompts = [prompt.strip() for prompt in incoming_prompts if isinstance(prompt, str) and prompt.strip()]
generation_paused = bool(initial_rollout_prompt and not single_clip_mode and len(curated_prompts) == 0)
generation_paused = bool(not manual_continuation_mode and initial_rollout_prompt and not single_clip_mode
and len(curated_prompts) == 0)
try:
session_init_image = persist_session_init_image(init_data.get("initial_image"))
@@ -373,7 +375,10 @@ async def websocket_endpoint(websocket: WebSocket):
prompt_sources_blocked = False
pending_seed_reset = False
pending_seed_reset_reason = ""
pending_simple_submission: PromptSubmission | None = None
pending_simple_submission: PromptSubmission | None = (PromptSubmission(
prompt_id=str(init_data.get("initial_rollout_prompt_id") or uuid.uuid4()),
raw_prompt=initial_rollout_prompt,
) if manual_continuation_mode and initial_rollout_prompt else None)
single_clip_waiting_for_request = False
rollout_waiting_for_rewrite = False
initial_rollout_waiting_for_rewrite = generation_paused
@@ -393,14 +398,26 @@ async def websocket_endpoint(websocket: WebSocket):
async def send_stream_start(seed_reason: str) -> None:
await ws_send_json({
"type": "ltx2_stream_start",
"total_segments": len(curated_prompts),
"preset_id": preset_id,
"stream_mode": "av_fmp4",
"live_mode": True,
"loop_generation_enabled": loop_generation_enabled,
"loop_iteration": loop_iteration,
"generation_segment_cap": 0,
"type":
"ltx2_stream_start",
"total_segments":
len(curated_prompts),
"preset_id":
preset_id,
"stream_mode":
"av_fmp4",
"live_mode":
True,
"loop_generation_enabled":
loop_generation_enabled,
"loop_iteration":
loop_iteration,
"generation_segment_cap":
_resolve_generation_segment_cap(
single_clip_mode=single_clip_mode,
cap=GENERATION_SEGMENT_CAP,
manual_continuation_mode=manual_continuation_mode,
),
})
if seed_reason == "init":
await ws_send_json({
@@ -493,6 +510,7 @@ async def websocket_endpoint(websocket: WebSocket):
nonlocal auto_extension_enabled
nonlocal loop_generation_enabled
nonlocal single_clip_mode
nonlocal manual_continuation_mode
nonlocal generation_paused
nonlocal seed_prompt_memory
nonlocal curated_prompts
@@ -537,6 +555,7 @@ async def websocket_endpoint(websocket: WebSocket):
auto_extension_enabled = bool(payload.get("auto_extension_enabled", False))
loop_generation_enabled = bool(payload.get("loop_generation_enabled", False))
single_clip_mode = bool(payload.get("single_clip_mode", False))
manual_continuation_mode = bool(payload.get("manual_continuation_mode", False))
seed_prompt_memory = list(next_curated_prompts)
curated_prompts = list(seed_prompt_memory)
@@ -544,10 +563,14 @@ async def websocket_endpoint(websocket: WebSocket):
segment_idx = 0
pending_seed_reset = False
pending_seed_reset_reason = ""
pending_simple_submission = None
pending_simple_submission = (PromptSubmission(
prompt_id=str(payload.get("initial_rollout_prompt_id") or uuid.uuid4()),
raw_prompt=initial_rollout_prompt,
) if manual_continuation_mode and initial_rollout_prompt else None)
single_clip_waiting_for_request = False
rollout_waiting_for_rewrite = False
generation_paused = bool(initial_rollout_prompt and not single_clip_mode and len(curated_prompts) == 0)
generation_paused = bool(not manual_continuation_mode and initial_rollout_prompt and not single_clip_mode
and len(curated_prompts) == 0)
initial_rollout_waiting_for_rewrite = generation_paused
rewrite_restart_pending = False
loop_iteration = 0
@@ -968,10 +991,11 @@ async def websocket_endpoint(websocket: WebSocket):
project_stream_started = True
await send_stream_start(pending_seed_reset_reason)
pending_seed_reset_reason = ""
if pending_simple_submission is not None:
submission = pending_simple_submission
pending_simple_submission = None
await promote_submission_to_ready(submission)
if pending_simple_submission is not None:
submission = pending_simple_submission
pending_simple_submission = None
await promote_submission_to_ready(submission)
if generation_paused:
await asyncio.sleep(0.05)
@@ -981,8 +1005,8 @@ async def websocket_endpoint(websocket: WebSocket):
await asyncio.sleep(0.05)
continue
if (not single_clip_mode and not rollout_waiting_for_rewrite and GENERATION_SEGMENT_CAP > 0
and segment_idx >= GENERATION_SEGMENT_CAP):
if (not single_clip_mode and not manual_continuation_mode and not rollout_waiting_for_rewrite
and GENERATION_SEGMENT_CAP > 0 and segment_idx >= GENERATION_SEGMENT_CAP):
rollout_waiting_for_rewrite = True
loop_generation_enabled = False
project_stream_started = False
@@ -1217,7 +1241,9 @@ def cli() -> None:
print(f"Starting mock server with {LATENCY_MS}ms latency on port {args.port}")
_install_heartbeat_log_filter()
uvicorn.run(app, host="0.0.0.0", port=args.port)
# A 15MB init image (session_init_image.MAX_SESSION_INIT_IMAGE_BYTES) is ~20MB
# as a base64 ws message, above uvicorn's default 16MiB frame cap.
uvicorn.run(app, host="0.0.0.0", port=args.port, ws_max_size=32 * 1024 * 1024)
if __name__ == "__main__":
+60 -18
View File
@@ -313,6 +313,33 @@ def _extract_content_or_empty(response_json: dict[str, Any]) -> str:
return ""
def _find_balanced_object_end(text: str, start: int) -> int:
"""Return the index just past the brace-balanced span opening at
``text[start] == '{'``, honoring JSON string literals and escapes, or -1
if the braces never balance (i.e. the object was truncated)."""
depth = 0
in_string = False
escaped = False
for i in range(start, len(text)):
ch = text[i]
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return i + 1
return -1
def _parse_json_response(content: str) -> dict[str, Any]:
text = content.strip()
if not text:
@@ -330,6 +357,7 @@ def _parse_json_response(content: str) -> dict[str, Any]:
r"```(?:json)?\s*([\s\S]*?)```",
flags=re.IGNORECASE,
)
last_fenced: dict[str, Any] | None = None
for match in fence_pattern.finditer(text):
block = match.group(1).strip()
if not block:
@@ -337,21 +365,37 @@ def _parse_json_response(content: str) -> dict[str, Any]:
try:
parsed = json.loads(block)
if isinstance(parsed, dict):
return parsed
last_fenced = parsed
except json.JSONDecodeError:
continue
if last_fenced is not None:
return last_fenced
# Fall back to scanning for the first decodable JSON object in free-form text.
# Scan for all decodable JSON objects and return the last — chain-of-thought
# models emit draft JSON mid-reasoning; the final answer is always last.
decoder = json.JSONDecoder()
for idx, char in enumerate(text):
if char != "{":
continue
last_parsed: dict[str, Any] | None = None
pos = 0
while (idx := text.find("{", pos)) != -1:
try:
parsed, _ = decoder.raw_decode(text[idx:])
parsed, consumed = decoder.raw_decode(text[idx:])
except json.JSONDecodeError:
# Skip the whole failed object rather than rescanning inside it:
# fragments nested in a malformed or truncated (finish_reason=
# length) object must not override an earlier complete object.
span_end = _find_balanced_object_end(text, idx)
if span_end == -1:
break
pos = span_end
continue
# Skip past the consumed span so nested braces inside a decoded
# object are not re-parsed as standalone objects.
pos = idx + consumed
if isinstance(parsed, dict):
return parsed
last_parsed = parsed
if last_parsed is not None:
return last_parsed
raise ValueError("No JSON object found in assistant response.")
@@ -381,7 +425,7 @@ def _format_locked_segments(locked_segments: list[str]) -> str:
class PromptEnhancer:
def __init__(self):
def __init__(self) -> None:
self.provider = PROMPT_PROVIDER
self.provider_label = _resolve_provider_label(PROMPT_PROVIDER)
self.api_key = PROMPT_API_KEY
@@ -1417,15 +1461,12 @@ class PromptEnhancer:
locked_text = _format_locked_segments(locked_segments_clean)
request_system_prompt = self.enhance_system_prompt
user_payload = {
"request": (
"<locked_segments>\n"
f"{locked_text}\n"
"</locked_segments>\n\n"
f"<conditioning_prompt>{cleaned}</conditioning_prompt>\n\n"
f"Write exactly one new segment ({next_segment_key}) "
"continuing from the locked segments. "
'Respond with valid JSON only as {"next_prompt": "..."}.' # noqa: E501
),
"request": ("<locked_segments>\n"
f"{locked_text}\n"
"</locked_segments>\n\n"
f"<conditioning_prompt>{cleaned}</conditioning_prompt>\n\n"
f"Write exactly one new segment ({next_segment_key}) "
"continuing from the locked segments."),
}
t0 = time.perf_counter()
@@ -1457,6 +1498,7 @@ class PromptEnhancer:
body=request_body,
timeout_seconds=timeout_seconds,
)
_enhance_print("INFO", f"raw_response: {response_content}")
if is_single_clip_mode:
prompt = self._extract_single_clip_prompt(response_content)
else:
@@ -1539,7 +1581,7 @@ class PromptEnhancer:
f"Write exactly one new segment ({next_segment_key}) "
"that continues linearly from the locked segments. "
"Infer the next narrative beat from this history. "
'Respond with valid JSON only as {"next_prompt": "..."}.' # noqa: E501
'Respond with valid JSON only: {"next_prompt": "<your segment description here>"}.' # noqa: E501
),
}
@@ -20,6 +20,7 @@ from __future__ import annotations
# mypy: ignore-errors
import asyncio
import os
import time
import uuid
from typing import TYPE_CHECKING
@@ -52,6 +53,14 @@ if TYPE_CHECKING:
from dreamverse.prompt_enhancer import PromptEnhancer
from dreamverse.prompt_safety import PromptSafetyFilter
# Optional append-only log of every generated segment prompt; unset disables it.
SEGMENT_PROMPT_LOG_PATH = os.environ.get("DREAMVERSE_SEGMENT_PROMPT_LOG", "")
def _append_segment_prompt_log(path: str, text: str) -> None:
with open(path, "a") as f:
f.write(text)
class SessionController:
"""Runs one WebSocket session from accept() through disconnect."""
@@ -198,6 +207,7 @@ class SessionController:
auto_extension_enabled = bool(init_data.get("auto_extension_enabled", False))
loop_generation_enabled = bool(init_data.get("loop_generation_enabled", False))
single_clip_mode = bool(init_data.get("single_clip_mode", False))
manual_continuation_mode = bool(init_data.get("manual_continuation_mode", False))
rewrite_model = self.prompt_enhancer.resolve_rewrite_model(init_data.get("rewrite_model"))
rewrite_system_prompt_override = str(init_data.get("rewrite_window_system_prompt") or "").strip()
rewrite_user_system_prompt_override = str(init_data.get("rewrite_user_system_prompt") or "").strip()
@@ -282,6 +292,9 @@ class SessionController:
# Session queues and mutable state.
raw_prompt_queue: asyncio.Queue[PromptSubmission] = asyncio.Queue()
ready_prompt_queue: asyncio.Queue[ReadyPrompt] = asyncio.Queue()
# Submissions dequeued by prompt_worker_loop but not yet resolved; while
# non-zero the prompt sources are busy, not drained.
prompt_enhancement_inflight = 0
curated_idx = 0
segment_idx = 0
@@ -291,13 +304,20 @@ class SessionController:
generation_cap_blocked = False
auto_extension_blocked_segment_idx: int | None = None
prompt_sources_drained_logged = False
generation_paused = bool(initial_rollout_prompt and not single_clip_mode and len(curated_prompts) == 0)
generation_paused = bool(not manual_continuation_mode and initial_rollout_prompt and not single_clip_mode
and len(curated_prompts) == 0)
pending_seed_reset = False
pending_seed_reset_reason = ""
pending_reset_conditioning = False
loop_iteration = 0 if generation_paused else 1
force_curated_restart_segment = False
pending_simple_prompt_submission: PromptSubmission | None = None
# The frontend records the opening scene under this id; reuse it so
# prompt lifecycle events for the opening prompt reach that record.
pending_simple_prompt_submission: PromptSubmission | None = (PromptSubmission(
prompt_id=str(init_data.get("initial_rollout_prompt_id") or uuid.uuid4()),
raw_prompt=initial_rollout_prompt,
created_at_s=time.time(),
) if manual_continuation_mode and initial_rollout_prompt else None)
single_clip_waiting_for_request = False
rollout_waiting_for_rewrite = False
initial_rollout_waiting_for_rewrite = generation_paused
@@ -306,6 +326,7 @@ class SessionController:
project_active = True
project_stream_started = False
pending_project_end = False
segment_prompt_log_warned = False
def replace_session_init_image(initial_image_payload: object) -> None:
nonlocal session_init_image
@@ -424,6 +445,7 @@ class SessionController:
nonlocal auto_extension_enabled
nonlocal loop_generation_enabled
nonlocal single_clip_mode
nonlocal manual_continuation_mode
nonlocal generation_paused
nonlocal curated_prompts
nonlocal seed_prompt_memory
@@ -458,6 +480,7 @@ class SessionController:
next_auto_extension_enabled = bool(payload.get("auto_extension_enabled", False))
next_loop_generation_enabled = bool(payload.get("loop_generation_enabled", False))
next_single_clip_mode = bool(payload.get("single_clip_mode", False))
next_manual_continuation_mode = bool(payload.get("manual_continuation_mode", False))
next_preset_id = str(payload.get("preset_id") or "").strip()
if next_preset_id:
@@ -510,6 +533,7 @@ class SessionController:
auto_extension_enabled = next_auto_extension_enabled
loop_generation_enabled = next_loop_generation_enabled
single_clip_mode = next_single_clip_mode
manual_continuation_mode = next_manual_continuation_mode
rewrite_model = next_rewrite_model
rewrite_system_prompt_override = (next_rewrite_system_prompt_override)
rewrite_user_system_prompt_override = (next_rewrite_user_system_prompt_override)
@@ -530,10 +554,15 @@ class SessionController:
generation_cap_blocked = False
auto_extension_blocked_segment_idx = None
prompt_sources_drained_logged = False
pending_simple_prompt_submission = None
pending_simple_prompt_submission = (PromptSubmission(
prompt_id=str(payload.get("initial_rollout_prompt_id") or uuid.uuid4()),
raw_prompt=initial_rollout_prompt,
created_at_s=time.time(),
) if manual_continuation_mode and initial_rollout_prompt else None)
single_clip_waiting_for_request = False
rollout_waiting_for_rewrite = False
generation_paused = bool(initial_rollout_prompt and not single_clip_mode and len(curated_prompts) == 0)
generation_paused = bool(not manual_continuation_mode and initial_rollout_prompt
and not single_clip_mode and len(curated_prompts) == 0)
initial_rollout_waiting_for_rewrite = generation_paused
rewrite_restart_pending = False
loop_iteration = 0
@@ -942,6 +971,7 @@ class SessionController:
_resolve_generation_segment_cap(
single_clip_mode=single_clip_mode,
cap=GENERATION_SEGMENT_CAP,
manual_continuation_mode=manual_continuation_mode,
),
})
continue
@@ -1005,11 +1035,9 @@ class SessionController:
continue
async def prompt_worker_loop():
while not stop_event.is_set():
try:
submission = await asyncio.wait_for(raw_prompt_queue.get(), timeout=0.1)
except asyncio.TimeoutError:
continue
nonlocal prompt_enhancement_inflight
async def process_submission(submission: PromptSubmission) -> None:
_main_print("INFO", f"Received user prompt for enhancement: {submission.raw_prompt}")
prompt_id = submission.prompt_id
raw_prompt = submission.raw_prompt
@@ -1032,8 +1060,9 @@ class SessionController:
await ws_send_json({
"type": "error",
"message": blocked_raw_prompt_error,
"prompt_id": prompt_id,
})
continue
return
await log_event(
"enhance_request",
{
@@ -1093,8 +1122,9 @@ class SessionController:
await ws_send_json({
"type": "error",
"message": blocked_final_prompt_error,
"prompt_id": prompt_id,
})
continue
return
if result.fallback_used or not final_prompt:
source = "user_enhancement_failed"
_main_print(
@@ -1113,7 +1143,7 @@ class SessionController:
})
# Enhancement is strict JSON-only; do not enqueue raw
# prompt when enhancement fails.
continue
return
else:
source = "user_enhanced"
await ws_send_json({
@@ -1130,6 +1160,7 @@ class SessionController:
source=source,
fallback_used=result.fallback_used,
loop_iteration=loop_iteration,
raw_prompt=raw_prompt,
))
else:
await ready_prompt_queue.put(
@@ -1139,6 +1170,7 @@ class SessionController:
source="user_raw",
fallback_used=False,
loop_iteration=loop_iteration,
raw_prompt=raw_prompt,
))
await ws_send_json({
"type": "prompt_ready",
@@ -1148,6 +1180,21 @@ class SessionController:
"latency_ms": 0.0,
})
while not stop_event.is_set():
try:
submission = raw_prompt_queue.get_nowait()
except asyncio.QueueEmpty:
await asyncio.sleep(0.1)
continue
# Dequeue and increment without an await in between so the
# generation loop never sees an empty queue with zero in flight
# while this submission is still being enhanced.
prompt_enhancement_inflight += 1
try:
await process_submission(submission)
finally:
prompt_enhancement_inflight -= 1
def queue_snapshot() -> dict[str, object]:
return {
"user_ready": ready_prompt_queue.qsize(),
@@ -1300,6 +1347,7 @@ class SessionController:
_resolve_generation_segment_cap(
single_clip_mode=single_clip_mode,
cap=GENERATION_SEGMENT_CAP,
manual_continuation_mode=manual_continuation_mode,
),
})
await ws_send_json({
@@ -1359,6 +1407,7 @@ class SessionController:
_resolve_generation_segment_cap(
single_clip_mode=single_clip_mode,
cap=GENERATION_SEGMENT_CAP,
manual_continuation_mode=manual_continuation_mode,
),
})
if nonlocal_reason == "loop_restart":
@@ -1388,8 +1437,9 @@ class SessionController:
await raw_prompt_queue.put(pending_simple_prompt_submission)
pending_simple_prompt_submission = None
if (not single_clip_mode and not generation_cap_blocked and not rollout_waiting_for_rewrite
and GENERATION_SEGMENT_CAP > 0 and generated_segment_count >= GENERATION_SEGMENT_CAP):
if (not single_clip_mode and not manual_continuation_mode and not generation_cap_blocked
and not rollout_waiting_for_rewrite and GENERATION_SEGMENT_CAP > 0
and generated_segment_count >= GENERATION_SEGMENT_CAP):
loop_generation_enabled = False
rollout_waiting_for_rewrite = True
_main_print(
@@ -1542,7 +1592,10 @@ class SessionController:
if single_clip_mode:
await asyncio.sleep(PROMPT_AUTO_SLEEP_MS / 1000.0)
continue
if not prompt_sources_drained_logged:
# A raw submission still queued or being enhanced will produce a
# ready prompt shortly; that is not a drained/blocked state.
enhancement_pending = (raw_prompt_queue.qsize() > 0 or prompt_enhancement_inflight > 0)
if not prompt_sources_drained_logged and not enhancement_pending:
snapshot = queue_snapshot()
_main_print(
"WARN",
@@ -1581,6 +1634,24 @@ class SessionController:
total_segments_hint = max(segment_idx, len(curated_prompts))
prompt = selected.prompt
locked_segment_prompts.append(prompt)
if SEGMENT_PROMPT_LOG_PATH:
_ts = time.strftime("%Y-%m-%d %H:%M:%S")
_lines = [
f"\n=== Segment {segment_idx} [{_ts}] source={selected.source} client={client_id[:8]} ===",
]
if selected.raw_prompt and selected.raw_prompt != prompt:
_lines.append(f"User: {selected.raw_prompt}")
_lines.append(f"Rewritten: {prompt}")
try:
await asyncio.to_thread(_append_segment_prompt_log, SEGMENT_PROMPT_LOG_PATH,
"\n".join(_lines) + "\n")
except Exception as exc:
if not segment_prompt_log_warned:
segment_prompt_log_warned = True
_main_print(
"WARN",
f"Failed to write segment prompt log {SEGMENT_PROMPT_LOG_PATH}: {exc}",
)
if (auto_extension_blocked_segment_idx is not None
and auto_extension_blocked_segment_idx <= segment_idx):
auto_extension_blocked_segment_idx = None
@@ -19,3 +19,4 @@ class ReadyPrompt:
fallback_used: bool = False
seed_prompt_index: int | None = None
loop_iteration: int | None = None
raw_prompt: str | None = None
@@ -150,12 +150,22 @@ 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_SESSION_TIMEOUT_SECONDS", raising=False)
module = _load_config_module()
assert module.SESSION_TIMEOUT_SECONDS == 300
def test_config_session_timeout_env_override(monkeypatch):
_set_required_prompt_keys(monkeypatch)
monkeypatch.setenv("DREAMVERSE_SESSION_TIMEOUT_SECONDS", "1800")
module = _load_config_module()
assert module.SESSION_TIMEOUT_SECONDS == 1800
def test_config_rejects_invalid_prompt_provider(monkeypatch):
monkeypatch.setenv("FASTVIDEO_PROMPT_PROVIDER", "unsupported")
_set_required_prompt_keys(monkeypatch)
@@ -75,12 +75,13 @@ def _run_cli(module, monkeypatch, argv: list[str]) -> list[dict[str, object]]:
calls: list[dict[str, object]] = []
uvicorn_stub = types.ModuleType("uvicorn")
def run(app, host: str, port: int) -> None:
def run(app, host: str, port: int, **kwargs) -> None:
calls.append(
{
"app": app,
"host": host,
"port": port,
**kwargs,
}
)
@@ -104,6 +105,7 @@ def test_server_cli_defaults_to_local_web_port(monkeypatch):
"app": server_main.app,
"host": "0.0.0.0",
"port": 8009,
"ws_max_size": 32 * 1024 * 1024,
}
]
@@ -121,6 +123,7 @@ def test_server_cli_allows_explicit_host_and_port(monkeypatch):
"app": server_main.app,
"host": "127.0.0.1",
"port": 8123,
"ws_max_size": 32 * 1024 * 1024,
}
]
@@ -147,6 +150,7 @@ def test_mock_server_cli_defaults_to_local_web_port(monkeypatch):
"app": mock_server.app,
"host": "0.0.0.0",
"port": 8009,
"ws_max_size": 32 * 1024 * 1024,
}
]
@@ -166,6 +170,7 @@ def test_mock_server_cli_updates_latency(monkeypatch):
"app": mock_server.app,
"host": "0.0.0.0",
"port": 8111,
"ws_max_size": 32 * 1024 * 1024,
}
]
assert mock_server.LATENCY_MS == 321
@@ -290,6 +290,152 @@ def test_mock_server_supports_initial_custom_rollout_prompt():
mock_server.LATENCY_MS = old_latency_ms
def test_mock_server_manual_mode_streams_initial_prompt_without_rewrite_or_cap():
old_segment_bytes = mock_server.MOCK_SEGMENT_BYTES
old_latency_ms = mock_server.LATENCY_MS
old_generation_segment_cap = mock_server.GENERATION_SEGMENT_CAP
try:
mock_server.MOCK_SEGMENT_BYTES = b"mock-fmp4-bytes"
mock_server.LATENCY_MS = 1
mock_server.GENERATION_SEGMENT_CAP = 1
ws = _FakeWebSocket(
[
(
0.0,
{
"type": "session_init_v2",
"preset_id": "custom_editable",
"preset_label": "Custom rollout",
"curated_prompts": [],
"initial_rollout_prompt": "A drone skims a neon canyon",
"initial_rollout_prompt_id": "steer-prompt-1",
"manual_continuation_mode": True,
"enhancement_enabled": True,
"auto_extension_enabled": False,
"loop_generation_enabled": False,
},
),
(
0.08,
{
"type": "append_prompt",
"prompt": "The drone dives toward the river",
"prompt_id": "steer-prompt-2",
},
),
(0.30, {"type": "leave"}),
]
)
asyncio.run(mock_server.websocket_endpoint(ws))
message_types = [payload["type"] for payload in ws.sent_json]
assert "rewrite_seed_prompts_started" not in message_types
assert "rewrite_seed_prompts_complete" not in message_types
assert "ltx2_stream_start" in message_types
# cap=1 must not stop a manual-mode session after the first segment
assert "ltx2_stream_complete" not in message_types
prompt_ready_events = [
payload for payload in ws.sent_json if payload["type"] == "prompt_ready"
]
assert [payload["prompt_id"] for payload in prompt_ready_events] == [
"steer-prompt-1",
"steer-prompt-2",
]
segment_start_events = [
payload
for payload in ws.sent_json
if payload["type"] == "ltx2_segment_start"
]
assert [payload["prompt"] for payload in segment_start_events] == [
"A drone skims a neon canyon",
"The drone dives toward the river",
]
segment_source_events = [
payload
for payload in ws.sent_json
if payload["type"] == "segment_prompt_source"
]
assert [payload["prompt_id"] for payload in segment_source_events] == [
"steer-prompt-1",
"steer-prompt-2",
]
finally:
mock_server.MOCK_SEGMENT_BYTES = old_segment_bytes
mock_server.LATENCY_MS = old_latency_ms
mock_server.GENERATION_SEGMENT_CAP = old_generation_segment_cap
def test_mock_server_project_init_manual_mode_streams_initial_prompt():
old_segment_bytes = mock_server.MOCK_SEGMENT_BYTES
old_latency_ms = mock_server.LATENCY_MS
try:
mock_server.MOCK_SEGMENT_BYTES = b"mock-fmp4-bytes"
mock_server.LATENCY_MS = 1
ws = _FakeWebSocket(
[
(
0.0,
{
"type": "session_init_v2",
"preset_id": "test_preset",
"curated_prompts": ["segment one"],
"enhancement_enabled": True,
"auto_extension_enabled": False,
"loop_generation_enabled": False,
},
),
(0.05, {"type": "end_project_keep_session"}),
(
0.15,
{
"type": "project_init_v1",
"preset_id": "custom_editable",
"preset_label": "Custom rollout",
"curated_prompts": [],
"initial_rollout_prompt": "A drone skims a neon canyon",
"initial_rollout_prompt_id": "steer-prompt-1",
"manual_continuation_mode": True,
"enhancement_enabled": True,
"auto_extension_enabled": False,
"loop_generation_enabled": False,
},
),
(0.45, {"type": "leave"}),
]
)
asyncio.run(mock_server.websocket_endpoint(ws))
message_types = [payload["type"] for payload in ws.sent_json]
assert "project_idle" in message_types
project_idle_index = message_types.index("project_idle")
# manual-mode restart must not run the rewrite rollout
assert "rewrite_seed_prompts_started" not in message_types[project_idle_index:]
assert "ltx2_stream_start" in message_types[project_idle_index:]
prompt_ready_events = [
payload for payload in ws.sent_json if payload["type"] == "prompt_ready"
]
assert [payload["prompt_id"] for payload in prompt_ready_events] == [
"steer-prompt-1",
]
segment_start_events = [
payload
for payload in ws.sent_json
if payload["type"] == "ltx2_segment_start"
]
assert segment_start_events[-1]["prompt"] == "A drone skims a neon canyon"
finally:
mock_server.MOCK_SEGMENT_BYTES = old_segment_bytes
mock_server.LATENCY_MS = old_latency_ms
def test_mock_server_can_start_new_project_without_reconnecting():
old_segment_bytes = mock_server.MOCK_SEGMENT_BYTES
old_latency_ms = mock_server.LATENCY_MS
@@ -6,6 +6,7 @@ import os
import re
import time
import pytest
os.environ.setdefault("CEREBRAS_API_KEY", "dummy")
os.environ.setdefault("GROQ_API_KEY", "dummy")
@@ -205,6 +206,59 @@ def test_parse_json_response_extracts_first_embedded_object():
assert parsed == {"segment_prompts": ["A", "B"]}
def test_parse_json_response_returns_outer_object_not_nested_value():
parsed = _parse_json_response(
"Final answer: {\"next_prompt\": \"a scene\", \"style\": {\"mood\": \"noir\"}} done."
)
assert parsed == {"next_prompt": "a scene", "style": {"mood": "noir"}}
def test_parse_json_response_returns_last_of_multiple_objects():
parsed = _parse_json_response(
"Draft: {\"next_prompt\": \"draft\"}\nRefined: {\"next_prompt\": \"final\"}"
)
assert parsed == {"next_prompt": "final"}
def test_parse_json_response_ignores_fragments_of_truncated_trailing_object():
# finish_reason=length cut the refined object short; the complete draft must
# win over a nested fragment of the truncated object.
parsed = _parse_json_response(
'{"next_prompt": "draft"} refined: {"next_prompt": "final", "style": {"mood": "noir"}'
)
assert parsed == {"next_prompt": "draft"}
def test_parse_json_response_ignores_fragments_of_mid_string_truncated_object():
# Unterminated-string truncation reports the error at the opening quote,
# not end-of-text; nested fragments still must not win over the draft.
parsed = _parse_json_response(
'{"next_prompt": "draft"} refined: {"style": {"mood": "noir"}, "next_prompt": "cut off'
)
assert parsed == {"next_prompt": "draft"}
def test_parse_json_response_ignores_fragments_of_malformed_object_with_trailing_prose():
parsed = _parse_json_response(
'{"next_prompt": "draft"} {"final": {"mood": "noir"}, "x": 1 and then some prose'
)
assert parsed == {"next_prompt": "draft"}
def test_parse_json_response_raises_when_only_object_is_truncated():
with pytest.raises(ValueError):
_parse_json_response('{"style": {"mood": "noir"}, "next_prompt": "cut off')
def test_parse_json_response_returns_outer_rollout_dict():
parsed = _parse_json_response(
"{\"rollout\": {\"segment_prompts\": [{\"prompt\": \"a\"}, {\"prompt\": \"b\"}]}}"
)
assert parsed == {
"rollout": {"segment_prompts": [{"prompt": "a"}, {"prompt": "b"}]}
}
def test_load_prompt_required_falls_back_to_default_path(tmp_path):
fallback_path = tmp_path / "next_segment_system_prompt.md"
fallback_path.write_text("fallback prompt\n", encoding="utf-8")
+3 -2
View File
@@ -15,5 +15,6 @@ def _utc_now_iso() -> str:
PROMPT_EXTENSION_FAILURE_USER_MESSAGE = ("Prompt extension failed for this request.")
def _resolve_generation_segment_cap(*, single_clip_mode: bool, cap: int) -> int:
return 0 if single_clip_mode else cap
def _resolve_generation_segment_cap(*, single_clip_mode: bool, cap: int, manual_continuation_mode: bool = False) -> int:
# Steering (manual continuation) lets the user keep going indefinitely, like single-clip mode.
return 0 if (single_clip_mode or manual_continuation_mode) else cap
@@ -489,7 +489,7 @@ class VideoGenerationWorker:
num_inference_steps=NUM_INFERENCE_STEPS,
guidance_scale=1.0,
seed=10,
ltx2_image_crf=0.0,
ltx2_image_crf=(33.0 if image_path and segment_idx == 1 else 0.0),
image_path=image_path if segment_idx == 1 else None,
return_continuation_state=False,
)
+3
View File
@@ -29,6 +29,9 @@ test = [
dreamverse-server = "dreamverse.server_entry:cli"
dreamverse-mock-server = "dreamverse.mock_server:cli"
[tool.setuptools.packages.find]
include = ["dreamverse*"]
[tool.uv]
package = false
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# launch-dreamverse.sh — launch dreamverse-server on a compute node.
#
# Usage (from repo root):
# bash apps/dreamverse/scripts/launch-dreamverse.sh # GPUs 0-3, SP_SIZE=4
# CUDA_VISIBLE_DEVICES=0 bash apps/dreamverse/scripts/launch-dreamverse.sh # single GPU, SP_SIZE=1
set -euo pipefail
CONDA_PREFIX="$HOME/miniconda3/envs/dreamverse"
CUDA_RT_DIR="$CONDA_PREFIX/lib/python3.11/site-packages/nvidia/cuda_runtime/lib"
GXX="$CONDA_PREFIX/bin/aarch64-conda-linux-gnu-g++"
export CUDA_HOME="$CONDA_PREFIX"
export FASTVIDEO_ENABLE_STARTUP_WARMUP=true
export FASTVIDEO_ENABLE_PROMPT_SAFETY=false
export DREAMVERSE_MAX_AUTOTUNE=true
export LTX2_USE_DISTILLED_SIGMAS=0
export LTX2_VIDEO_CONDITIONING_NUM_FRAMES=1
export AUDIO_CONDITIONING_NUM_FRAMES=41
export DREAMVERSE_SESSION_TIMEOUT_SECONDS="${DREAMVERSE_SESSION_TIMEOUT_SECONDS:-1800}"
# GB200 max-autotune warmup compiles can run for hours; keep the watchdog generous here
export FASTVIDEO_STARTUP_WARMUP_TIMEOUT_SECONDS="${FASTVIDEO_STARTUP_WARMUP_TIMEOUT_SECONDS:-24000}"
export CEREBRAS_API_KEY="${CEREBRAS_API_KEY:-}" # set this in your env or ~/.env
export FASTVIDEO_PROMPT_CEREBRAS_MODEL="gpt-oss-120b"
export TORCHINDUCTOR_CACHE_DIR="$HOME/.cache/torchinductor"
export TRITON_CACHE_DIR="$HOME/.triton/cache"
export TORCH_CUDA_ARCH_LIST="10.0a"
# Compiler env (needed for flashinfer JIT compilation at server startup)
export CXX="$CONDA_PREFIX/compiler_compat/g++"
export CC="$CONDA_PREFIX/compiler_compat/gcc"
export CUDAHOSTCXX="$GXX"
export NVCC_PREPEND_FLAGS="-ccbin $GXX -allow-unsupported-compiler"
# Link against libcudart.so.12 at JIT compile time; stubs for libcuda.so
# cuda-compat has libcudart.so -> libcudart.so.12 (linker needs unversioned name)
export LIBRARY_PATH="$CONDA_PREFIX/lib/cuda-compat:$CONDA_PREFIX/lib/stubs"
# Only libcudart.so.12 at runtime — prevents cuDNN from seeing .so.13
export LD_LIBRARY_PATH="$CUDA_RT_DIR"
CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0,1,2,3}"
export FASTVIDEO_GPU_COUNT="${FASTVIDEO_GPU_COUNT:-all}"
# Default SP size to the usable GPU count so single-GPU invocations work.
# A numeric FASTVIDEO_GPU_COUNT caps the pool below the visible count, and an
# SP size above the pool size fails GPUPool startup with "Not enough GPUs".
IFS=',' read -ra _VISIBLE_GPUS <<< "$CUDA_VISIBLE_DEVICES"
# Count only non-empty tokens, matching gpu_pool.get_available_gpus (e.g. ",0,1" is 2 GPUs).
_USABLE_GPU_COUNT=0
for _gpu in "${_VISIBLE_GPUS[@]}"; do
[[ -n "${_gpu//[[:space:]]/}" ]] && _USABLE_GPU_COUNT=$((_USABLE_GPU_COUNT + 1))
done
if [[ "$FASTVIDEO_GPU_COUNT" =~ ^[0-9]+$ ]] && (( FASTVIDEO_GPU_COUNT < _USABLE_GPU_COUNT )); then
_USABLE_GPU_COUNT="$FASTVIDEO_GPU_COUNT"
fi
export DREAMVERSE_SP_SIZE="${DREAMVERSE_SP_SIZE:-$_USABLE_GPU_COUNT}"
PORT="${DREAMVERSE_PORT:-8009}"
FFMPEG_ENV="$(dirname "$0")/ffmpeg-env.sh"
# shellcheck source=ffmpeg-env.sh
[[ -f "$FFMPEG_ENV" ]] && source "$FFMPEG_ENV"
echo "==> Launching dreamverse-server on GPU $CUDA_VISIBLE_DEVICES port $PORT"
CUDA_VISIBLE_DEVICES="$CUDA_VISIBLE_DEVICES" \
"$CONDA_PREFIX/bin/dreamverse-server" --host 0.0.0.0 --port "$PORT"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# launch-frontend.sh — start the Dreamverse Next.js dev server and ngrok tunnel.
#
# Usage (from repo root):
# bash apps/dreamverse/scripts/launch-frontend.sh
#
# Override backend or ngrok URL via env:
# BACKEND_HOST=1.2.3.4 bash apps/dreamverse/scripts/launch-frontend.sh
set -euo pipefail
CONDA_PREFIX="$HOME/miniconda3/envs/dreamverse"
BACKEND_HOST="${BACKEND_HOST:-10.244.18.228}"
BACKEND_PORT="${BACKEND_PORT:-8009}"
NGROK_URL="${NGROK_URL:-ltx23.ngrok.app}"
WEB_DIR="$(git rev-parse --show-toplevel)/apps/dreamverse/web"
cleanup() {
echo "==> Shutting down..."
kill "$FRONTEND_PID" 2>/dev/null || true
}
trap cleanup EXIT
echo "==> Starting frontend (backend: $BACKEND_HOST:$BACKEND_PORT)"
BACKEND_HOST="$BACKEND_HOST" BACKEND_PORT="$BACKEND_PORT" \
npm run --prefix "$WEB_DIR" dev &
FRONTEND_PID=$!
echo "==> Starting ngrok tunnel -> $NGROK_URL"
"$CONDA_PREFIX/bin/ngrok" http --url="$NGROK_URL" 5299
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# setup-dreamverse-env.sh — create and configure the dreamverse conda env
# from scratch on this aarch64 NFS Slurm cluster.
#
# Run from the login node (from the repo root):
# bash apps/dreamverse/scripts/setup-dreamverse-env.sh
#
# After this script completes, use launch-dreamverse.sh on a compute node.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
ENV_NAME="dreamverse"
LOCAL_DIR="/mnt/local/hal-kevin" # cache/pkgs — keep on local disk
CONDA_PREFIX="$HOME/miniconda3/envs/$ENV_NAME" # env — on shared NFS so it survives node changes
echo "==> Removing existing env if present"
conda env remove -p "$CONDA_PREFIX" -y 2>/dev/null || true
rm -rf "$CONDA_PREFIX" 2>/dev/null || true
echo "==> Creating conda env at $CONDA_PREFIX"
CONDA_PKGS_DIRS="$LOCAL_DIR/conda/pkgs" conda create -p "$CONDA_PREFIX" python=3.11 -y
GXX="$CONDA_PREFIX/bin/aarch64-conda-linux-gnu-g++"
GCC="$CONDA_PREFIX/bin/aarch64-conda-linux-gnu-gcc"
echo "==> Installing compiler"
CONDA_PKGS_DIRS="$LOCAL_DIR/conda/pkgs" conda install -p "$CONDA_PREFIX" gxx_linux-aarch64 -y
echo "==> Installing CUDA toolkit (nvcc + headers)"
CONDA_PKGS_DIRS="$LOCAL_DIR/conda/pkgs" conda install -p "$CONDA_PREFIX" -c nvidia cuda-toolkit -y
echo "==> Hiding conflicting libcudart.so.13 immediately"
mkdir -p "$CONDA_PREFIX/lib/hidden"
mv "$CONDA_PREFIX"/lib/libcudart.so* "$CONDA_PREFIX/lib/hidden/" 2>/dev/null || true
echo "==> Fixing compiler_compat symlinks"
mkdir -p "$CONDA_PREFIX/compiler_compat"
ln -sf "$GXX" "$CONDA_PREFIX/compiler_compat/g++"
ln -sf "$GCC" "$CONDA_PREFIX/compiler_compat/gcc"
echo "==> Symlinking CUDA headers to standard location"
for f in "$CONDA_PREFIX/targets/sbsa-linux/include/"*; do
ln -sf "$f" "$CONDA_PREFIX/include/$(basename "$f")" 2>/dev/null || true
done
echo "==> Installing ffmpeg (native build with x264 + NVENC)"
CUDA_PREFIX="$CONDA_PREFIX" bash "$REPO_ROOT/apps/dreamverse/scripts/install_native_ffmpeg.sh"
echo "==> Installing pip and uv"
CONDA_PKGS_DIRS="$LOCAL_DIR/conda/pkgs" conda install -p "$CONDA_PREFIX" pip -y
"$CONDA_PREFIX/bin/pip" install uv
echo "==> Setting compiler env vars"
export UV_CACHE_DIR="$LOCAL_DIR/cache"
export UV_LINK_MODE=copy
export CXX="$CONDA_PREFIX/compiler_compat/g++"
export CC="$CONDA_PREFIX/compiler_compat/gcc"
export CUDAHOSTCXX="$GXX"
export NVCC_PREPEND_FLAGS="-ccbin $GXX -allow-unsupported-compiler"
export CUDA_HOME="$CONDA_PREFIX"
# Only build for GB200 (sm_100a); CUDA 13 dropped support for older archs
export TORCH_CUDA_ARCH_LIST="10.0a"
echo "==> Installing torch with CUDA 12.8"
UV_CACHE_DIR="$LOCAL_DIR/cache" "$CONDA_PREFIX/bin/uv" pip install torch==2.11.0 torchvision \
--index-url https://download.pytorch.org/whl/cu128
echo "==> Hiding any newly introduced libcudart.so.13"
mv "$CONDA_PREFIX"/lib/libcudart.so* "$CONDA_PREFIX/lib/hidden/" 2>/dev/null || true
# Set paths now that torch (and its nvidia packages) are installed
CUDA_RT_DIR="$CONDA_PREFIX/lib/python3.11/site-packages/nvidia/cuda_runtime/lib"
CUDA_RT_SO="$(ls "$CUDA_RT_DIR"/libcudart.so.* 2>/dev/null | head -1)"
# The pip nvidia package only has libcudart.so.12 (versioned), not libcudart.so.
# The linker needs the unversioned name to satisfy -lcudart. Create a compat dir.
mkdir -p "$CONDA_PREFIX/lib/cuda-compat"
ln -sf "$CUDA_RT_SO" "$CONDA_PREFIX/lib/cuda-compat/libcudart.so"
export LIBRARY_PATH="$CONDA_PREFIX/lib/cuda-compat:$CONDA_PREFIX/lib/stubs"
export CMAKE_ARGS="-DCUDA_CUDART_LIBRARY=$CUDA_RT_SO -DCUDA_INCLUDE_DIRS=$CONDA_PREFIX/targets/sbsa-linux/include"
echo "==> Installing build tools"
"$CONDA_PREFIX/bin/pip" install scikit-build-core cmake ninja
echo "==> Initializing git submodules"
cd "$REPO_ROOT"
git submodule update --init fastvideo-kernel/include/cutlass fastvideo-kernel/include/tk
echo "==> Building fastvideo-kernel from local source"
UV_CACHE_DIR="$LOCAL_DIR/cache" "$CONDA_PREFIX/bin/uv" pip install \
-e "./fastvideo-kernel" --no-build-isolation
echo "==> Installing fastvideo + dreamverse extras"
UV_CACHE_DIR="$LOCAL_DIR/cache" "$CONDA_PREFIX/bin/uv" pip install \
-e ".[dreamverse]" --no-build-isolation
echo "==> Installing flashinfer-python (pinned, must be last)"
UV_CACHE_DIR="$LOCAL_DIR/cache" "$CONDA_PREFIX/bin/uv" pip install \
https://github.com/flashinfer-ai/flashinfer/releases/download/v0.6.11.post3/flashinfer_python-0.6.11.post3-py3-none-any.whl
echo ""
echo "Done. On a compute node run (GPUs 0-3 by default; set CUDA_VISIBLE_DEVICES to restrict):"
echo " bash apps/dreamverse/scripts/launch-dreamverse.sh"
@@ -110,7 +110,7 @@ default_request:
fps: 24 # internal: gpu_pool.py:85 TARGET_FPS
streaming:
# internal: config.py:33 SESSION_TIMEOUT_SECONDS = 300
# internal: config.py SESSION_TIMEOUT_SECONDS (env DREAMVERSE_SESSION_TIMEOUT_SECONDS, default 300)
session_timeout_seconds: 300
# internal: config.py:282-284 GENERATION_SEGMENT_CAP default 6
generation_segment_cap: 6
+125 -13
View File
@@ -9,7 +9,7 @@ import SessionTimeoutModal from "@/components/SessionTimeoutModal";
import Sidebar from "@/components/Sidebar";
import Header from "@/components/Header";
import VideoPlayer from "@/components/VideoPlayer";
import Workspace from "@/components/Workspace";
import Workspace, { SceneHistoryList } 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";
@@ -31,7 +31,7 @@ import { applyNormalizedSocketEvent } from "@/lib/ws/reducer";
import { createPromptWindowStore } from "@/stores/promptWindow";
import { createRewriteStore } from "@/stores/rewrite";
import { createSessionStore } from "@/stores/session";
import { createStreamStore } from "@/stores/stream";
import { createStreamStore, USER_PROMPT_SOURCES } from "@/stores/stream";
import { createUiStore } from "@/stores/ui";
import { Button } from "@/components/ui/button";
@@ -80,7 +80,7 @@ function yieldToEventLoop(): Promise<void> {
const HERO_WAVE_LIGHT = ["#2A4A98", "#4878E5", "#6FA0F2", "#B0BCC8", "#E8D99E", "#D8C844", "#C2A620"];
const HERO_WAVE_DARK = ["#143468", "#1E58B8", "#3892F0", "#80B8E8", "#B8D0EA", "#E2D498", "#DABB50"];
const HERO_TEXT = "Direct scenes in seconds";
const HERO_TEXT = "Direct scenes in seconds with";
function HeroTagline() {
const ref = useRef<HTMLHeadingElement>(null);
@@ -166,6 +166,8 @@ function HeroTagline() {
</span>
</Fragment>
))}
<span data-char className="transition-[color,filter] duration-150">{" "}</span>
<img src="/logo.svg" alt="FastVideo" className="inline-block h-[1.1em] w-auto align-middle" />
</h1>
);
}
@@ -237,6 +239,7 @@ export default function Page() {
enhancementEnabled,
promptExtensionError,
autoExtensionEnabled,
manualContinuationMode,
autoExtensionTimeoutHint,
loopGenerationEnabled,
generationPaused,
@@ -249,6 +252,8 @@ export default function Page() {
livePromptRewriteMode,
sessionExpired,
projectResetPending,
waitingForSegmentPrompt,
generatingNextScene,
} = sessionState;
const {
@@ -323,6 +328,11 @@ export default function Page() {
const [ttffValueMs, setTtffValueMs] = useState<number | null>(null);
const ttffIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pendingInitialPromptRef = useRef("");
// Prompt id the opening scene is recorded under; sent as initial_rollout_prompt_id
// so the backend's pre-seeded opening PromptSubmission emits status updates
// (prompt_enhancing/prompt_ready/prompt_fallback_used) against the same id.
const pendingInitialPromptIdRef = useRef("");
const [initialImageDataUrl, setInitialImageDataUrl] = useState("");
const lastArchivedReplayKeyRef = useRef("");
const [sidebarOpen, setSidebarOpen] = useState(false);
const [currentThumbnail, setCurrentThumbnail] = useState<string | null>(null);
@@ -422,6 +432,11 @@ export default function Page() {
if (String(e?.source || "") === "user_rewrite" && typeof e?.text === "string" && e.text.trim()) {
return e.text.trim();
}
// Steering opening: the backend overwrites text/source with the enhanced
// prompt once ready, so fall back to the stable rawText record.
if (e?.steeringUserPrompt && typeof e?.rawText === "string" && e.rawText.trim()) {
return e.rawText.trim();
}
}
return "Untitled project";
}, [selectedPreset, promptEvents]);
@@ -432,11 +447,39 @@ export default function Page() {
const canDownloadVideo = useMemo(() => {
const currentActiveClip = activeClip as Record<string, any> | null;
if (currentActiveClip?.blob instanceof Blob) return true;
return (completedClips as Record<string, any>[]).some((clip) => clip?.blob instanceof Blob);
}, [activeClip, completedClips]);
if ((completedClips as Record<string, any>[]).some((clip) => clip?.blob instanceof Blob)) return true;
// Steering only: once playback has started the live AV pipeline holds playable segments, so
// the user can download the in-progress video at any time (handleDownloadVideo remuxes live
// segments). Auto mode keeps its original blob-gated behavior.
return Boolean(manualContinuationMode) && Boolean(avPlaybackStarted);
}, [activeClip, completedClips, avPlaybackStarted, manualContinuationMode]);
// Steering mode scene list (oldest first). Primary source is the user's own words, captured
// stably at submit time as `rawText` (the backend later overwrites text/source with the
// enhanced prompt, so we never read those). A segment with no user prompt — e.g. a preset's
// opening scene — falls back to promptHistory (the actual prompt that drove that segment).
const steeringScenes = useMemo(() => {
if (!manualContinuationMode) return [] as Record<string, any>[];
const userScenes = (promptEvents as Record<string, any>[])
.filter((e) => e?.steeringUserPrompt && !e?.steeringFailed && typeof e?.rawText === "string" && e.rawText.trim())
.slice()
.reverse() // oldest -> newest
.map((e) => ({ id: e.promptId, prompt: e.rawText as string }));
const scenes: Record<string, any>[] = [];
// Preset opening segments: curated seeds with no user prompt of their own.
const curatedHists = (promptHistory as Record<string, any>[])
.slice()
.reverse() // oldest first
.filter((h) => !USER_PROMPT_SOURCES.has(String(h?.source || "")) && typeof h?.prompt === "string" && (h.prompt as string).trim());
scenes.push(...curatedHists.map((h) => ({ id: h.id || "scene_open", prompt: h.prompt })));
scenes.push(...userScenes);
return scenes;
}, [manualContinuationMode, promptEvents, promptHistory]);
const hasEdits = useMemo(
() => Boolean(sessionStarted) && (promptEvents as Record<string, any>[]).some((e) => typeof e?.text === "string" && e.text.trim() && String(e?.source || "").trim() === "user_rewrite"),
() => Boolean(sessionStarted) && (
(promptEvents as Record<string, any>[]).some((e) => typeof e?.text === "string" && e.text.trim() && String(e?.source || "").trim() === "user_rewrite")
),
[sessionStarted, promptEvents],
);
@@ -1421,7 +1464,8 @@ export default function Page() {
if (!prompt) return;
lastSubmitTimeRef.current = now;
const rCAR = !uiStore.get().devtoolsMode && !uiStore.get().demoMode;
if (rCAR || (sessionStore.get().livePromptRewriteMode && !uiStore.get().demoMode)) {
const inManualMode = sessionStore.get().manualContinuationMode;
if (!inManualMode && (rCAR || (sessionStore.get().livePromptRewriteMode && !uiStore.get().demoMode))) {
if (rewriteStore.get().rewritingSeedPrompts) return;
const rewriteSourcePromptWindowPrompts = getActivePromptWindowPrompts();
const nextPendingClip = {
@@ -1462,6 +1506,11 @@ export default function Page() {
status: "submitted",
source: "user_raw",
text: prompt,
// Stable record of the user's own words for the steering scene list. The backend
// later overwrites `text`/`source` with the enhanced prompt via prompt/ready, but
// these two fields are never touched by trackPromptEvent.
steeringUserPrompt: true,
rawText: prompt,
});
ws.send(
JSON.stringify({
@@ -1475,7 +1524,14 @@ export default function Page() {
activeClipId: shouldUseArchivedPlaybackFallback() ? streamStore.get().activeClipId : "",
activePlaybackStartTime: shouldUseArchivedPlaybackFallback() ? streamStore.get().activePlaybackStartTime : 0,
});
sessionStore.patch({ livePromptDraft: "" });
sessionStore.patch({
livePromptDraft: "",
waitingForSegmentPrompt: false,
sessionNotice: "",
// Light the "Generating next scene" overlay immediately on a real submit;
// stream/media_init (or a fallback/error) clears it.
...(inManualMode ? { generatingNextScene: true } : {}),
});
}
function setLivePromptRewriteMode(enabled: boolean) {
@@ -1550,6 +1606,14 @@ export default function Page() {
);
}
// Steering (manual continuation) vs the automatic 6-segment rollout — a pre-session
// preference honored when the session starts.
function handleManualContinuationToggle(event: any) {
sessionStore.patch({
manualContinuationMode: Boolean(event.currentTarget.checked),
});
}
function handleLoopGenerationToggle(event: any) {
sessionStore.patch({
loopGenerationEnabled: Boolean(event.currentTarget.checked),
@@ -1725,6 +1789,9 @@ export default function Page() {
sessionNotice: preserveSessionNotice ? sessionStore.get().sessionNotice : "",
sessionExpired: preserveSessionNotice ? sessionStore.get().sessionExpired : false,
projectResetPending: false,
manualContinuationMode: true,
waitingForSegmentPrompt: false,
generatingNextScene: false,
});
rewriteStore.resetSessionState();
streamStore.resetSessionState();
@@ -1737,6 +1804,7 @@ export default function Page() {
function resetToProjectLobbyState() {
setVideoMuted(true);
pendingInitialPromptRef.current = "";
pendingInitialPromptIdRef.current = "";
sessionStore.patch({
sessionStarted: false,
livePromptDraft: "",
@@ -1750,6 +1818,9 @@ export default function Page() {
sessionNotice: "",
sessionExpired: false,
projectResetPending: false,
manualContinuationMode: true,
waitingForSegmentPrompt: false,
generatingNextScene: false,
});
rewriteStore.resetSessionState();
streamStore.resetSessionState();
@@ -1760,7 +1831,14 @@ export default function Page() {
}
function buildProjectInitPayload(type: "session_init_v2" | "project_init_v1") {
const segmentPrompts = getSessionInitPrompts();
const manualMode = Boolean(sessionStore.get().manualContinuationMode);
let segmentPrompts = getSessionInitPrompts();
// Steering mode: seed the first 2 segments from the preset so there's no
// gap between segment 1 and 2; the user drives every subsequent segment.
// Force auto/loop off so the backend waits after the seeded prompts run out.
if (manualMode) {
segmentPrompts = segmentPrompts.slice(0, 2);
}
setSeedPrompts(segmentPrompts);
return {
type,
@@ -1768,11 +1846,17 @@ export default function Page() {
preset_label: getInitialPresetLabel(),
curated_prompts: segmentPrompts,
initial_rollout_prompt: normalizeInitialPrompt(pendingInitialPromptRef.current),
initial_image: null,
// Ties the backend's pre-seeded opening PromptSubmission to the prompt event
// recorded in beginProjectLocally so its status updates land on that record.
initial_rollout_prompt_id: pendingInitialPromptIdRef.current,
initial_image: initialImageDataUrl
? { data_url: initialImageDataUrl, mime_type: initialImageDataUrl.split(";")[0].split(":")[1] || "image/png", name: "upload.png" }
: null,
single_clip_mode: false,
enhancement_enabled: sessionStore.get().enhancementEnabled,
auto_extension_enabled: sessionStore.get().autoExtensionEnabled,
loop_generation_enabled: sessionStore.get().loopGenerationEnabled,
auto_extension_enabled: manualMode ? false : sessionStore.get().autoExtensionEnabled,
loop_generation_enabled: manualMode ? false : sessionStore.get().loopGenerationEnabled,
manual_continuation_mode: manualMode,
};
}
@@ -1951,7 +2035,11 @@ export default function Page() {
setCurrentThumbnail(null);
const initialPrompt = normalizeInitialPrompt(sessionStore.get().livePromptDraft as string);
pendingInitialPromptRef.current = initialPrompt;
setInitialImageDataUrl("");
const rCAR = !uiStore.get().devtoolsMode && !uiStore.get().demoMode;
// The "Steering mode" toggle is authoritative: checked = manual per-segment steering,
// unchecked = automatic 6-segment rollout (default).
const nextManualContinuationMode = Boolean(sessionStore.get().manualContinuationMode);
sessionStore.patch({
sessionNotice: "",
sessionExpired: false,
@@ -1966,6 +2054,9 @@ export default function Page() {
autoExtensionTimeoutHint: "",
generationPaused: false,
projectResetPending: false,
manualContinuationMode: nextManualContinuationMode,
waitingForSegmentPrompt: false,
generatingNextScene: false,
});
resetPlaybackState();
streamStore.patch({
@@ -1979,12 +2070,15 @@ export default function Page() {
selectedHistoryId: "",
});
rewriteStore.resetSessionState();
pendingInitialPromptIdRef.current = initialPrompt ? makePromptId() : "";
if (initialPrompt) {
addPromptEvent({
promptId: makePromptId(),
promptId: pendingInitialPromptIdRef.current,
status: "rewrite_requested",
source: "user_rewrite",
text: initialPrompt,
// In steering mode the typed opening is the user's Scene 1 — record it stably.
...(nextManualContinuationMode ? { steeringUserPrompt: true, rawText: initialPrompt } : {}),
});
}
setSeedPrompts(getSessionInitPrompts());
@@ -2500,6 +2594,7 @@ export default function Page() {
selectedPresetId={selectedPresetId as string}
enhancementEnabled={enhancementEnabled as boolean}
autoExtensionEnabled={autoExtensionEnabled as boolean}
manualContinuationEnabled={manualContinuationMode as boolean}
loopGenerationEnabled={loopGenerationEnabled as boolean}
canJoinSession={canJoinSession as boolean}
canSubmitContinuation={canSubmitContinuation}
@@ -2512,6 +2607,7 @@ export default function Page() {
onEnhancementToggle={handleEnhancementToggle}
onCuratedPromptLimitChange={handleCuratedPromptLimitChange}
onAutoExtensionToggle={handleAutoExtensionToggle}
onManualContinuationToggle={handleManualContinuationToggle}
onLoopToggle={handleLoopGenerationToggle}
onJoin={joinSession}
onLeave={leaveSession}
@@ -2641,6 +2737,7 @@ export default function Page() {
/>
<Header timeLeft={headerTimeLeft} formatTime={formatTime} onToggleSidebar={() => setSidebarOpen((prev) => !prev)} />
<div className={cn("flex flex-1 min-h-0 flex-col", sessionStarted && "pb-16 sm:pb-28")}>
<div className="relative flex flex-1 min-h-0 flex-col justify-center px-4 pb-2 sm:px-6 sm:pb-12">
{isViewingMode && (
<>
@@ -2724,6 +2821,8 @@ export default function Page() {
showLivePlayback={showLivePlayback}
defaultMuted={videoMuted}
canDownload={canDownloadVideo}
waitingForSegmentPrompt={waitingForSegmentPrompt as boolean}
generatingNextScene={generatingNextScene as boolean}
onPlaying={markFirstFrameRendered}
onDownload={handleDownloadVideo}
/>
@@ -2734,6 +2833,7 @@ export default function Page() {
<section className={cn("mx-auto w-full max-w-2xl", hasEdits && "flex-1 min-h-0 overflow-y-auto")}>
<Workspace
promptEvents={promptEvents as any[]}
manualMode={manualContinuationMode as boolean}
currentThumbnail={currentThumbnail}
originalLabel={pendingInitialPromptRef.current || (selectedPreset as Record<string, any>)?.label || ""}
sessionStarted={sessionStarted as boolean}
@@ -2780,11 +2880,17 @@ export default function Page() {
isGenerating={loadingAnimation as boolean}
storyPresets={storyPresets as any[]}
continuationDraft={livePromptDraft as string}
manualContinuationEnabled={manualContinuationMode as boolean}
onModeChange={(manual) => sessionStore.patch({ manualContinuationMode: manual })}
initialImageDataUrl={initialImageDataUrl}
onImageUpload={(dataUrl) => setInitialImageDataUrl(dataUrl)}
onImageClear={() => setInitialImageDataUrl("")}
canJoinSession={canStartSession}
canSubmitContinuation={canSubmitContinuation}
sessionExpired={sessionExpired as boolean}
sessionNotice={sessionNotice as string}
projectResetPending={projectResetPending as boolean}
waitingForSegmentPrompt={waitingForSegmentPrompt as boolean}
onPresetGenerate={handlePresetGenerate}
onContinuationInput={handleLivePromptInput}
onContinuationKeydown={handleLivePromptKeydown}
@@ -2798,6 +2904,12 @@ export default function Page() {
</motion.div>
</div>
</div>
{manualContinuationMode && (
<div className="px-4 sm:px-6">
<SceneHistoryList sceneHistory={steeringScenes as any[]} />
</div>
)}
</div>
</main>
);
}
+96 -6
View File
@@ -2,13 +2,16 @@
import React, { useRef, useState, useCallback, useEffect } from "react";
import Image from "next/image";
import { Film, ArrowUp, X, Loader2, ArrowLeft } from "lucide-react";
import { Film, ArrowUp, X, Loader2, ArrowLeft, ImagePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import LeaveSessionModal, { shouldShowLeaveWarning } from "@/components/LeaveSessionModal";
import SpeechToTextButton from "@/components/SpeechToTextButton";
import { cn } from "@/lib/utils";
const PROMPT_MAX_LENGTH = 500;
// Must match backend session_init_image.py: MAX_SESSION_INIT_IMAGE_BYTES / SUPPORTED_SESSION_INIT_IMAGE_MIME_TYPES.
const IMAGE_MAX_BYTES = 15 * 1024 * 1024;
const IMAGE_ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp"];
interface Props {
sessionStarted?: boolean;
@@ -22,6 +25,12 @@ interface Props {
sessionNotice?: string;
projectResetPending?: boolean;
viewingReadOnly?: boolean;
waitingForSegmentPrompt?: boolean;
manualContinuationEnabled?: boolean;
onModeChange?: (manual: boolean) => void;
initialImageDataUrl?: string;
onImageUpload?: (dataUrl: string, mimeType: string, name: string) => void;
onImageClear?: () => void;
onPresetGenerate?: (presetId: string) => void;
onContinuationInput?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onContinuationKeydown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
@@ -46,6 +55,12 @@ export default function ChatBar({
sessionNotice = "",
projectResetPending = false,
viewingReadOnly = false,
waitingForSegmentPrompt = false,
manualContinuationEnabled = false,
onModeChange = () => {},
initialImageDataUrl = "",
onImageUpload = () => {},
onImageClear = () => {},
onPresetGenerate = () => {},
onContinuationInput = () => {},
onContinuationKeydown = () => {},
@@ -59,15 +74,51 @@ export default function ChatBar({
}: Props) {
const [sttBusy, setSttBusy] = useState(false);
const [leaveModalOpen, setLeaveModalOpen] = useState(false);
const [imageError, setImageError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const processImageFile = useCallback((file: File) => {
if (!IMAGE_ALLOWED_TYPES.includes(file.type)) {
setImageError("Unsupported image type. Use a PNG, JPEG, or WebP image.");
return;
}
if (file.size > IMAGE_MAX_BYTES) {
setImageError("Image is too large. The maximum size is 15MB.");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) {
setImageError("");
onImageUpload(dataUrl, file.type, file.name);
}
};
reader.onerror = () => {
setImageError("Could not read the image file. Please try again.");
};
reader.readAsDataURL(file);
}, [onImageUpload]);
const handleImagePaste = useCallback((e: React.ClipboardEvent) => {
if (sessionStarted) return;
const items = Array.from(e.clipboardData?.items ?? []);
const imageItem = items.find((item) => item.type.startsWith("image/"));
if (!imageItem) return;
const file = imageItem.getAsFile();
if (file) processImageFile(file);
}, [sessionStarted, processImageFile]);
const showSpinner = isGenerating || rewritingSeedPrompts;
const isBusy = isGenerating || rewritingSeedPrompts || projectResetPending;
const messagePlaceholder = projectResetPending
? "Starting new project\u2026"
: isBusy
? "Generating video\u2026"
: !sessionStarted
? "What video are you imagining?"
: "What do you want to edit?";
: waitingForSegmentPrompt
? "Describe the next scene\u2026"
: !sessionStarted
? "What video are you imagining?"
: "What do you want to edit?";
const actionLabel = !sessionStarted ? "Generate" : "Rewrite rollout";
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -267,9 +318,9 @@ export default function ChatBar({
<Button onClick={onStartNewProject} size="sm" className="rounded-full px-5">
New Project
</Button>
<a href="https://docs.google.com/forms/d/e/1FAIpQLSe5zpO1iD8Ds-Ih-fOLm64qd7YZVvuvAyHuJaAfw1hkRHTe_A/viewform?usp=publish-editor" target="_blank" rel="noopener noreferrer">
<a href="https://haoailab.com/blogs/dreamverse/" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="rounded-full px-5">
Join Waitlist
Blog
</Button>
</a>
</div>
@@ -346,6 +397,31 @@ export default function ChatBar({
</div>
)}
{!sessionStarted && imageError && (
<div className="rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-2.5 text-center text-xs text-rose-700 dark:text-rose-300">
{imageError}
</div>
)}
{!sessionStarted && initialImageDataUrl && (
<div className="flex items-center gap-2 rounded-2xl border border-input bg-card/65 px-3 py-2">
<img src={initialImageDataUrl} alt="Initial frame" className="h-12 w-12 rounded-lg object-cover" />
<span className="flex-1 truncate text-xs text-muted-foreground">Starting image set</span>
<button type="button" onClick={() => { setImageError(""); onImageClear(); }} className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-4" />
</button>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept={IMAGE_ALLOWED_TYPES.join(",")}
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) processImageFile(f); e.target.value = ""; }}
/>
<div
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",
@@ -359,6 +435,7 @@ export default function ChatBar({
value={continuationDraft}
onChange={onContinuationInput}
onKeyDown={handleKeyDown}
onPaste={handleImagePaste}
placeholder={sttBusy ? "Listening\u2026" : messagePlaceholder}
maxLength={PROMPT_MAX_LENGTH}
disabled={isBusy || sttBusy}
@@ -369,6 +446,19 @@ export default function ChatBar({
)}
/>
{onSpeechTranscript && <SpeechToTextButton disabled={isBusy} onTranscript={onSpeechTranscript} onInterimChange={onSpeechInterimChange} onBusyChange={setSttBusy} />}
{!sessionStarted && (
<Button
type="button"
variant="ghost"
size="icon-sm"
title="Add starting image"
onClick={() => fileInputRef.current?.click()}
disabled={isBusy}
className="shrink-0 rounded-full text-muted-foreground hover:text-foreground"
>
<ImagePlus className="size-4" />
</Button>
)}
{!sessionStarted ? (
<Button
aria-label={actionLabel}
@@ -8,7 +8,6 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/ui/theme-toggle";
const FASTVIDEO_REPO_URL = "https://haoailab.com/blogs/dreamverse/";
const FASTVIDEO_BLOG_URL = "https://haoailab.com/blogs/dreamverse/";
interface Props {
@@ -29,13 +28,13 @@ export default function Header({ timeLeft = null, formatTime = (seconds) => `${s
<SidePanelOpenFilled size={20} />
</Button>
)}
<a href={FASTVIDEO_REPO_URL} target="_blank" rel="noopener noreferrer" title="FastVideo on GitHub">
<a href="/" title="FastVideo home">
<Image src="/logo.svg" alt="FastVideo" width={32} height={32} className="h-8 w-auto sm:h-9 transition-opacity hover:opacity-70" />
</a>
<div className="hidden sm:flex items-center gap-3">
<a href="https://docs.google.com/forms/d/e/1FAIpQLSe5zpO1iD8Ds-Ih-fOLm64qd7YZVvuvAyHuJaAfw1hkRHTe_A/viewform?usp=publish-editor" target="_blank" rel="noopener noreferrer">
<a href={FASTVIDEO_BLOG_URL} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-1.5 rounded-full px-3 text-xs">
Join Waitlist
Blog
<ExternalLink className="size-3 opacity-60" />
</Button>
</a>
@@ -53,9 +52,9 @@ export default function Header({ timeLeft = null, formatTime = (seconds) => `${s
</div>
<div className="flex sm:hidden items-center gap-2 px-4 pb-3">
<a href="https://docs.google.com/forms/d/e/1FAIpQLSe5zpO1iD8Ds-Ih-fOLm64qd7YZVvuvAyHuJaAfw1hkRHTe_A/viewform?usp=publish-editor" target="_blank" rel="noopener noreferrer">
<a href={FASTVIDEO_BLOG_URL} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-1.5 rounded-full px-3 text-xs">
Join Waitlist
Blog
<ExternalLink className="size-3 opacity-60" />
</Button>
</a>
@@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { PlayFilledAlt } from "@carbon/icons-react";
import { Download, Loader2, Share } from "lucide-react";
import { Check, ChevronDown, Download, Loader2, Share } from "lucide-react";
import { Button } from "@/components/ui/button";
interface VideoPlayerProps {
videoRef?: React.RefCallback<HTMLVideoElement>;
@@ -21,6 +21,8 @@ interface VideoPlayerProps {
showLivePlayback?: boolean;
defaultMuted?: boolean;
rewritePending?: boolean;
waitingForSegmentPrompt?: boolean;
generatingNextScene?: boolean;
onPlaying?: () => void;
onDownload?: () => void;
}
@@ -57,6 +59,8 @@ export default function VideoPlayer({
showLivePlayback = true,
defaultMuted = true,
rewritePending = false,
waitingForSegmentPrompt = false,
generatingNextScene = false,
onPlaying = () => {},
onDownload,
}: VideoPlayerProps) {
@@ -88,6 +92,137 @@ export default function VideoPlayer({
setCanShare(typeof navigator.canShare === "function" && window.matchMedia("(pointer: coarse)").matches);
}, []);
// Steering mode: the backend may signal "waiting for next prompt" while the current
// segment is still PLAYING (it generates ahead). Only surface the "Segment complete"
// overlay once the playhead actually reaches the end of the buffered segment.
const [playbackReachedEnd, setPlaybackReachedEnd] = useState(false);
// Steering mode: when the user submits the next scene, the segment is generated
// (a few seconds of latency) before frames stream. Show a "Generating next scene…"
// indicator across that gap so the frozen frame isn't silent. Driven by the explicit
// generatingNextScene state (set on scene submit / prompt selection), never inferred
// from waitingForSegmentPrompt edges.
const [generatingNext, setGeneratingNext] = useState(false);
// End of the buffered timeline captured the moment generation starts; the freshly
// generated segment extends the buffer past this, which is how we know it landed.
const genBoundaryRef = useRef(0);
// Steering mode: the backend may signal "waiting for next prompt" while the current
// segment is still PLAYING (it generates ahead). Track whether the playhead has reached
// the end of the buffered segment so end-overlays only show there. Keep tracking through
// the generating phase too, so scrubbing back and replaying to the end re-shows them.
useEffect(() => {
if (!waitingForSegmentPrompt && !generatingNext) {
setPlaybackReachedEnd(false);
return;
}
const el = liveVideoEl.current;
if (!el) return;
const check = () => {
try {
const buffered = el.buffered;
if (buffered.length === 0) return;
const end = buffered.end(buffered.length - 1);
// Track proximity both ways: scrubbing back off the end hides the overlay,
// playing forward to the end re-shows it.
setPlaybackReachedEnd(el.ended || end - el.currentTime <= 0.2);
} catch {
/* buffered access can throw mid-append */
}
};
check();
el.addEventListener("timeupdate", check);
el.addEventListener("ended", check);
el.addEventListener("waiting", check);
el.addEventListener("stalled", check);
el.addEventListener("pause", check);
el.addEventListener("seeking", check);
el.addEventListener("seeked", check);
el.addEventListener("playing", check);
el.addEventListener("progress", check);
return () => {
el.removeEventListener("timeupdate", check);
el.removeEventListener("ended", check);
el.removeEventListener("waiting", check);
el.removeEventListener("stalled", check);
el.removeEventListener("pause", check);
el.removeEventListener("seeking", check);
el.removeEventListener("seeked", check);
el.removeEventListener("playing", check);
el.removeEventListener("progress", check);
};
}, [waitingForSegmentPrompt, generatingNext]);
useEffect(() => {
if (waitingForSegmentPrompt || !sessionStarted) {
// Back to waiting (or session over): nothing is generating.
setGeneratingNext(false);
return;
}
if (!generatingNextScene) return;
// Snapshot the current end of the buffered timeline; the generated segment will
// extend the buffer past this boundary.
const el = liveVideoEl.current;
let boundary = el?.currentTime ?? 0;
try {
const b = el?.buffered;
if (b && b.length) boundary = Math.max(boundary, b.end(b.length - 1));
} catch {
/* buffered access can throw mid-append */
}
genBoundaryRef.current = boundary;
setGeneratingNext(true);
}, [generatingNextScene, waitingForSegmentPrompt, sessionStarted]);
useEffect(() => {
if (!generatingNext) return;
if (!sessionStarted) {
setGeneratingNext(false);
return;
}
const el = liveVideoEl.current;
if (!el) return;
// Clear the instant the freshly generated segment lands: the buffer grows past the
// boundary captured at generation start (or the playhead advances into the new
// frames). Deliberately NOT a bare "playing" handler — scrubbing back and replaying
// the EXISTING segment must keep "Generating" up until the new frames actually arrive.
const check = () => {
try {
const b = el.buffered;
const end = b.length ? b.end(b.length - 1) : 0;
if (end > genBoundaryRef.current + 0.25 || el.currentTime > genBoundaryRef.current + 0.1) {
setGeneratingNext(false);
}
} catch {
/* buffered access can throw mid-append */
}
};
check();
el.addEventListener("progress", check);
el.addEventListener("timeupdate", check);
el.addEventListener("durationchange", check);
return () => {
el.removeEventListener("progress", check);
el.removeEventListener("timeupdate", check);
el.removeEventListener("durationchange", check);
};
}, [generatingNext, sessionStarted]);
// Drive a ~10.5s progress bar during generation so the wait has a visible ETA.
const GEN_DURATION_MS = 10500;
const [genProgress, setGenProgress] = useState(0);
useEffect(() => {
if (!generatingNext) {
setGenProgress(0);
return;
}
const start = performance.now();
setGenProgress(0);
const id = setInterval(() => {
setGenProgress(Math.min((performance.now() - start) / GEN_DURATION_MS, 1));
}, 50);
return () => clearInterval(id);
}, [generatingNext]);
return (
<div className="mx-auto w-full max-w-3xl mb-2 sm:mb-6">
<div className="rounded-2xl border border-border bg-card/50 p-2 shadow-lg backdrop-blur-md">
@@ -104,7 +239,7 @@ export default function VideoPlayer({
<PlayFilledAlt className="size-10 text-white/25" />
<p className="text-sm text-white/50">Your video will appear here</p>
</div>
) : !avPlaybackStarted && !mediaAppendError && !inQueue && loadingAnimation ? (
) : !avPlaybackStarted && !mediaAppendError && !inQueue && !waitingForSegmentPrompt && loadingAnimation ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-slate-900/60 p-4 backdrop-blur-[2px]">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_3s_ease-in-out_infinite] bg-gradient-to-r from-transparent via-white/[0.04] to-transparent" />
@@ -114,6 +249,35 @@ export default function VideoPlayer({
</div>
) : null}
{/* Steering mode: this segment finished — wait gracefully for the user's next scene
instead of spinning. The last frame stays visible behind a soft bottom gradient. */}
{sessionStarted && waitingForSegmentPrompt && playbackReachedEnd && !mediaAppendError && !inQueue && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-end gap-2 bg-gradient-to-t from-slate-950/85 via-slate-950/15 to-transparent p-5 pb-6 text-center">
<div className="flex size-9 items-center justify-center rounded-full border border-white/25 bg-white/10 shadow-lg backdrop-blur-md">
<Check className="size-4 text-white/90" />
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium text-white/95">Segment complete</p>
<p className="text-xs text-white/65">Describe the next scene below to keep going</p>
</div>
<ChevronDown className="size-4 animate-bounce text-white/45" />
</div>
)}
{/* Steering mode: generating the next segment — show a ~10.5s progress bar so the wait has an ETA.
Gated on playbackReachedEnd like "Segment complete": scrubbing back hides it, playing to the end re-shows it. */}
{sessionStarted && generatingNext && playbackReachedEnd && !mediaAppendError && !inQueue && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-end gap-3 bg-gradient-to-t from-slate-950/85 via-slate-950/15 to-transparent p-5 pb-7 text-center">
<p className="text-sm font-medium text-white/95">Generating next scene&hellip;</p>
<div className="h-1.5 w-48 overflow-hidden rounded-full bg-white/15 shadow-sm">
<div
className="h-full rounded-full bg-white/85 transition-[width] duration-100 ease-linear"
style={{ width: `${Math.round(genProgress * 100)}%` }}
/>
</div>
</div>
)}
{rewritePending && avPlaybackStarted && (
<div className="absolute inset-x-0 bottom-0 z-10 flex items-center justify-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-4 pb-12 pt-8 pointer-events-none">
<Loader2 className="size-4 animate-spin text-white/90" />
@@ -3,11 +3,90 @@ import React, { useRef, useMemo, useEffect, useCallback, useState } from "react"
import { motion, useAnimationControls } from "framer-motion";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { Check, Lightbulb, Pencil } from "lucide-react";
import { Check, Clapperboard, Lightbulb, Pencil } from "lucide-react";
export const WORKSPACE_ORIGINAL_SELECTION_KEY = "original";
export const WORKSPACE_CURRENT_SELECTION_KEY = "current";
export function SceneHistoryList({ sceneHistory = [] }: { sceneHistory?: Record<string, any>[] }) {
const bottomSentinelRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
const [showTopFade, setShowTopFade] = useState(false);
const scenes = useMemo(
() => (sceneHistory || []).filter((s) => normalizeText(s?.prompt)),
[sceneHistory],
);
const scrollToBottom = useCallback(() => {
setTimeout(() => {
bottomSentinelRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
}, 60);
}, []);
useEffect(() => {
if (scenes.length > 0) scrollToBottom();
}, [scenes.length, scrollToBottom]);
useEffect(() => {
const sentinel = bottomSentinelRef.current;
if (!sentinel || typeof ResizeObserver === "undefined") return;
let container: HTMLElement | null = sentinel.parentElement;
while (container) {
const oy = getComputedStyle(container).overflowY;
if (oy === "auto" || oy === "scroll") break;
container = container.parentElement;
}
if (!container) return;
const ro = new ResizeObserver(() => {
const nearBottom = container!.scrollHeight - container!.scrollTop - container!.clientHeight < 96;
if (nearBottom) scrollToBottom();
});
ro.observe(container);
return () => ro.disconnect();
}, [scenes.length, scrollToBottom]);
useEffect(() => {
const el = topSentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setShowTopFade(!entry.isIntersecting), { threshold: 0.1 });
observer.observe(el);
return () => observer.disconnect();
}, [scenes.length]);
if (scenes.length === 0) return null;
return (
<section className="relative z-10 flex flex-col mx-auto w-full max-w-2xl max-h-32 overflow-y-auto">
<div
className={cn(
"pointer-events-none sticky top-0 z-20 -mb-12 h-12 bg-linear-to-b from-background to-transparent transition-opacity duration-200",
showTopFade ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
<div ref={topSentinelRef} className="h-0 w-0" aria-hidden="true" />
<div className="flex flex-col gap-2 pt-12 pb-4">
{scenes.map((scene, index) => (
<div
key={scene.id || index}
className="flex items-start gap-3 rounded-xl p-3 transition-colors duration-200 hover:bg-slate-200/50 hover:dark:bg-slate-800/30"
>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Badge variant="secondary" className="horizontal gap-2 items-center w-fit">
<Clapperboard className="size-3 opacity-70" />
{`Scene ${index + 1}`}
</Badge>
<p className="line-clamp-2 text-sm leading-5 text-muted-foreground">{scene.prompt}</p>
</div>
</div>
))}
</div>
<div ref={bottomSentinelRef} className="h-0 w-0" aria-hidden="true" />
</section>
);
}
interface WorkspaceProps {
promptEvents?: Record<string, any>[];
currentThumbnail?: string | null;
@@ -19,6 +98,7 @@ interface WorkspaceProps {
selectedClipId?: string;
selectedEntryKey?: string;
originalClipId?: string;
manualMode?: boolean;
}
function normalizeText(value: any): string {
@@ -218,7 +298,7 @@ function ChromaGradient({ sessionStarted = false }: { sessionStarted?: boolean }
);
}
export default function Workspace({ promptEvents = [], currentThumbnail = null, originalLabel = "", sessionStarted = false, onSelectOriginal, onSelectEvent, onSelectCurrent, selectedClipId, selectedEntryKey: selectedEntryKeyProp, originalClipId = "" }: WorkspaceProps) {
export default function Workspace({ promptEvents = [], currentThumbnail = null, originalLabel = "", sessionStarted = false, onSelectOriginal, onSelectEvent, onSelectCurrent, selectedClipId, selectedEntryKey: selectedEntryKeyProp, originalClipId = "", manualMode = false }: WorkspaceProps) {
const bottomSentinelRef = useRef<HTMLDivElement>(null);
const topSentinelRef = useRef<HTMLDivElement>(null);
const [showTopFade, setShowTopFade] = useState(false);
@@ -259,6 +339,14 @@ export default function Workspace({ promptEvents = [], currentThumbnail = null,
return () => observer.disconnect();
}, [conversationEvents.length]);
if (manualMode) {
return (
<div className="mt-auto flex flex-col">
<ChromaGradient sessionStarted={sessionStarted} />
</div>
);
}
return (
<div className="mt-auto flex flex-col">
<ChromaGradient sessionStarted={sessionStarted} />
@@ -34,6 +34,7 @@ interface DevtoolsComposerProps {
demoMode?: boolean;
enhancementEnabled?: boolean;
autoExtensionEnabled?: boolean;
manualContinuationEnabled?: boolean;
loopGenerationEnabled?: boolean;
curatedPromptLimit?: number;
maxCuratedPromptCount?: number;
@@ -49,6 +50,7 @@ interface DevtoolsComposerProps {
onEnhancementToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onCuratedPromptLimitChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onAutoExtensionToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onManualContinuationToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onLoopToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onLivePromptModeToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSpeechTranscript?: (text: string) => void;
@@ -71,6 +73,7 @@ export default function DevtoolsComposer({
demoMode = false,
enhancementEnabled = true,
autoExtensionEnabled = false,
manualContinuationEnabled = false,
loopGenerationEnabled = false,
curatedPromptLimit = 0,
maxCuratedPromptCount = 0,
@@ -86,6 +89,7 @@ export default function DevtoolsComposer({
onEnhancementToggle = () => {},
onCuratedPromptLimitChange = () => {},
onAutoExtensionToggle = () => {},
onManualContinuationToggle = () => {},
onLoopToggle = () => {},
onLivePromptModeToggle = () => {},
onSpeechTranscript,
@@ -328,6 +332,28 @@ export default function DevtoolsComposer({
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="devtools-steering-mode"
checked={manualContinuationEnabled}
onCheckedChange={(checked) =>
onManualContinuationToggle({
target: { checked: Boolean(checked) },
currentTarget: { checked: Boolean(checked) },
} as React.ChangeEvent<HTMLInputElement>)
}
/>
<div className="space-y-1">
<Label htmlFor="devtools-steering-mode">
Steering mode
</Label>
<p className="text-sm text-muted-foreground">
Drive each segment manually — type the next scene to
continue (vs the automatic 6-segment rollout).
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="devtools-loop-generation"
@@ -21,6 +21,7 @@ interface DevtoolsShellProps {
selectedPresetId?: string;
enhancementEnabled?: boolean;
autoExtensionEnabled?: boolean;
manualContinuationEnabled?: boolean;
loopGenerationEnabled?: boolean;
canJoinSession?: boolean;
canSubmitContinuation?: boolean;
@@ -34,6 +35,7 @@ interface DevtoolsShellProps {
onEnhancementToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onCuratedPromptLimitChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onAutoExtensionToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onManualContinuationToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onLoopToggle?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onJoin?: () => void;
onLeave?: () => void;
@@ -128,6 +130,7 @@ export default function DevtoolsShell({
selectedPresetId = '',
enhancementEnabled = true,
autoExtensionEnabled = false,
manualContinuationEnabled = false,
loopGenerationEnabled = false,
canJoinSession = false,
canSubmitContinuation = false,
@@ -141,6 +144,7 @@ export default function DevtoolsShell({
onEnhancementToggle = () => {},
onCuratedPromptLimitChange = () => {},
onAutoExtensionToggle = () => {},
onManualContinuationToggle = () => {},
onLoopToggle = () => {},
onJoin = () => {},
onLeave = () => {},
@@ -284,6 +288,7 @@ export default function DevtoolsShell({
demoMode={demoMode}
enhancementEnabled={enhancementEnabled}
autoExtensionEnabled={autoExtensionEnabled}
manualContinuationEnabled={manualContinuationEnabled}
loopGenerationEnabled={loopGenerationEnabled}
curatedPromptLimit={curatedPromptLimit}
maxCuratedPromptCount={maxCuratedPromptCount}
@@ -299,6 +304,7 @@ export default function DevtoolsShell({
onEnhancementToggle={onEnhancementToggle}
onCuratedPromptLimitChange={onCuratedPromptLimitChange}
onAutoExtensionToggle={onAutoExtensionToggle}
onManualContinuationToggle={onManualContinuationToggle}
onLoopToggle={onLoopToggle}
onLivePromptModeToggle={onLivePromptModeToggle}
onSpeechTranscript={onSpeechTranscript}
@@ -57,4 +57,32 @@ describe('prependPromptEvent', () => {
expect(next[0].promptId).toBe('new');
expect(next.some((item: any) => item.promptId === 'p-23')).toBe(false);
});
it('never drops steering scene events when capping', () => {
// 30 scenes interleaved with 30 other events — well past the cap.
let events: Record<string, any>[] = [];
for (let i = 0; i < 30; i += 1) {
events = prependPromptEvent(events, {
promptId: `scene-${i}`,
status: 'submitted',
steeringUserPrompt: true,
rawText: `scene ${i}`,
});
events = prependPromptEvent(events, {
promptId: `other-${i}`,
status: 'submitted',
});
}
const scenes = events.filter((e) => e.steeringUserPrompt);
expect(scenes).toHaveLength(30);
// Oldest-first scene order (and therefore numbering) is stable and complete.
expect(scenes[scenes.length - 1].promptId).toBe('scene-0');
expect(scenes[0].promptId).toBe('scene-29');
// Non-scene events are still capped, oldest dropped first.
const others = events.filter((e) => !e.steeringUserPrompt);
expect(others.length).toBeLessThanOrEqual(24);
expect(others.some((e) => e.promptId === 'other-0')).toBe(false);
expect(others[0].promptId).toBe('other-29');
});
});
+15 -1
View File
@@ -16,5 +16,19 @@ export function prependPromptEvent(
events: Record<string, any>[],
event: Record<string, any>,
): Record<string, any>[] {
return [event, ...events].slice(0, MAX_PROMPT_EVENTS);
const next = [event, ...events];
if (next.length <= MAX_PROMPT_EVENTS) {
return next;
}
// Steering scene events (steeringUserPrompt) are exempt from the cap: the
// scene list is derived from them and must stay complete and stably numbered
// for long sessions. Only the oldest non-scene events are dropped.
let nonSceneKept = 0;
return next.filter((e) => {
if (e?.steeringUserPrompt) {
return true;
}
nonSceneKept += 1;
return nonSceneKept <= MAX_PROMPT_EVENTS;
});
}
+165 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { resolveSessionErrorMessage } from './reducer';
import { applyNormalizedSocketEvent, resolveSessionErrorMessage } from './reducer';
import { createSessionStore } from '../../stores/session';
import { createRewriteStore } from '../../stores/rewrite';
import { createStreamStore } from '../../stores/stream';
import { createUiStore } from '../../stores/ui';
import { createPromptWindowStore } from '../../stores/promptWindow';
describe('resolveSessionErrorMessage', () => {
it('returns a dedicated message for IP session limit errors', () => {
@@ -19,3 +24,162 @@ describe('resolveSessionErrorMessage', () => {
})).toBe('Backend replica unavailable. Rejoin session.');
});
});
function buildContext(overrides: Record<string, unknown> = {}) {
const sessionStore = createSessionStore();
const rewriteStore = createRewriteStore();
const streamStore = createStreamStore();
const uiStore = createUiStore();
const promptWindowStore = createPromptWindowStore();
const avPipeline = {
reset: () => {},
setStreamCompleted: () => {},
noteSegmentInit: () => {},
noteSegmentComplete: () => {},
maybeStartPlayback: () => {},
ensurePipeline: async () => {},
};
return {
sessionStore,
promptWindowStore,
rewriteStore,
streamStore,
uiStore,
avPipeline,
tick: async () => {},
defaultAvMime: 'video/mp4',
fixedRewriteModel: 'model',
parseLatencyMs: () => null,
formatPromptWindowEventText: () => '',
makePromptId: () => 'generated-id',
buildClipLabel: () => 'clip',
startSessionCountdown: () => {},
clearCountdownInterval: () => {},
resetTtffTimer: () => {},
startTtffTimer: () => {},
preserveArchivedPlaybackSelection: false,
finalizeStreamCompletion: async () => {},
...overrides,
};
}
describe('steering generatingNextScene flow', () => {
it('sets generatingNextScene on prompt/sources_resumed in manual mode', async () => {
const context = buildContext();
await applyNormalizedSocketEvent(
{ type: 'prompt/sources_resumed', payload: { segment_idx: 2 } },
context,
);
expect(context.sessionStore.get().generatingNextScene).toBe(true);
expect(context.sessionStore.get().waitingForSegmentPrompt).toBe(false);
});
it('does NOT set generatingNextScene on session/auto_extension_updated', async () => {
const context = buildContext();
await applyNormalizedSocketEvent(
{ type: 'session/auto_extension_updated', payload: { enabled: true } },
context,
);
expect(context.sessionStore.get().generatingNextScene).toBe(false);
expect(context.sessionStore.get().waitingForSegmentPrompt).toBe(false);
});
it('clears generatingNextScene when segment media arrives', async () => {
const context = buildContext();
context.sessionStore.patch({ generatingNextScene: true });
await applyNormalizedSocketEvent(
{
type: 'stream/media_init',
payload: { segment_idx: 2, stream_id: 's', mime: 'video/mp4' },
},
context,
);
expect(context.sessionStore.get().generatingNextScene).toBe(false);
});
it('clears generatingNextScene and returns to waiting on prompt/sources_blocked', async () => {
const context = buildContext();
context.sessionStore.patch({ generatingNextScene: true });
await applyNormalizedSocketEvent(
{ type: 'prompt/sources_blocked', payload: { segment_idx: 3 } },
context,
);
expect(context.sessionStore.get().generatingNextScene).toBe(false);
expect(context.sessionStore.get().waitingForSegmentPrompt).toBe(true);
});
it('clears generatingNextScene when the opening prompt falls back', async () => {
const context = buildContext();
context.sessionStore.patch({ generatingNextScene: true });
await applyNormalizedSocketEvent(
{
type: 'prompt/fallback_used',
payload: { prompt_id: 'p1', prompt: '', source: 'user_enhancement_failed' },
},
context,
);
expect(context.sessionStore.get().generatingNextScene).toBe(false);
expect(context.sessionStore.get().waitingForSegmentPrompt).toBe(true);
});
});
describe('opening prompt id tracking', () => {
it('routes prompt lifecycle updates to the frontend-recorded opening event', async () => {
// The frontend records the opening scene under its own prompt id and sends it
// as initial_rollout_prompt_id; the backend echoes it in status updates.
const context = buildContext();
context.rewriteStore.addPromptEvent({
promptId: 'opening-id',
status: 'rewrite_requested',
source: 'user_rewrite',
text: 'a castle at dawn',
steeringUserPrompt: true,
rawText: 'a castle at dawn',
});
await applyNormalizedSocketEvent(
{ type: 'prompt/enhancing', payload: { prompt_id: 'opening-id' } },
context,
);
let opening = (context.rewriteStore.get().promptEvents as Record<string, any>[])
.find((e) => e.promptId === 'opening-id');
expect(opening?.status).toBe('enhancing');
await applyNormalizedSocketEvent(
{
type: 'prompt/fallback_used',
payload: { prompt_id: 'opening-id', prompt: '', source: 'user_enhancement_failed' },
},
context,
);
opening = (context.rewriteStore.get().promptEvents as Record<string, any>[])
.find((e) => e.promptId === 'opening-id');
expect(opening?.status).toBe('ready_fallback');
// A failed opening is dropped from the steering scene list instead of
// lingering as a ghost "Scene 1".
expect(opening?.steeringFailed).toBe(true);
});
it('marks a prompt-scoped session/error (e.g. safety block) as steeringFailed', async () => {
const context = buildContext();
context.rewriteStore.addPromptEvent({
promptId: 'blocked-id',
status: 'queued',
source: 'user_raw',
text: 'a blocked prompt',
steeringUserPrompt: true,
rawText: 'a blocked prompt',
});
await applyNormalizedSocketEvent(
{
type: 'session/error',
payload: { message: 'Prompt blocked by safety filter.', prompt_id: 'blocked-id' },
},
context,
);
const blocked = (context.rewriteStore.get().promptEvents as Record<string, any>[])
.find((e) => e.promptId === 'blocked-id');
expect(blocked?.steeringFailed).toBe(true);
});
});
+50 -9
View File
@@ -99,7 +99,19 @@ export async function applyNormalizedSocketEvent(event: any, context: any): Prom
status: "ready_fallback",
source: payload.source || "user_raw",
text: payload.prompt,
// Steering: this prompt produced no segment — drop it from the scene list.
steeringFailed: true,
});
// Steering recovery: enhancement failed so the backend enqueued nothing AND won't
// re-emit prompt_sources_blocked (its drained flag is still set). Put the user back to
// "describe the next scene" ourselves so the generating overlay clears and they can retry.
if (!uiStore.get().simpleMode && sessionStore.get().manualContinuationMode) {
sessionStore.patch({
waitingForSegmentPrompt: true,
generatingNextScene: false,
sessionNotice: "Couldn't continue from that prompt — try rephrasing the next scene.",
});
}
console.warn("[PromptEnhanceFallback] Prompt extension failed for this request.");
return;
@@ -243,19 +255,32 @@ export async function applyNormalizedSocketEvent(event: any, context: any): Prom
}
case "prompt/sources_blocked":
sessionStore.patch({
autoExtensionTimeoutHint: uiStore.get().simpleMode ? "" : "blocked on user input, increase prompt count for smoother experience",
});
if (sessionStore.get().manualContinuationMode) {
sessionStore.patch({ waitingForSegmentPrompt: true, generatingNextScene: false, autoExtensionTimeoutHint: "" });
} else {
sessionStore.patch({
autoExtensionTimeoutHint: uiStore.get().simpleMode ? "" : "blocked on user input, increase prompt count for smoother experience",
});
}
return;
case "prompt/sources_resumed":
sessionStore.patch({
autoExtensionTimeoutHint: "",
waitingForSegmentPrompt: false,
// A real prompt was just selected for the next segment; media arriving
// (stream/media_init) clears this again.
...(sessionStore.get().manualContinuationMode ? { generatingNextScene: true } : {}),
});
return;
case "session/auto_extension_updated":
sessionStore.patch({ autoExtensionTimeoutHint: "" });
if (event.type === "session/auto_extension_updated") {
console.log("[AutoExtensionUpdated]", {
enabled: sessionStore.get().autoExtensionEnabled,
});
}
// Deliberately does NOT touch generatingNextScene: toggling auto extension
// starts no generation.
sessionStore.patch({ autoExtensionTimeoutHint: "", waitingForSegmentPrompt: false });
console.log("[AutoExtensionUpdated]", {
enabled: sessionStore.get().autoExtensionEnabled,
});
return;
case "segment/step_complete":
@@ -277,6 +302,7 @@ export async function applyNormalizedSocketEvent(event: any, context: any): Prom
projectResetPending: false,
sessionExpired: true,
sessionNotice: "",
generatingNextScene: false,
});
console.log("Session timed out");
clearCountdownInterval();
@@ -354,6 +380,8 @@ export async function applyNormalizedSocketEvent(event: any, context: any): Prom
return;
case "stream/media_init":
// Segment media is arriving — the "Generating next scene" phase is over.
sessionStore.patch({ generatingNextScene: false });
streamStore.patch({
mediaAppendError: null,
loadingAnimation: streamStore.get().avPlaybackStarted ? streamStore.get().loadingAnimation : true,
@@ -471,17 +499,30 @@ export async function applyNormalizedSocketEvent(event: any, context: any): Prom
sessionStore.patch({
generationCapReached: false,
sessionNotice: "",
generatingNextScene: false,
});
await finalizeStreamCompletion();
return;
case "session/error": {
const errorMessage = resolveSessionErrorMessage(payload);
if (payload?.prompt_id) {
// Prompt-scoped error (e.g. safety-blocked): the prompt produced no
// segment, so drop it from the steering scene list.
rewriteStore.trackPromptEvent(payload.prompt_id, {
steeringFailed: true,
});
}
sessionStore.patch({
generationCapReached: false,
preservePlaybackOnClose: false,
promptExtensionError: "",
sessionNotice: errorMessage,
// Steering: a blocked/failed prompt produced no segment and the backend won't re-emit
// prompt_sources_blocked, so recover the "describe the next scene" state ourselves.
...(!uiStore.get().simpleMode && sessionStore.get().manualContinuationMode
? { waitingForSegmentPrompt: true, generatingNextScene: false }
: {}),
});
rewriteStore.patch({
rewritingSeedPrompts: false,
@@ -24,6 +24,9 @@ export interface SessionState {
livePromptRewriteMode: boolean;
sessionExpired: boolean;
projectResetPending: boolean;
manualContinuationMode: boolean;
waitingForSegmentPrompt: boolean;
generatingNextScene: boolean;
}
const DEFAULT_SESSION_STATE: SessionState = {
@@ -49,6 +52,12 @@ const DEFAULT_SESSION_STATE: SessionState = {
livePromptRewriteMode: false,
sessionExpired: false,
projectResetPending: false,
// Steering-only product: every session drives scenes manually. There is no
// auto-rollout mode and no UI selector, so this stays true throughout.
manualContinuationMode: true,
waitingForSegmentPrompt: false,
// True from scene submit / prompt selection until the segment's media arrives.
generatingNextScene: false,
};
export type SessionStore = ManagedStore<SessionState> & {
+18 -1
View File
@@ -1,5 +1,18 @@
import { createManagedStore, type ManagedStore } from "./createManagedStore";
// Prompt-history sources driven by the user's own submissions. Curated (non-user)
// entries — e.g. a preset's opening scene — feed the steering scene list and are
// exempt from the history cap so Scene 1 survives long sessions.
export const USER_PROMPT_SOURCES = new Set([
"user_raw",
"user",
"user_enhanced",
"user_rewrite",
"user_enhancement_failed",
]);
const PROMPT_HISTORY_CAP = 120;
export interface StreamState {
playingSeedPromptIndex: number | null;
generatingSeedPromptIndex: number | null;
@@ -159,10 +172,14 @@ export function createStreamStore(initialState: Partial<StreamState> = {}): Stre
loopIteration: typeof loopIteration === "number" ? loopIteration : null,
};
const nextHistory = [entry, ...state.promptHistory];
return {
...state,
promptHistoryCounter: nextCounter,
promptHistory: [entry, ...state.promptHistory].slice(0, 120),
promptHistory: nextHistory.length > PROMPT_HISTORY_CAP
? nextHistory.filter((item, index) =>
index < PROMPT_HISTORY_CAP || !USER_PROMPT_SOURCES.has(String(item.source || "")))
: nextHistory,
selectedHistoryId: state.selectedHistoryId || (entry.id as string),
};
});
@@ -191,10 +191,13 @@ def run_server(serve_config: ServeConfig, *, generator: _GeneratorProto | None =
generator = VideoGenerator.from_pretrained(config=serve_config.generator)
app = build_app(serve_config, generator)
# A 15MB init image is ~20MB as a base64 ws message, above uvicorn's
# default 16MiB frame cap.
uvicorn.run(
app,
host=serve_config.server.host,
port=serve_config.server.port,
ws_max_size=32 * 1024 * 1024,
)