Add dialogue/shot editor

This commit is contained in:
kevin314
2026-09-25 11:05:41 +00:00
parent a943220c11
commit 01050277ea
45 changed files with 7324 additions and 39 deletions
+231 -25
View File
@@ -4,8 +4,8 @@ In-memory mock of the FastVideo Studio API (``server.py``).
This mock implements the same ``/api`` routes and response shapes as the real
FastAPI server but keeps everything in memory and never touches the FastVideo
library, a GPU, or a database. It is meant to back the Playwright e2e suite so
the Next.js frontend can be exercised end-to-end without a real backend.
library or a GPU. It is meant to back the Playwright e2e suite so the Next.js
frontend can be exercised end-to-end without a real backend.
Job lifecycle is simulated by recording a start timestamp and *computing* the
status on read: a started job reports ``running`` for a few seconds and then
@@ -13,14 +13,24 @@ flips to ``completed`` with an ``output_path``, so polling the job list / logs
shows progression. Generated media is a tiny 1s ``testsrc`` MP4 built lazily
with ffmpeg and cached.
Pass ``--data-dir`` to seed jobs/datasets/settings from a real server's
persisted SQLite database instead of the built-in fake demo data, so you can
browse real job history through the mock without a GPU. This is a read-only
snapshot taken once at startup: nothing created or changed while the mock
runs is ever written back to that database, so it's safe to point at the
same ``--data-dir`` a real ``server.py`` is using.
Usage (from the ``apps/`` directory)::
PYTHONPATH=.. python -m fastvideo_studio.mock_server --port 8190
PYTHONPATH=.. python -m fastvideo_studio.mock_server --port 8190 --data-dir ../outputs/ui_data
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import random
import shutil
@@ -29,22 +39,31 @@ import tempfile
import threading
import time
import uuid
from pathlib import Path
from typing import Annotated, Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse
from fastvideo_studio.database import default_settings_dict
from fastvideo_studio.database import Database, default_settings_dict
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, SettingsUpdate, UpdateCaptionRequest,
model_label)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("fastvideo.studio.mock")
# --- Config -----------------------------------------------------------------
# How long a started job stays "running" before it flips to "completed".
COMPLETE_AFTER_SECONDS = 3.0
FFMPEG_BIN = shutil.which(os.getenv("FASTVIDEO_FFMPEG_BIN", "ffmpeg"))
# Marks a job dict as a frozen row loaded from a real --data-dir snapshot
# (see _load_snapshot_from_db) rather than something this process is
# simulating. Stripped from API responses by _public_job.
_FROM_SNAPSHOT_KEY = "_from_db_snapshot"
# A small catalogue of fake models keyed by workload type. Mirrors the real
# server's {id, label} shape (label derived from the HF-style path).
_MODELS_BY_WORKLOAD: dict[str, list[str]] = {
@@ -54,6 +73,10 @@ _MODELS_BY_WORKLOAD: dict[str, list[str]] = {
],
"i2v": [
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
# Real registered id (fastvideo/registry.py) -- needed so the frontend's
# `modelId.toLowerCase().includes('minimax-h3')` gate (speech-tag
# console, references, last-frame upload) actually lights up in the mock.
"MiniMaxAI/MiniMax-H3",
],
"t2i": [
"black-forest-labs/FLUX.1-schnell",
@@ -98,16 +121,76 @@ _mock_media_cache: dict[str, str] = {}
_mock_media_lock = threading.Lock()
def _testsrc_frame(width: int, height: int, index: int):
"""A cheap, dependency-light stand-in for ffmpeg's `testsrc` pattern:
a gradient that shifts over `index` so an encoded clip visibly animates."""
import numpy as np
frame = np.zeros((height, width, 3), dtype=np.uint8)
frame[:, :, 0] = np.linspace(0, 255, width, dtype=np.uint8)[None, :]
frame[:, :, 1] = np.linspace(0, 255, height, dtype=np.uint8)[:, None]
frame[:, :, 2] = (index * 10) % 256
return frame
def _build_mock_media_with_pyav(kind: str, path: str) -> None:
"""ffmpeg isn't installed on every dev box; PyAV (a real FastVideo
dependency already, used for reference-video decoding) can encode the
same tiny clip directly via its bundled libav, no external binary."""
from PIL import Image
if kind == "png":
Image.fromarray(_testsrc_frame(320, 240, 0)).save(path)
return
import av as pyav
# h264 is what every browser's <video> element actually plays; mpeg4
# (MPEG-4 Part 2) is a PyAV-always-has-it fallback that no mainstream
# browser decodes, so gallery/job previews would just fail to load.
try:
pyav.codec.Codec("h264", "w")
codec_name = "h264"
except Exception:
logger.warning("PyAV has no h264 encoder available; falling back to mpeg4, which most "
"browsers cannot play in <video>. Install ffmpeg for real preview playback.")
codec_name = "mpeg4"
container = pyav.open(path, mode="w")
try:
stream = container.add_stream(codec_name, rate=24)
stream.width = 320
stream.height = 240
stream.pix_fmt = "yuv420p"
for i in range(24): # 1s at 24fps, matching the ffmpeg path's clip length
frame = pyav.VideoFrame.from_ndarray(_testsrc_frame(320, 240, i), format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
finally:
container.close()
def _build_mock_media(kind: str) -> str:
"""Build (once) a tiny testsrc clip of the given ``kind`` with ffmpeg; cache the path."""
"""Build (once) a tiny testsrc-like clip of the given ``kind``; cache the path."""
with _mock_media_lock:
cached = _mock_media_cache.get(kind)
if cached and os.path.isfile(cached):
return cached
if not FFMPEG_BIN:
raise HTTPException(status_code=500, detail="ffmpeg is required to build mock media")
fd, path = tempfile.mkstemp(prefix="fvstudio_mock_", suffix=f".{kind}")
os.close(fd)
if not FFMPEG_BIN:
try:
_build_mock_media_with_pyav(kind, path)
except Exception as exc:
with contextlib.suppress(OSError):
os.remove(path)
raise HTTPException(status_code=500, detail=f"PyAV fallback failed to build mock {kind}: {exc}") from exc
if not os.path.isfile(path) or os.path.getsize(path) == 0:
raise HTTPException(status_code=500, detail=f"PyAV fallback produced no mock {kind} bytes")
_mock_media_cache[kind] = path
return path
if kind == "png":
command = [
FFMPEG_BIN,
@@ -166,15 +249,23 @@ def _build_mock_media(kind: str) -> str:
def _new_job_dict(req: CreateJobRequest) -> dict[str, Any]:
"""Build a job dict (mirrors job_runner.Job.to_dict()) in the pending state."""
"""Build a job dict (mirrors job_runner.Job.to_dict()) in the pending state.
Starts from req.model_dump() rather than re-listing CreateJobRequest's
fields by hand -- the previous version silently dropped name,
last_image_path, references, and every offload/tuning flag because it
was never updated as CreateJobRequest grew those fields. Spreading the
request means new fields flow through automatically.
"""
job_id = str(uuid.uuid4())
return {
**req.model_dump(),
"id": job_id,
"model_id": req.model_id,
"prompt": req.prompt,
"workload_type": req.workload_type or "t2v",
"job_type": req.job_type or "inference",
"image_path": req.image_path or "",
"negative_prompt": req.negative_prompt or "",
"data_path": req.data_path or "",
"status": "pending",
"created_at": time.time(),
"started_at": None,
@@ -182,17 +273,6 @@ def _new_job_dict(req: CreateJobRequest) -> dict[str, Any]:
"error": None,
"output_path": None,
"log_file_path": None,
"num_inference_steps": req.num_inference_steps,
"num_frames": req.num_frames,
"height": req.height,
"width": req.width,
"guidance_scale": req.guidance_scale,
"guidance_rescale": req.guidance_rescale,
"fps": req.fps,
"seed": req.seed,
"negative_prompt": req.negative_prompt or "",
"num_gpus": req.num_gpus,
"data_path": req.data_path or "",
"progress": 0.0,
"progress_msg": "",
"phase": "pending",
@@ -204,8 +284,13 @@ def _advance_job(job: dict[str, Any]) -> None:
Status is *computed on read* from the recorded start timestamp, so polling
the job list / logs naturally shows pending -> running -> completed.
Jobs loaded from a persisted DB snapshot (``_FROM_SNAPSHOT_KEY``) are a
frozen historical record, not something this process is simulating — skip
them so a real "pending" or "running" row doesn't silently start ticking
toward a fake "completed" just because someone polled it.
"""
if job["status"] != "running" or not job.get("started_at"):
if job.get(_FROM_SNAPSHOT_KEY) or job["status"] != "running" or not job.get("started_at"):
return
elapsed = time.time() - job["started_at"]
if elapsed >= COMPLETE_AFTER_SECONDS:
@@ -219,9 +304,9 @@ def _advance_job(job: dict[str, Any]) -> None:
def _public_job(job: dict[str, Any]) -> dict[str, Any]:
"""Advance + return a copy safe to serialize."""
"""Advance + return a copy safe to serialize (internal-only keys stripped)."""
_advance_job(job)
return dict(job)
return {k: v for k, v in job.items() if k != _FROM_SNAPSHOT_KEY}
_LOG_TAIL = [
@@ -346,6 +431,84 @@ def _seed() -> None:
}
# --- DB snapshot (--data-dir) ------------------------------------------------
def _decode_references(value: Any) -> list[dict[str, Any]]:
"""Reference lists round-trip through the DB as JSON text."""
if not value:
return []
if isinstance(value, list):
return list(value)
try:
decoded = json.loads(value)
except (TypeError, ValueError):
return []
return list(decoded) if isinstance(decoded, list) else []
def _job_from_snapshot(row: dict[str, Any]) -> dict[str, Any]:
"""Adapt a persisted job row into the mock's job dict shape.
Frozen as a read-only historical record (see _FROM_SNAPSHOT_KEY): the mock
never actually ran this job, so it doesn't get to simulate progress for
one. A row still marked "running" is orphaned -- the real JobRunner
reclassifies those as failed on its own restart, so mirror that here.
"""
job = dict(row)
status = job["status"]
if status == "running":
status = "failed"
job["error"] = job.get("error") or "Server restarted (job was running)"
job["finished_at"] = job.get("finished_at") or time.time()
job["status"] = status
job["references"] = _decode_references(row.get("references"))
job["progress"] = 100.0 if status == "completed" else 0.0
job["progress_msg"] = "50/50 steps" if status == "completed" else ""
job["phase"] = "done" if status == "completed" else status
job[_FROM_SNAPSHOT_KEY] = True
return job
def _load_snapshot_from_db(data_dir: Path) -> bool:
"""Seed jobs/datasets/settings from a real server's persisted SQLite db.
Read-only: opens the db (Database.__init__ runs the same idempotent
schema migrations a real server startup would) and copies rows into the
mock's in-memory state; nothing the mock does afterward is written back.
Returns False (leaving the caller to fall back to _seed()) if no db file
is found at that path, rather than silently creating an empty one.
"""
db_path = data_dir / "fastvideo_ui.db"
if not db_path.is_file():
logger.warning("--data-dir %s has no fastvideo_ui.db; falling back to fake demo data.", data_dir)
return False
db = Database(db_path)
settings = db.get_settings()
jobs = [_job_from_snapshot(row) for row in db.get_all_jobs()]
datasets = db.get_all_datasets()
with _state_lock:
_settings.update({k: v for k, v in settings.items() if v is not None})
_jobs.clear()
_jobs.update({j["id"]: j for j in jobs})
_datasets.clear()
_datasets.update({d["id"]: d for d in datasets})
_dataset_files.clear()
for d in datasets:
captions = db.get_dataset_captions(d["id"])
_dataset_files[d["id"]] = {"file_names": list(captions.keys()), "captions": captions}
logger.info(
"Loaded read-only snapshot from %s: %d job(s), %d dataset(s).",
db_path,
len(jobs),
len(datasets),
)
return True
# --- App --------------------------------------------------------------------
app = FastAPI(title="FastVideo Studio Mock API", version="0.1.0")
@@ -357,8 +520,6 @@ app.add_middleware(
allow_headers=["*"],
)
_seed()
@app.get("/api/__mock__")
def mock_sentinel() -> dict[str, bool]:
@@ -423,6 +584,32 @@ async def upload_image(file: Annotated[UploadFile, File()]) -> dict[str, str]:
return {"path": f"/mock/uploads/{uuid.uuid4().hex}_{os.path.basename(name)}"}
ALLOWED_REFERENCE_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
ALLOWED_REFERENCE_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".m4a", ".ogg"}
ALLOWED_REFERENCE_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
@app.post("/api/upload-media")
async def upload_media(file: Annotated[UploadFile, File()]) -> dict[str, str]:
"""Ref2VA reference upload (image/video/audio) -- the real server's
counterpart to upload_image, which only ever handles images."""
name = file.filename or "reference"
ext = os.path.splitext(name)[1].lower()
allowed = ALLOWED_REFERENCE_IMAGE_EXTENSIONS | ALLOWED_REFERENCE_VIDEO_EXTENSIONS | ALLOWED_REFERENCE_AUDIO_EXTENSIONS
if ext not in allowed:
raise HTTPException(status_code=400, detail=f"Invalid file type. Allowed: {', '.join(sorted(allowed))}")
if ext in ALLOWED_REFERENCE_VIDEO_EXTENSIONS:
media_type = "video"
elif ext in ALLOWED_REFERENCE_AUDIO_EXTENSIONS:
media_type = "audio"
else:
media_type = "image"
return {
"path": f"/mock/uploads/{uuid.uuid4().hex}_{os.path.basename(name)}",
"media_type": media_type,
}
@app.post("/api/upload-raw-dataset")
async def upload_raw_dataset(files: Annotated[list[UploadFile], File()]) -> dict[str, Any]:
video_exts = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
@@ -473,6 +660,9 @@ def create_job(req: CreateJobRequest) -> dict[str, Any]:
def _start(job: dict[str, Any]) -> None:
# Once the mock is actually driving this job, it's no longer a frozen
# historical record -- let _advance_job simulate it like any other.
job.pop(_FROM_SNAPSHOT_KEY, None)
job["status"] = "running"
job["started_at"] = time.time()
job["finished_at"] = None
@@ -552,6 +742,12 @@ def get_video(job_id: str) -> FileResponse:
output_path = job.get("output_path")
if job["status"] != "completed" or not output_path:
raise HTTPException(status_code=404, detail="No output available for this job")
# A job loaded from a --data-dir snapshot may point at a real file this
# process never wrote, only reads -- serve it if it's actually there,
# otherwise fall back to the same synthetic clip every other job gets.
if os.path.isfile(output_path):
media_type = "video/mp4" if output_path.endswith(".mp4") else "image/png"
return FileResponse(output_path, media_type=media_type, filename=os.path.basename(output_path))
# Mirror the real server: image workloads (t2i) output a .png served as an
# image; everything else is a video.
if output_path.endswith(".png"):
@@ -651,8 +847,18 @@ def main() -> None:
parser.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1)")
# Default off the real server's 8189 so the mock never shadows a real API.
parser.add_argument("--port", type=int, default=8190, help="Port number (default: 8190)")
parser.add_argument(
"--data-dir",
default=None,
help=("Read-only: seed jobs/datasets/settings from a real server's "
"fastvideo_ui.db under this directory (its --data-dir) instead "
"of fake demo data. Nothing the mock does is written back."),
)
args = parser.parse_args()
if args.data_dir is None or not _load_snapshot_from_db(Path(args.data_dir).resolve()):
_seed()
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
@@ -0,0 +1,282 @@
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ScenesPage from './page';
import { getJobsList } from '@/lib/api';
import { makeJob } from '@/test/factories';
vi.mock('@/lib/api', () => ({
getJobsList: vi.fn(),
getJobVideoUrl: (id: string) => `http://test.local/api/jobs/${id}/video`,
}));
vi.mock('@/components/jobs/CreateJobModal', () => ({
default: (props: { readOnly?: boolean; editingJob?: { name?: string } | null; onClose: () => void }) => (
<div role="dialog" data-readonly={String(!!props.readOnly)}>
{props.editingJob?.name}
<button onClick={props.onClose}>close</button>
</div>
),
}));
const wolf = (n: number, over: Parameters<typeof makeJob>[0] = {}) =>
makeJob({
id: `wolf-${n}`,
name: `wolf-lunch-clip-${String(n).padStart(2, '0')}`,
created_at: n,
num_frames: 240,
fps: 24,
prompt: [
'detailed_description:',
`[Shot 1] Wide shot. Clip ${n} opens.`,
`[Shot 2] At 00:02.000, the shot cuts to a close-up shot. (S1) <d>[English] Line from clip ${n}.</d>`,
].join('\n'),
...over,
});
beforeEach(() => {
vi.mocked(getJobsList).mockReset();
});
describe('ScenesPage', () => {
it('groups clips into a scene and lists every shot in order across them', async () => {
vi.mocked(getJobsList).mockResolvedValue([wolf(2), wolf(3), wolf(1)]);
render(<ScenesPage />);
expect(await screen.findByRole('heading', { name: 'wolf-lunch' })).toBeInTheDocument();
expect(getJobsList).toHaveBeenCalledWith('inference');
expect(screen.getByText(/3 clips · 6 shots · about 0:30/)).toBeInTheDocument();
const rows = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent);
expect(rows).toEqual(['wolf-lunch-clip-01', 'wolf-lunch-clip-02', 'wolf-lunch-clip-03']);
for (const n of [1, 2, 3]) {
expect(screen.getByText(new RegExp(`S1: “Line from clip ${n}\\.”`))).toBeInTheDocument();
}
});
it('numbers shots straight through the scene, not per clip', async () => {
vi.mocked(getJobsList).mockResolvedValue([wolf(1), wolf(2)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const second = screen.getByLabelText('Shots in wolf-lunch-clip-02');
const badges = [...second.querySelectorAll('span.rounded-full.bg-secondary')].map((b) => b.textContent);
expect(badges.slice(0, 2)).toEqual(['3', '4']);
});
it('puts scene-wide times on each shot', async () => {
vi.mocked(getJobsList).mockResolvedValue([wolf(1), wolf(2)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const second = screen.getByLabelText('Shots in wolf-lunch-clip-02');
// clip 2 starts at 10 s (clip 1 is 240 frames at 24 fps), and its second shot cuts in 2 s later
expect(within(second).getByText('00:10.000')).toBeInTheDocument();
expect(within(second).getByText('00:12.000')).toBeInTheDocument();
});
it('switches between scenes', async () => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockResolvedValue([
wolf(1),
makeJob({ id: 'r1', name: 'rooftop-1', created_at: 99, prompt: '[Shot 1] Wide shot. A roof.' }),
]);
render(<ScenesPage />);
expect(await screen.findByRole('heading', { name: 'rooftop' })).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText('Scene'), 'wolf-lunch');
expect(await screen.findByRole('heading', { name: 'wolf-lunch' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'rooftop' })).not.toBeInTheDocument();
});
it('hides the scene picker when there is only one scene', async () => {
vi.mocked(getJobsList).mockResolvedValue([wolf(1), wolf(2)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
expect(screen.queryByLabelText('Scene')).not.toBeInTheDocument();
});
it('shows status counts and marks a continuation clip', async () => {
vi.mocked(getJobsList).mockResolvedValue([
wolf(1, { status: 'completed' }),
wolf(2, { status: 'pending', prompt: '[Shot 1] Close-up shot. Continuing the same shot on <Subject 1>, still speaking.' }),
]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
expect(screen.getAllByText('1 completed').length).toBeGreaterThan(0);
expect(screen.getAllByText('1 pending').length).toBeGreaterThan(0);
expect(screen.getByText('continues previous shot')).toBeInTheDocument();
});
it('previews a completed clip on demand', async () => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockResolvedValue([
wolf(1, { status: 'completed', output_path: '/out/a.mp4' }),
wolf(2, { status: 'pending' }),
]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
expect(screen.getAllByRole('button', { name: 'Preview' })).toHaveLength(1);
expect(screen.queryByLabelText(/Generated video/)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Preview' }));
expect(screen.getByLabelText(/Generated video for wolf-lunch-clip-01/)).toHaveAttribute(
'src',
'http://test.local/api/jobs/wolf-1/video',
);
await user.click(screen.getByRole('button', { name: 'Hide video' }));
expect(screen.queryByLabelText(/Generated video/)).not.toBeInTheDocument();
});
it('opens a pending clip for editing and a finished one read-only', async () => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockResolvedValue([wolf(1, { status: 'completed' }), wolf(2, { status: 'pending' })]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
await user.click(screen.getByRole('button', { name: 'Edit' }));
let dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('data-readonly', 'false');
expect(dialog).toHaveTextContent('wolf-lunch-clip-02');
await user.click(within(dialog).getByRole('button', { name: 'close' }));
await user.click(screen.getByRole('button', { name: 'View' }));
dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('data-readonly', 'true');
expect(dialog).toHaveTextContent('wolf-lunch-clip-01');
});
it('timeline segments jump to their clip', async () => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockResolvedValue([wolf(1), wolf(2)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
await user.click(screen.getByRole('button', { name: /Go to clip 2: wolf-lunch-clip-02/ }));
expect(scroll).toHaveBeenCalled();
expect((scroll.mock.instances.at(-1) as unknown as Element).id).toBe('clip-wolf-2');
});
it('shows an empty state when there are no jobs', async () => {
vi.mocked(getJobsList).mockResolvedValue([]);
render(<ScenesPage />);
expect(await screen.findByText(/No inference jobs yet/)).toBeInTheDocument();
});
it('shows an error with a retry that reloads', async () => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockRejectedValueOnce(new Error('API down')).mockResolvedValue([wolf(1)]);
render(<ScenesPage />);
expect(await screen.findByRole('alert')).toHaveTextContent('API down');
await user.click(screen.getByRole('button', { name: 'Try Again' }));
expect(await screen.findByRole('heading', { name: 'wolf-lunch' })).toBeInTheDocument();
await waitFor(() => expect(getJobsList).toHaveBeenCalledTimes(2));
});
it('says so when a clip has no shots', async () => {
vi.mocked(getJobsList).mockResolvedValue([makeJob({ id: 'e', name: 'empty-1', prompt: '' })]);
render(<ScenesPage />);
expect(await screen.findByText(/prompt has no shots/)).toBeInTheDocument();
});
describe('re-runs of the same clip', () => {
it('shows one clip with a take picker instead of a row per re-run', async () => {
vi.mocked(getJobsList).mockResolvedValue([
wolf(1, { id: 't1', created_at: 1, status: 'completed' }),
wolf(1, { id: 't2', created_at: 2, status: 'failed' }),
wolf(1, { id: 't3', created_at: 3, status: 'completed' }),
wolf(2, { id: 'n', created_at: 4, status: 'pending' }),
]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(2);
expect(screen.getByText(/2 clips · 4 shots/)).toBeInTheDocument();
const picker = screen.getByLabelText('Take for clip 1') as HTMLSelectElement;
expect(picker.options).toHaveLength(3);
// newest completed take is shown by default (not the failed one in the middle)
expect(picker.value).toBe('t3');
expect(screen.queryByLabelText('Take for clip 2')).not.toBeInTheDocument();
});
it('switching takes changes the clip shown and its status', async () => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockResolvedValue([
wolf(1, { id: 't1', created_at: 1, status: 'completed', prompt: '[Shot 1] Wide shot. First take.' }),
wolf(1, { id: 't2', created_at: 2, status: 'failed', prompt: '[Shot 1] Wide shot. Second take.' }),
]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
expect(screen.getByText('First take.')).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText('Take for clip 1'), 't2');
expect(await screen.findByText('Second take.')).toBeInTheDocument();
expect(screen.queryByText('First take.')).not.toBeInTheDocument();
expect(screen.getAllByText('1 failed').length).toBeGreaterThan(0);
});
});
describe('sticky scene header', () => {
type Callback = (entries: { isIntersecting: boolean }[]) => void;
let callback: Callback | null = null;
const disconnect = vi.fn();
beforeEach(() => {
callback = null;
disconnect.mockClear();
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(cb: Callback) {
callback = cb;
}
observe() {}
disconnect = disconnect;
},
);
});
afterEach(() => vi.unstubAllGlobals());
const header = () => screen.getByRole('heading', { name: 'wolf-lunch' }).closest('[data-stuck]') as HTMLElement;
it('is sticky to the top of the scrolling area', async () => {
vi.mocked(getJobsList).mockResolvedValue([wolf(1), wolf(2)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
expect(header()).toHaveClass('sticky', 'top-0');
// the timeline bar lives in the sticky block, so it follows the scroll
expect(within(header()).getByRole('group', { name: 'Scene timeline' })).toBeInTheDocument();
});
it('adds a shadow only once it has stuck', async () => {
vi.mocked(getJobsList).mockResolvedValue([wolf(1), wolf(2)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
expect(header()).toHaveAttribute('data-stuck', 'false');
expect(header()).not.toHaveClass('shadow-md');
// the sentinel above the header has scrolled out of view
act(() => callback?.([{ isIntersecting: false }]));
expect(header()).toHaveAttribute('data-stuck', 'true');
expect(header()).toHaveClass('shadow-md');
// ...and back into view at the top of the page
act(() => callback?.([{ isIntersecting: true }]));
expect(header()).toHaveAttribute('data-stuck', 'false');
expect(header()).not.toHaveClass('shadow-md');
});
it('stops observing when the scene board goes away', async () => {
vi.mocked(getJobsList).mockResolvedValue([wolf(1)]);
const { unmount } = render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
unmount();
expect(disconnect).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,132 @@
'use client';
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react';
import * as React from 'react';
import CreateJobModal from '@/components/jobs/CreateJobModal';
import { EDITABLE_STATUSES, SceneBoard } from '@/components/scenes/SceneBoard';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { NativeSelect } from '@/components/ui/native-select';
import { getJobsList } from '@/lib/api';
import { buildScenes } from '@/lib/scenes';
import type { Job } from '@/lib/types';
export default function ScenesPage() {
const [jobs, setJobs] = React.useState<Job[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [reloadKey, setReloadKey] = React.useState(0);
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const [openJob, setOpenJob] = React.useState<Job | null>(null);
// which re-run of a clip number the user picked, by clip
const [choices, setChoices] = React.useState<Record<string, string>>({});
React.useEffect(() => {
let cancelled = false;
async function load() {
try {
const list = await getJobsList('inference');
if (cancelled) return;
setJobs(list);
setError(null);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load jobs');
} finally {
if (!cancelled) setIsLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, [reloadKey]);
function reload() {
setIsLoading(true);
setReloadKey((k) => k + 1);
}
const scenes = React.useMemo(() => buildScenes(jobs, choices), [jobs, choices]);
const scene = scenes.find((s) => s.key === selectedKey) ?? scenes[0];
return (
<div className="mx-auto w-full max-w-[1200px] px-4 pb-12 pt-4">
<Card className="p-6">
<div className="mb-1 flex flex-wrap items-center gap-3">
<h2 className="text-2xl font-semibold text-foreground">Scenes</h2>
<Button
type="button"
variant="outline"
size="sm"
className="ml-auto gap-1.5"
onClick={reload}
disabled={isLoading}
>
<RefreshCw className={isLoading ? 'size-3.5 animate-spin' : 'size-3.5'} aria-hidden />
Refresh
</Button>
</div>
<p className="mb-6 text-sm text-muted-foreground">
Every shot across your jobs, in order. Jobs named with a shared stem and a number, like{' '}
<code className="font-mono text-xs">wolf-lunch-clip-01</code>,{' '}
<code className="font-mono text-xs">wolf-lunch-clip-02</code>, are grouped into one scene.
</p>
{isLoading && jobs.length === 0 ? (
<div className="flex items-center gap-3 p-8 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span>Loading scenes…</span>
</div>
) : error && jobs.length === 0 ? (
<div role="alert" className="flex flex-col items-center gap-3 py-8 text-center">
<AlertTriangle className="size-6 text-destructive" aria-hidden />
<p className="max-w-md text-sm text-muted-foreground">{error}</p>
<Button type="button" variant="outline" onClick={reload}>
Try Again
</Button>
</div>
) : !scene ? (
<p className="py-8 text-center text-muted-foreground">
No inference jobs yet. Create some and they&apos;ll show up here.
</p>
) : (
<div className="flex flex-col gap-6">
{scenes.length > 1 && (
<label className="flex max-w-md flex-col gap-1 text-xs text-muted-foreground">
Scene
<NativeSelect value={scene.key} onChange={(e) => setSelectedKey(e.target.value)}>
{scenes.map((s) => (
<option key={s.key} value={s.key}>
{s.title} ({s.clips.length} clip{s.clips.length === 1 ? '' : 's'})
</option>
))}
</NativeSelect>
</label>
)}
<SceneBoard
scene={scene}
onOpenJob={setOpenJob}
onChooseTake={(takeKey, jobId) => setChoices((c) => ({ ...c, [takeKey]: jobId }))}
/>
</div>
)}
</Card>
{openJob && (
<CreateJobModal
isOpen
readOnly={!EDITABLE_STATUSES.includes(openJob.status)}
editingJob={openJob}
jobType={(openJob.job_type ?? 'inference') as never}
workloadType={openJob.workload_type ?? 't2v'}
onClose={() => setOpenJob(null)}
onSuccess={() => {
setOpenJob(null);
reload();
}}
/>
)}
</div>
);
}
@@ -1,19 +1,29 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from 'sonner';
import { describe, expect, it, vi } from 'vitest';
import CreateJobButton from './CreateJobButton';
vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } }));
vi.mock('./CreateJobModal', () => ({
default: ({
isOpen,
workloadType,
prefillJob,
}: {
isOpen: boolean;
workloadType: string;
prefillJob?: { name?: string; prompt: string } | null;
}) =>
isOpen ? (
<div role="dialog" data-workload-type={workloadType}>
<div
role="dialog"
data-workload-type={workloadType}
data-prefill-name={prefillJob?.name ?? ''}
data-prefill-prompt={prefillJob?.prompt ?? ''}
>
Create job form
</div>
) : null,
@@ -27,7 +37,11 @@ describe('CreateJobButton', () => {
await user.click(screen.getByRole('button', { name: 'Create Job' }));
await user.click(screen.getByRole('menuitem', { name: /I2V/i }));
expect(screen.getByRole('dialog')).toHaveAttribute(
// The dialog opens a frame after the menu selection (see CreateJobButton's
// openModal: deferred via requestAnimationFrame to dodge a Radix
// DropdownMenu + Dialog pointer-events race), so wait for it rather than
// asserting synchronously.
expect(await screen.findByRole('dialog')).toHaveAttribute(
'data-workload-type',
'i2v',
);
@@ -45,9 +59,75 @@ describe('CreateJobButton', () => {
expect(firstItem).toHaveFocus();
await user.keyboard('{Enter}');
expect(screen.getByRole('dialog')).toHaveAttribute(
expect(await screen.findByRole('dialog')).toHaveAttribute(
'data-workload-type',
't2v',
);
});
describe('importing a job file', () => {
const file = (contents: string, name = 'job.json') =>
new File([contents], name, { type: 'application/json' });
it('opens the form for the file\'s workload with the job pre-filled', async () => {
const user = userEvent.setup();
render(<CreateJobButton jobType="inference" />);
await user.click(screen.getByRole('button', { name: 'Create Job' }));
await user.click(screen.getByRole('menuitem', { name: /Import job JSON/ }));
await user.upload(
screen.getByLabelText('Import job JSON file'),
file(
JSON.stringify({
model_id: 'MiniMaxAI/MiniMax-H3',
name: 'from-file',
workload_type: 'i2v',
prompt: ['summary:', 'Hi.'],
}),
),
);
const dialog = await screen.findByRole('dialog');
expect(dialog).toHaveAttribute('data-workload-type', 'i2v');
expect(dialog).toHaveAttribute('data-prefill-name', 'from-file');
expect(dialog).toHaveAttribute('data-prefill-prompt', 'summary:\nHi.');
});
it('reports an unreadable file instead of opening the form', async () => {
const user = userEvent.setup();
render(<CreateJobButton jobType="inference" />);
await user.click(screen.getByRole('button', { name: 'Create Job' }));
await user.click(screen.getByRole('menuitem', { name: /Import job JSON/ }));
await user.upload(screen.getByLabelText('Import job JSON file'), file('not json', 'bad.json'));
await vi.waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(vi.mocked(toast.error).mock.calls.at(-1)?.[0]).toContain("bad.json");
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('a normal open after an import starts clean', async () => {
const user = userEvent.setup();
render(<CreateJobButton jobType="inference" />);
await user.click(screen.getByRole('button', { name: 'Create Job' }));
await user.click(screen.getByRole('menuitem', { name: /Import job JSON/ }));
await user.upload(
screen.getByLabelText('Import job JSON file'),
file(JSON.stringify({ model_id: 'm', name: 'from-file', prompt: 'p' })),
);
expect(await screen.findByRole('dialog')).toHaveAttribute('data-prefill-name', 'from-file');
await user.click(screen.getByRole('button', { name: 'Create Job' }));
await user.click(await screen.findByRole('menuitem', { name: /T2V/i }));
await vi.waitFor(() => expect(screen.getByRole('dialog')).toHaveAttribute('data-prefill-name', ''));
});
it('is not offered on the training pages', async () => {
const user = userEvent.setup();
render(<CreateJobButton jobType="finetuning" />);
await user.click(screen.getByRole('button', { name: 'Create Job' }));
expect(screen.queryByRole('menuitem', { name: /Import job JSON/ })).not.toBeInTheDocument();
});
});
});
@@ -6,7 +6,10 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import CreateJobModal from '@/components/jobs/CreateJobModal';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { WORKLOAD_OPTIONS } from '@/lib/jobConfig';
import { parseJobJson } from '@/lib/jobJson';
import type { JobLike } from '@/lib/jobToFields';
import type { JobType } from '@/lib/types';
import { triggerRefresh } from '@/stores/jobsRefresh';
@@ -22,9 +25,32 @@ export default function CreateJobButton({ jobType }: CreateJobButtonProps) {
options[0]?.type ?? 't2v',
);
function openModal(type: string) {
// An imported job file pre-fills the form; a normal open starts clean.
const [prefillJob, setPrefillJob] = React.useState<JobLike | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
async function handleImportFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = ''; // allow re-picking the same file
if (!file) return;
const result = parseJobJson(await file.text());
if (!result.ok) {
toast.error(`Couldn't import ${file.name}: ${result.error}`);
return;
}
openModal(result.workloadType, result.job);
}
function openModal(type: string, prefill: JobLike | null = null) {
setWorkloadType(type);
setModalOpen(true);
setPrefillJob(prefill);
// Opening the Dialog synchronously inside the DropdownMenu.Item's
// onSelect races the two Radix layers' pointer-events body lock/unlock:
// the dropdown's own close cleanup can lose that race, leaving <body>
// stuck with `pointer-events: none` after this dialog later closes --
// silently blocking every click, including this same button. Deferring
// to the next frame lets the dropdown finish closing first.
requestAnimationFrame(() => setModalOpen(true));
}
function handleSuccess() {
@@ -60,15 +86,39 @@ export default function CreateJobButton({ jobType }: CreateJobButtonProps) {
</span>
</DropdownMenu.Item>
))}
{jobType === 'inference' && (
<>
<DropdownMenu.Separator className="my-1 h-px bg-border" />
<DropdownMenu.Item
onSelect={() => fileInputRef.current?.click()}
className="flex min-h-11 cursor-pointer select-none flex-col justify-center px-4 py-2 text-left text-sm font-medium outline-none data-[highlighted]:bg-secondary"
>
Import job JSON…
<span className="mt-0.5 block text-xs font-normal text-muted-foreground">
Fill the form from a job file
</span>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
aria-label="Import job JSON file"
tabIndex={-1}
className="sr-only"
onChange={handleImportFile}
/>
<CreateJobModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSuccess={handleSuccess}
jobType={jobType}
workloadType={workloadType}
prefillJob={prefillJob}
/>
</>
);
@@ -4,10 +4,13 @@ import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import CreateJobModal from './CreateJobModal';
import { toast } from 'sonner';
import { createJob, getDatasets, getModels, uploadImage } from '@/lib/api';
import { defaultOptionsStore } from '@/stores/defaultOptions';
import { DEFAULT_OPTIONS } from '@/lib/defaultOptions';
vi.mock('sonner', () => ({ toast: { warning: vi.fn(), error: vi.fn(), success: vi.fn() } }));
vi.mock('@/lib/api', () => ({
createJob: vi.fn(),
getModels: vi.fn(),
@@ -206,4 +209,179 @@ describe('CreateJobModal', () => {
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
});
it('shows the H3 speech-tag console only for an H3 model and inserts at the prompt cursor', async () => {
vi.mocked(getModels).mockResolvedValue([
...MODELS,
{ id: 'minimax-h3/i2v', label: 'MiniMax H3', type: 'i2v' },
]);
const user = userEvent.setup();
renderModal({ workloadType: 'i2v' });
await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' });
expect(screen.queryByText(/Speech tags/)).not.toBeInTheDocument();
await user.selectOptions(
screen.getByLabelText('Model'),
'minimax-h3/i2v',
);
expect(await screen.findByText(/Speech tags/)).toBeInTheDocument();
const promptField = screen.getByLabelText('Prompt') as HTMLTextAreaElement;
await user.type(promptField, 'Hello there.');
promptField.setSelectionRange(5, 5);
await user.click(screen.getByRole('button', { name: '<pause>' }));
expect(promptField.value).toBe('Hello <pause> there.');
// Focus and cursor return to the prompt field so tags can be chained.
await waitFor(() => expect(promptField).toHaveFocus());
});
});
const H3 = { id: 'minimax-h3/i2v', label: 'MiniMax H3', type: 'i2v' };
const BASE_PROMPT = [
'For the target video, <Picture 1> is fully referenced.',
'',
'integrated_multimodal_description: [Shot 1] A close-up. He (S1) says: <d>[English] Sorry.</d> <cutoff>',
'',
'overall_soundscape: Room tone.',
'',
'non_diegetic_music: N/A',
].join('\n');
describe('CreateJobModal with a prefilled job', () => {
beforeEach(() => {
vi.mocked(getModels).mockResolvedValue([...MODELS, H3]);
vi.mocked(toast.warning).mockClear();
});
it('fills the form from the job and creates a new job on submit', async () => {
const user = userEvent.setup();
const { onSuccess } = renderModal({
workloadType: 'i2v',
prefillJob: {
id: 'imported-1',
model_id: H3.id,
name: 'from-file',
prompt: BASE_PROMPT,
workload_type: 'i2v',
references: [{ source: '/data/inputs/face.png', media_type: 'image' }],
num_frames: 243,
seed: 7,
},
});
await screen.findByRole('option', { name: /MiniMax H3/ });
await waitFor(() => expect(screen.getByLabelText('Model')).toHaveValue(H3.id));
expect(screen.getByLabelText('Name (optional)')).toHaveValue('from-file');
expect(screen.getByLabelText('Prompt')).toHaveValue(BASE_PROMPT);
expect(screen.getByText('face.png')).toBeInTheDocument();
expect(screen.getByText('New Inference Job (I2V)')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Create Job' }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
const payload = vi.mocked(createJob).mock.calls[0][0] as unknown as Record<string, unknown>;
expect(payload).toMatchObject({
model_id: H3.id,
name: 'from-file',
prompt: BASE_PROMPT,
num_frames: 243,
seed: 7,
references: [{ source: '/data/inputs/face.png', media_type: 'image' }],
});
});
it("warns when the file's model isn't offered for the workload", async () => {
renderModal({
workloadType: 't2v',
prefillJob: { id: 'imported-2', model_id: 'not/a-real-model', prompt: 'p' },
});
await waitFor(() => expect(toast.warning).toHaveBeenCalled());
expect(vi.mocked(toast.warning).mock.calls[0][0]).toContain('not/a-real-model');
expect(screen.getByLabelText('Model')).toHaveValue(MODELS[0].id);
});
});
describe('CreateJobModal shot list on a raw prompt', () => {
beforeEach(() => {
vi.mocked(getModels).mockResolvedValue([...MODELS, H3]);
});
async function openShotList(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Open shot list' }));
return screen.findByRole('dialog', { name: 'Shot list' });
}
it('edits dialogue inside integrated_multimodal_description and leaves the rest of the prompt alone', async () => {
const user = userEvent.setup();
renderModal({
workloadType: 'i2v',
prefillJob: {
id: 'imported-3',
model_id: H3.id,
prompt: BASE_PROMPT,
references: [{ source: '/data/face.png', media_type: 'image' }],
},
});
await waitFor(() => expect(screen.getByLabelText('Model')).toHaveValue(H3.id));
const dialog = await openShotList(user);
expect(within(dialog).getAllByText(/integrated_multimodal_description/).length).toBeGreaterThan(0);
expect(within(dialog).queryByText('Retention analysis')).not.toBeInTheDocument();
const line = within(dialog).getByPlaceholderText('What the speaker says') as HTMLTextAreaElement;
expect(line.value).toBe('Sorry.');
await user.type(line, ' Really.');
await user.click(within(dialog).getByRole('button', { name: 'Apply' }));
await waitFor(() =>
expect(screen.getByLabelText('Prompt')).toHaveValue(BASE_PROMPT.replace('Sorry.', 'Sorry. Really.')),
);
});
it('treats a plain prompt as the shot text and writes shot markers back over it', async () => {
const user = userEvent.setup();
renderModal({ workloadType: 't2v' });
await screen.findByRole('option', { name: /MiniMax H3/ });
await user.selectOptions(screen.getByLabelText('Model'), H3.id);
await user.type(screen.getByLabelText('Prompt'), 'A dock at dawn.');
const dialog = await openShotList(user);
await user.click(within(dialog).getByRole('button', { name: 'Apply' }));
await waitFor(() => expect(screen.getByLabelText('Prompt')).toHaveValue('[Shot 1] A dock at dawn.'));
});
it('keeps retention analysis in step when the raw prompt has a detailed_description and retention_analysis', async () => {
const user = userEvent.setup();
const sections = [
'subject_definitions:',
'<Subject 1> is the courier.',
'',
'retention_analysis:',
'<Subject 1> (appears in [Shot 1]): fully_preserved - jacket.',
'',
'detailed_description:',
'[Shot 1] Wide shot. A rooftop with <Subject 1>.',
].join('\n');
renderModal({ workloadType: 't2v', prefillJob: { id: 'imported-4', model_id: H3.id, prompt: sections } });
await waitFor(() => expect(screen.getByLabelText('Model')).toHaveValue(H3.id));
const dialog = await openShotList(user);
expect(within(dialog).getByText('Retention analysis')).toBeInTheDocument();
await user.click(within(dialog).getAllByRole('button', { name: /Add shot/ })[0]);
await user.click(within(dialog).getAllByRole('button', { name: '<Subject 1>' })[1]);
await user.click(within(dialog).getByRole('button', { name: 'Apply' }));
const out = (screen.getByLabelText('Prompt') as HTMLTextAreaElement).value;
expect(out).toContain('<Subject 1> (appears in [Shot 1], [Shot 2]): fully_preserved - jacket.');
expect(out).toContain('subject_definitions:\n<Subject 1> is the courier.');
});
it('is not offered for a non-H3 model', async () => {
renderModal({ workloadType: 't2v' });
await screen.findByRole('option', { name: 'Wan T2V (wan/t2v-1.3b)' });
expect(screen.queryByRole('button', { name: 'Open shot list' })).not.toBeInTheDocument();
});
});
@@ -49,8 +49,18 @@ import {
parseH3Prompt,
serializeH3Prompt,
type H3PromptFields,
type H3PromptSection,
} from '@/lib/h3Prompt';
import { applyH3Tag, type H3Tag } from '@/lib/h3Tags';
import { H3TagConsole } from '@/components/jobs/H3TagConsole';
import { H3DialogueEditor } from '@/components/jobs/H3DialogueEditor';
import { H3ShotListEditor } from '@/components/jobs/H3ShotListEditor';
import { jobToFormFields, type JobLike } from '@/lib/jobToFields';
import { readField, shotTarget, writeField, writeShotTarget } from '@/lib/h3PromptFields';
import { toast } from 'sonner';
/** Prompt fields the speech-tag console can insert into. */
type TagTarget = 'prompt' | H3PromptSection;
export interface CreateJobModalProps {
isOpen: boolean;
@@ -60,6 +70,8 @@ export interface CreateJobModalProps {
workloadType: string;
/** When set, the modal edits this pending job instead of creating a new one. */
editingJob?: JobLike | null;
/** Seed the form from this job (an imported job file) but still create a new job on submit. */
prefillJob?: JobLike | null;
/** Show the configuration without allowing changes (started/finished jobs). */
readOnly?: boolean;
}
@@ -71,6 +83,7 @@ export default function CreateJobModal({
jobType,
workloadType,
editingJob,
prefillJob,
readOnly = false,
}: CreateJobModalProps) {
const { options } = useStore(defaultOptionsStore);
@@ -121,11 +134,85 @@ export default function CreateJobModal({
// H3 is the only registered model with an end frame or references.
const supportsLastImage = modelId.toLowerCase().includes('minimax-h3');
const usingReferences = supportsLastImage && references.length > 0;
// Same check, named for where it gates H3-only prompt features (speech tags)
// rather than the end-frame/reference upload UI.
const isH3Model = supportsLastImage;
// Which prompt field the speech-tag console inserts into: whichever one is
// actually visible, tracked via each field's onFocus below.
const [activeTagField, setActiveTagField] = React.useState<TagTarget>('prompt');
const tagFieldRefs = React.useRef<Partial<Record<TagTarget, HTMLTextAreaElement | null>>>({});
const showGuidedFields = usingReferences && useGuidedPrompt;
React.useEffect(() => {
setActiveTagField(showGuidedFields ? 'detailed_description' : 'prompt');
}, [showGuidedFields]);
function getTagFieldValue(field: TagTarget): string {
return field === 'prompt' ? prompt : promptFields[field];
}
function setTagFieldValue(field: TagTarget, value: string) {
if (field === 'prompt') setPrompt(value);
else setPromptField(field, value);
}
function insertH3Tag(tag: H3Tag) {
const field = activeTagField;
const el = tagFieldRefs.current[field] ?? null;
const currentValue = getTagFieldValue(field);
const start = el?.selectionStart ?? currentValue.length;
const end = el?.selectionEnd ?? currentValue.length;
const result = applyH3Tag(currentValue, start, end, tag);
setTagFieldValue(field, result.value);
// The textarea re-renders with the new value before this runs; restore
// focus and the cursor position so tags can be chained without reaching
// for the mouse each time.
requestAnimationFrame(() => {
const node = tagFieldRefs.current[field];
if (node) {
node.focus();
node.setSelectionRange(result.start, result.end);
}
});
}
// Full-screen dialogue editor: opens with a copy of whichever field the
// speech-tag console would currently insert into, and on Apply overwrites
// that same field wholesale (not just an insertion at the cursor).
const [dialogueEditorOpen, setDialogueEditorOpen] = React.useState(false);
const dialogueEditorField = activeTagField;
// Shot list: writes into detailed_description AND retention_analysis
// together, so unlike the speech-tag console/dialogue editor it isn't
// scoped to "whichever field is focused" -- it's specific to those two.
const [shotListOpen, setShotListOpen] = React.useState(false);
// 'sections' edits the guided Detailed description; 'prompt' edits the shots
// inside the raw prompt (base format, or a plain prompt).
const [shotListMode, setShotListMode] = React.useState<'sections' | 'prompt'>('sections');
function applyShotList(detailedDescription: string, retentionAnalysis: string) {
setPromptFields((prev) => ({
...prev,
detailed_description: detailedDescription,
retention_analysis: retentionAnalysis,
}));
}
const promptShots = React.useMemo(() => shotTarget(prompt), [prompt]);
const promptSubjects = React.useMemo(() => readField(prompt, 'subject_definitions') ?? '', [prompt]);
const promptRetention = React.useMemo(() => readField(prompt, 'retention_analysis'), [prompt]);
const promptSyncsRetention = promptShots.field === 'detailed_description' && promptRetention !== null;
function applyShotListToPrompt(shotText: string, retentionAnalysis: string) {
let next = writeShotTarget(prompt, promptShots, shotText);
if (promptSyncsRetention) next = writeField(next, 'retention_analysis', retentionAnalysis);
setPrompt(next);
}
// JobCard re-renders on every job-list poll, so `editingJob` is a fresh
// object each time. Effects must depend on these, never on the object.
const editingJobId = editingJob?.id ?? null;
const editingJobModelId = editingJob?.model_id ?? null;
const seedJob = editingJob ?? prefillJob ?? null;
const seedJobId = seedJob?.id ?? null;
const seedJobModelId = seedJob?.model_id ?? null;
const isPrefill = !editingJob && !!prefillJob;
// Layerwise offload and FSDP compete for the DiT weights and FastVideoArgs
// silently picks a winner (fastvideo_args.py:859); resolve it visibly here.
@@ -204,10 +291,10 @@ export default function CreateJobModal({
const justOpened = isOpen && !justOpenedRef.current;
justOpenedRef.current = isOpen;
if (!justOpened) return;
if (editingJob) {
if (seedJob) {
// Must not fall through to the defaults below: a partially-seeded
// form silently edits values the user never saw.
const f = jobToFormFields(editingJob);
const f = jobToFormFields(seedJob);
setModelId(f.modelId);
setName(f.name);
setPrompt(f.prompt);
@@ -296,7 +383,7 @@ export default function CreateJobModal({
setRealScoreModelPath('');
setFakeScoreModelPath('');
}
}, [isOpen, workloadType, inferenceWorkload, options, editingJobId]);
}, [isOpen, workloadType, inferenceWorkload, options, seedJobId]);
// Load the models available for this workload.
React.useEffect(() => {
@@ -319,7 +406,7 @@ export default function CreateJobModal({
// When editing, the job's own model wins over the workload default --
// this resolves after the seeding effect, so choosing a default here
// would silently swap the model out from under the user.
const editedId = editingJobModelId;
const editedId = seedJobModelId;
const chosen =
editedId && ids.includes(editedId)
? editedId
@@ -327,6 +414,11 @@ export default function CreateJobModal({
? defaultId
: (list[0]?.id ?? '');
setModelId(chosen);
if (isPrefill && editedId && chosen !== editedId) {
toast.warning(
`${editedId} isn't available for ${inferenceWorkload.toUpperCase()}${chosen ? `, so ${chosen} is selected instead` : ''}.`,
);
}
if (workloadType === 'dmd_t2v') {
setRealScoreModelPath(chosen);
setFakeScoreModelPath(chosen);
@@ -347,7 +439,7 @@ export default function CreateJobModal({
return () => {
stale = true;
};
}, [isOpen, inferenceWorkload, workloadType, editingJobModelId]);
}, [isOpen, inferenceWorkload, workloadType, seedJobModelId, isPrefill]);
// Training jobs need a dataset; load the ready datasets when relevant.
React.useEffect(() => {
@@ -857,6 +949,22 @@ export default function CreateJobModal({
</FieldRow>
)}
{isH3Model && isInference && workloadType !== 't2i' && (
<div className="flex flex-col gap-2">
<H3TagConsole onInsert={insertH3Tag} disabled={isSubmitting} />
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => setDialogueEditorOpen(true)}
disabled={isSubmitting}
>
Open dialogue editor
</Button>
</div>
)}
{usingReferences && useGuidedPrompt ? (
/* Six-section format from the model's reference prompt guide. */
<>
@@ -868,12 +976,31 @@ export default function CreateJobModal({
>
<Textarea
id={`modal-prompt-${section}`}
ref={(el) => {
tagFieldRefs.current[section] = el;
}}
value={promptFields[section]}
onChange={(e) => setPromptField(section, e.target.value)}
onFocus={() => setActiveTagField(section)}
rows={section === 'detailed_description' ? 5 : 2}
placeholder={H3_SECTION_HINTS[section]}
disabled={isSubmitting}
/>
{section === 'detailed_description' && (
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => {
setShotListMode('sections');
setShotListOpen(true);
}}
disabled={isSubmitting}
>
Open shot list
</Button>
)}
</FieldRow>
))}
<button
@@ -893,8 +1020,12 @@ export default function CreateJobModal({
>
<Textarea
id="modal-prompt"
ref={(el) => {
tagFieldRefs.current.prompt = el;
}}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onFocus={() => setActiveTagField('prompt')}
rows={isInference ? 3 : 2}
placeholder={
isInference
@@ -904,6 +1035,21 @@ export default function CreateJobModal({
required={!(usingReferences && useGuidedPrompt)}
disabled={isSubmitting}
/>
{isH3Model && isInference && workloadType !== 't2i' && (
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => {
setShotListMode('prompt');
setShotListOpen(true);
}}
disabled={isSubmitting}
>
Open shot list
</Button>
)}
</FieldRow>
{usingReferences && (
<button
@@ -1379,6 +1525,38 @@ export default function CreateJobModal({
</Button>
</div>
</form>
{/* Nested in the React tree (still portaled to <body>) so Radix treats
clicks and focus inside these as inside this dialog. As siblings
they counted as "outside", and closing one could close this too. */}
<H3DialogueEditor
open={dialogueEditorOpen}
initialValue={getTagFieldValue(dialogueEditorField)}
onApply={(value) => setTagFieldValue(dialogueEditorField, value)}
onClose={() => setDialogueEditorOpen(false)}
/>
<H3ShotListEditor
open={shotListOpen}
subjectDefinitions={shotListMode === 'sections' ? promptFields.subject_definitions : promptSubjects}
initialDetailedDescription={
shotListMode === 'sections' ? promptFields.detailed_description : promptShots.body
}
initialRetentionAnalysis={
shotListMode === 'sections' ? promptFields.retention_analysis : (promptRetention ?? '')
}
targetLabel={
shotListMode === 'sections'
? undefined
: promptShots.field === 'integrated_multimodal_description'
? 'integrated_multimodal_description'
: promptShots.field === 'detailed_description'
? 'Detailed description'
: 'the prompt'
}
withRetention={shotListMode === 'sections' || promptSyncsRetention}
onApply={shotListMode === 'sections' ? applyShotList : applyShotListToPrompt}
onClose={() => setShotListOpen(false)}
/>
</DialogContent>
</Dialog>
);
@@ -0,0 +1,346 @@
'use client';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { H3DialoguePreview } from '@/components/jobs/H3DialoguePreview';
import { H3TagPalette } from '@/components/jobs/H3TagPalette';
import { useUndoableText } from '@/hooks/useUndoableText';
import { applyH3Preset, applyH3Tag, type H3Preset, type H3Tag } from '@/lib/h3Tags';
import {
SPEECH_RATES,
dialogueTree,
estimateDuration,
type DialogueNode,
type SpeechRate,
} from '@/lib/h3DialogueEstimate';
import { cn } from '@/lib/utils';
export interface H3DialogueEditorProps {
open: boolean;
/** Seeded from whichever prompt field the editor was opened for. */
initialValue: string;
onApply: (value: string) => void;
onClose: () => void;
}
/** Every `<d>...</d>` block's inner text -- what's actually spoken, ignoring surrounding shot/camera prose. */
function extractDialogueBlocks(text: string): string[] {
const blocks: string[] = [];
const re = /<d>([\s\S]*?)<\/d>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) blocks.push(m[1].replace(/^\s*\[[^\]]*\]\s*/, '')); // drop a leading [Language] marker
return blocks;
}
/**
* Full-screen dialogue editor for MiniMax-H3 speech tags: the same tag
* registry as `H3TagConsole`, plus a duration estimate (scoped to `<d>`
* blocks so scene-description prose doesn't inflate it), a styled
* performance preview, and emotion presets. Opens with a copy of the target
* field's text; `onApply` hands the edited text back, `onClose` discards it.
* The shot list editor reuses the same palette, preview, and undo history.
*/
export function H3DialogueEditor({ open, initialValue, onApply, onClose }: H3DialogueEditorProps) {
const { text, type, commit, undo, redo, reset, canUndo, canRedo } = useUndoableText(initialValue);
const [pace, setPace] = React.useState<SpeechRate>('Natural');
const [calib, setCalib] = React.useState(1);
const [actualSeconds, setActualSeconds] = React.useState('');
const taRef = React.useRef<HTMLTextAreaElement>(null);
const pending = React.useRef<[number, number] | null>(null);
function restoreCaretAtEnd(restored: string | null) {
if (restored != null) pending.current = [restored.length, restored.length];
}
// Re-seed from the field's current value each time the editor opens, the
// same "seed on the open transition only" guard CreateJobModal itself
// uses -- otherwise an in-progress edit here could be clobbered by a
// stray parent re-render while open. History resets with it: undo
// shouldn't reach back into a previous time this editor was opened.
const wasOpenRef = React.useRef(false);
React.useEffect(() => {
if (open && !wasOpenRef.current) {
reset(initialValue);
setActualSeconds('');
setCalib(1);
}
wasOpenRef.current = open;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
React.useEffect(() => {
if (pending.current && taRef.current) {
const [s, e] = pending.current;
taRef.current.focus();
taRef.current.setSelectionRange(s, e);
pending.current = null;
}
});
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return;
e.preventDefault();
restoreCaretAtEnd(e.shiftKey ? redo() : undo());
}
function insertTag(tag: H3Tag) {
const ta = taRef.current;
const result = applyH3Tag(text, ta?.selectionStart ?? text.length, ta?.selectionEnd ?? text.length, tag);
commit(result.value);
pending.current = [result.start, result.end];
}
function applyPreset(p: H3Preset) {
const ta = taRef.current;
const result = applyH3Preset(text, ta?.selectionStart ?? text.length, ta?.selectionEnd ?? text.length, p);
commit(result.value);
pending.current = [result.start, result.end];
}
const dialogueBlocks = React.useMemo(() => extractDialogueBlocks(text), [text]);
// Falls back to the whole field only when it contains no <d> blocks at
// all yet -- e.g. a fresh field the user is about to write dialogue into.
const segments = dialogueBlocks.length > 0 ? dialogueBlocks : [text];
const estimates = React.useMemo(
() => segments.map((s) => estimateDuration(s, SPEECH_RATES[pace], calib)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[segments.join('\n'), pace, calib],
);
const totalSeconds = estimates.reduce((sum, e) => sum + e.total, 0);
function calibrate() {
const actual = parseFloat(actualSeconds);
if (!actual || actual <= 0 || segments.length === 0) return;
const base = segments.reduce((sum, s) => sum + estimateDuration(s, SPEECH_RATES[pace], 1).total, 0);
if (base > 0) setCalib(actual / base);
}
/* ---- rough browser-voice preview, for rhythm/pacing only ---- */
const synth = typeof window !== 'undefined' ? window.speechSynthesis : null;
const [playing, setPlaying] = React.useState(false);
const playTokenRef = React.useRef({ cancelled: true });
function stopPreview() {
playTokenRef.current.cancelled = true;
synth?.cancel();
setPlaying(false);
}
function playPreview() {
if (!synth) return;
stopPreview();
const token = { cancelled: false };
playTokenRef.current = token;
setPlaying(true);
const VOX: Record<string, string> = {
laughs: 'ha ha ha', chuckle: 'heh', sighs: 'haah', gasp: 'ah', uh: 'uh', mhm: 'mhm', phew: 'phew',
};
const atoms: { say?: string; gap?: number }[] = [];
for (const [i, seg] of segments.entries()) {
if (i > 0) atoms.push({ gap: 0.6 });
const walk = (nodes: DialogueNode[]) => {
for (const n of nodes) {
if (n.t === 'text') {
const t = n.v.replace(/\s+/g, ' ').trim();
if (t) atoms.push({ say: t });
} else if (n.t === 'point') {
atoms.push(VOX[n.name] ? { say: VOX[n.name] } : { gap: 0.25 });
} else {
if (n.name === 'humming') atoms.push({ say: 'hmm hmm' });
else walk(n.children);
}
}
};
walk(dialogueTree(seg));
}
let i = 0;
const step = () => {
if (token.cancelled) return;
if (i >= atoms.length) {
setPlaying(false);
return;
}
const a = atoms[i++];
if (a.gap != null) {
setTimeout(() => { if (!token.cancelled) step(); }, a.gap * 1000);
return;
}
const u = new SpeechSynthesisUtterance(a.say);
u.rate = 0.95;
u.onend = () => { if (!token.cancelled) step(); };
u.onerror = () => { if (!token.cancelled) step(); };
synth.speak(u);
};
step();
}
React.useEffect(() => () => stopPreview(), []); // eslint-disable-line react-hooks/exhaustive-deps
function handleApply() {
onApply(text);
onClose();
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="flex h-[92vh] max-h-[920px] w-[96vw] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border px-5 py-4">
<DialogTitle>Dialogue editor</DialogTitle>
<DialogDescription>
Click a tag to insert it at the cursor, or select text first to wrap it. Applying writes the edited text
back into the field you opened this from.
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1">
{/* palette */}
<H3TagPalette
onInsertTag={insertTag}
onApplyPreset={applyPreset}
className="w-64 shrink-0 overflow-y-auto border-r border-border p-3"
/>
{/* editor + preview */}
<div className="flex min-w-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
{/* duration estimate */}
<div className="rounded-xl border border-border bg-secondary/30 p-4">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<div className="flex items-baseline gap-3">
<span className="text-2xl font-semibold tracking-tight text-foreground">
{totalSeconds.toFixed(1)}<span className="ml-1 text-sm font-normal text-muted-foreground">s spoken</span>
</span>
<span className="font-mono text-xs text-muted-foreground">
{dialogueBlocks.length > 0
? `across ${dialogueBlocks.length} <d> block${dialogueBlocks.length === 1 ? '' : 's'}`
: 'no <d> blocks yet — estimating the whole field'}
</span>
</div>
<div className="flex items-center gap-1 rounded-lg border border-border bg-card p-1">
{(Object.keys(SPEECH_RATES) as SpeechRate[]).map((p) => (
<button
key={p}
type="button"
onClick={() => setPace(p)}
className={cn(
'rounded-md px-2.5 py-1 text-xs transition-colors',
pace === p ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-accent',
)}
>
{p}
</button>
))}
</div>
</div>
{segments.length > 1 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{estimates.map((e, i) => (
<span key={i} className="rounded-md border border-border bg-card px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
block {i + 1}: {e.total.toFixed(1)}s
</span>
))}
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span title="Enter the real length of a clip you already generated, to tune the estimate to your setup">
calibrate
</span>
<input
value={actualSeconds}
onChange={(e) => setActualSeconds(e.target.value)}
placeholder="actual s"
inputMode="decimal"
className="w-16 rounded-md border border-input bg-card px-2 py-1 text-foreground"
/>
<Button type="button" variant="outline" size="sm" onClick={calibrate}>Set</Button>
{Math.abs(calib - 1) > 0.001 && (
<button
type="button"
onClick={() => { setCalib(1); setActualSeconds(''); }}
className="font-mono text-muted-foreground/80 hover:text-foreground"
title="Reset calibration"
>
×{calib.toFixed(2)}
</button>
)}
</div>
</div>
{/* audio preview */}
{synth && (
<div className="flex items-center gap-3 rounded-xl border border-border bg-secondary/30 p-3">
<Button type="button" size="sm" onClick={playing ? stopPreview : playPreview}>
{playing ? '■ Stop' : '▶ Play preview'}
</Button>
<span className="text-xs text-muted-foreground">
A stand-in browser voice for rhythm only — it won&apos;t match MiniMax&apos;s voice.
</span>
</div>
)}
{/* the field itself */}
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-foreground">Text</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => restoreCaretAtEnd(undo())}
disabled={!canUndo}
title="Undo (⌘/Ctrl+Z)"
>
↶ Undo
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => restoreCaretAtEnd(redo())}
disabled={!canRedo}
title="Redo (⌘/Ctrl+Shift+Z)"
>
↷ Redo
</Button>
</div>
</div>
<Textarea
ref={taRef}
value={text}
onChange={(e) => type(e.target.value)}
onKeyDown={handleKeyDown}
spellCheck={false}
rows={8}
className="flex-1 font-mono text-sm"
/>
{/* performance preview */}
<div>
<div className="mb-2 text-xs font-semibold text-foreground">Performance preview</div>
<H3DialoguePreview
text={text}
className="rounded-xl border border-border bg-card p-4 text-sm leading-7 text-foreground"
/>
</div>
</div>
</div>
<DialogFooter className="border-t border-border px-5 py-3">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="button" onClick={handleApply}>Apply to field</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import { CATEGORY_STYLE } from '@/components/jobs/H3TagPalette';
import { dialogueTree, type DialogueNode } from '@/lib/h3DialogueEstimate';
import { H3_TAG_CATEGORIES } from '@/lib/h3Tags';
import { cn } from '@/lib/utils';
const CATEGORY_BY_TAG_NAME: Record<string, string> = Object.fromEntries(
H3_TAG_CATEGORIES.flatMap((c) => c.tags.map((t) => [t.open.replace(/[<>]/g, ''), c.id])),
);
function renderPreviewNodes(nodes: DialogueNode[], keyPrefix = ''): React.ReactNode[] {
return nodes.map((n, i) => {
const key = `${keyPrefix}${i}`;
if (n.t === 'text') return <span key={key}>{n.v}</span>;
if (n.t === 'point') {
const cat = CATEGORY_BY_TAG_NAME[n.name] ?? 'body';
const style = CATEGORY_STYLE[cat] ?? CATEGORY_STYLE.body;
return (
<span
key={key}
className={cn(
'mx-0.5 inline-block rounded border px-1.5 py-0.5 font-mono text-[11px] leading-none',
style.chip,
)}
>
{n.name}
</span>
);
}
const inner = renderPreviewNodes(n.children, `${key}-`);
if (n.name === 'i') return <strong key={key} className="italic">{inner}</strong>;
if (n.name === 'whisper') return <span key={key} className="text-[0.92em] tracking-wide opacity-60">{inner}</span>;
if (n.name === 'humming') return <em key={key} className="underline decoration-wavy decoration-emerald-500 underline-offset-4">{inner}</em>;
return <span key={key}>{inner}</span>;
});
}
/** Styled "performance" rendering of speech tags: chips for point tags, styling for wrapping ones. */
export function H3DialoguePreview({ text, className }: { text: string; className?: string }) {
return <div className={className}>{renderPreviewNodes(dialogueTree(text))}</div>;
}
@@ -0,0 +1,184 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { H3DialogueEditor } from './H3DialogueEditor';
import { H3ShotListEditor } from './H3ShotListEditor';
const SUBJECTS = '<Subject 1> is the courier.\n<Subject 2> is the rival.';
const DETAILED = [
'[Shot 1] Wide shot. A rooftop at night.',
'[Shot 2] Close-up shot, handheld. He turns. (S2) <d>[English] I had no choice.</d>',
].join('\n\n');
describe('H3DialogueEditor (shared palette + undo)', () => {
it('inserts a tag at the cursor, undoes it, and applies the edited text', async () => {
const user = userEvent.setup();
const onApply = vi.fn();
render(<H3DialogueEditor open initialValue="Hello there" onApply={onApply} onClose={() => {}} />);
const textarea = screen.getByDisplayValue('Hello there') as HTMLTextAreaElement;
textarea.focus();
textarea.setSelectionRange(5, 5);
await user.click(screen.getByRole('button', { name: /<pause>/ }));
expect(textarea.value).toBe('Hello <pause> there');
await user.click(screen.getByRole('button', { name: /Undo/ }));
expect(textarea.value).toBe('Hello there');
await user.click(screen.getByRole('button', { name: 'Nervous' }));
expect(textarea.value).toContain('<uh> <stutter>');
await user.click(screen.getByRole('button', { name: 'Apply to field' }));
expect(onApply).toHaveBeenCalledWith(expect.stringContaining('<uh> <stutter>'));
});
});
describe('H3ShotListEditor dialogue tooling', () => {
function renderEditor(onApply = vi.fn()) {
render(
<H3ShotListEditor
open
subjectDefinitions={SUBJECTS}
initialDetailedDescription={DETAILED}
initialRetentionAnalysis=""
onApply={onApply}
onClose={() => {}}
/>,
);
return onApply;
}
it('disables the palette until a shot dialogue is focused', () => {
renderEditor();
expect(screen.getByText(/Click into a shot's dialogue/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /<pause>/ })).toBeDisabled();
});
it('inserts a palette tag into the focused shot dialogue and writes it on apply', async () => {
const user = userEvent.setup();
const onApply = renderEditor();
const dialogueBoxes = screen.getAllByPlaceholderText('What the speaker says') as HTMLTextAreaElement[];
expect(dialogueBoxes).toHaveLength(1);
expect(dialogueBoxes[0].value).toBe('I had no choice.');
await user.click(dialogueBoxes[0]);
dialogueBoxes[0].setSelectionRange(1, 1);
expect(screen.getByText('Tags insert into Shot 2 dialogue')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /<pause>/ }));
expect(dialogueBoxes[0].value).toBe('I <pause> had no choice.');
await user.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply).toHaveBeenCalledTimes(1);
expect(onApply.mock.calls[0][0]).toContain('<d>[English] I <pause> had no choice.</d>');
});
it('adds and removes dialogue lines, writing every line on apply', async () => {
const user = userEvent.setup();
const onApply = renderEditor();
await user.click(screen.getAllByRole('button', { name: '+ Add line' })[1]);
const boxes = screen.getAllByPlaceholderText('What the speaker says') as HTMLTextAreaElement[];
await user.type(boxes[1], 'Then go.');
await user.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply.mock.calls[0][0]).toContain(
'(S2) <d>[English] I had no choice.</d> (S2) <d>[English] Then go.</d>',
);
});
it('shows the styled preview once a shot dialogue contains tags', async () => {
const user = userEvent.setup();
renderEditor();
const boxes = screen.getAllByPlaceholderText('What the speaker says') as HTMLTextAreaElement[];
await user.click(boxes[0]);
await user.click(screen.getByRole('button', { name: /<long pause>/ }));
const shot2 = boxes[0].closest('[id^="shot-card-"]') as HTMLElement;
expect(within(shot2).getByText('long pause')).toBeInTheDocument();
});
});
describe('H3ShotListEditor ordered body', () => {
function renderWith(detailed: string) {
const onApply = vi.fn();
render(
<H3ShotListEditor
open
subjectDefinitions={SUBJECTS}
initialDetailedDescription={detailed}
initialRetentionAnalysis=""
onApply={onApply}
onClose={() => {}}
/>,
);
return onApply;
}
it('shows hand-written dialogue as an editable line between its prose and applies the text unchanged', async () => {
const user = userEvent.setup();
const text = "[Shot 1] He (S1) says: <d>[English] I think you're sorry.</d> <cutoff>";
const onApply = renderWith(text);
expect(screen.getByText(/keeps its original wording/)).toBeInTheDocument();
const lines = screen.getAllByPlaceholderText('What the speaker says') as HTMLTextAreaElement[];
expect(lines).toHaveLength(1);
expect(lines[0].value).toBe("I think you're sorry.");
const prose = screen.getAllByPlaceholderText("What's happening in this shot") as HTMLTextAreaElement[];
expect(prose.map((p) => p.value)).toEqual(['He (S1) says: ', ' <cutoff>']);
await user.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply.mock.calls[0][0]).toBe(text);
});
it('editing a hand-written line keeps the tag after it where it was', async () => {
const user = userEvent.setup();
const onApply = renderWith("[Shot 1] He (S1) says: <d>[English] Sorry.</d> <cutoff>");
const [line] = screen.getAllByPlaceholderText('What the speaker says') as HTMLTextAreaElement[];
await user.type(line, ' Really.');
await user.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply.mock.calls[0][0]).toBe("[Shot 1] He (S1) says: <d>[English] Sorry. Really.</d> <cutoff>");
});
it('reorders dialogue lines with the move buttons', async () => {
const user = userEvent.setup();
const onApply = renderWith('[Shot 1] Wide shot. (S1) <d>[English] One.</d> (S2) <d>[English] Two.</d>');
await user.click(screen.getByRole('button', { name: 'Move line 2 up' }));
await user.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply.mock.calls[0][0]).toBe(
'[Shot 1] Wide shot. (S2) <d>[English] Two.</d> (S1) <d>[English] One.</d>',
);
});
it('adds a text block after the existing content', async () => {
const user = userEvent.setup();
const onApply = renderWith('[Shot 1] Wide shot. A dock.');
await user.click(screen.getByRole('button', { name: '+ Add text' }));
const prose = screen.getAllByPlaceholderText("What's happening in this shot") as HTMLTextAreaElement[];
expect(prose).toHaveLength(2);
await user.type(prose[1], 'Rain falls.');
await user.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply.mock.calls[0][0]).toBe('[Shot 1] Wide shot. A dock. Rain falls.');
});
it('removes a text block', async () => {
const user = userEvent.setup();
const onApply = renderWith('[Shot 1] Wide shot. A dock. (S1) <d>[English] Hi.</d>');
await user.click(screen.getByRole('button', { name: 'Remove text 1' }));
await user.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply.mock.calls[0][0]).toBe('[Shot 1] Wide shot. (S1) <d>[English] Hi.</d>');
});
it('adds a new line at the end of the shot, after any closing prose', async () => {
const user = userEvent.setup();
const onApply = renderWith('[Shot 1] Wide shot. He speaks. (S1) <d>[English] First.</d> He pauses.');
await user.click(screen.getByRole('button', { name: '+ Add line' }));
const lines = screen.getAllByPlaceholderText('What the speaker says') as HTMLTextAreaElement[];
await user.type(lines[1], 'Later.');
await user.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply.mock.calls[0][0]).toBe(
'[Shot 1] Wide shot. He speaks. (S1) <d>[English] First.</d> He pauses. (S1) <d>[English] Later.</d>',
);
});
});
@@ -0,0 +1,123 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { H3ShotListEditor } from './H3ShotListEditor';
// Original test material -- not from any real screenplay.
const SCRIPT = [
'CAPTAIN: Sit down. (EXHALES) We need to talk about the ledger.',
'',
'CLERK: Yes, sir.',
'',
'CAPTAIN: Three crates are missing and nobody on this dock saw a thing.',
].join('\n');
const SUBJECTS = '<Subject 1> is the captain.\n<Subject 2> is the clerk.';
function renderEditor(detailed = '', onApply = vi.fn()) {
render(
<H3ShotListEditor
open
subjectDefinitions={SUBJECTS}
initialDetailedDescription={detailed}
initialRetentionAnalysis=""
onApply={onApply}
onClose={() => {}}
/>,
);
return onApply;
}
async function importScript(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Import script…' }));
const dialog = await screen.findByRole('dialog', { name: 'Import script' });
await user.click(within(dialog).getByLabelText('Script'));
await user.paste(SCRIPT);
return dialog;
}
describe('script import', () => {
it('maps speakers to subjects by default and previews the clip', async () => {
const user = userEvent.setup();
renderEditor();
const dialog = await importScript(user);
expect(within(dialog).getByLabelText('CAPTAIN subject')).toHaveValue('<Subject 1>');
expect(within(dialog).getByLabelText('CLERK subject')).toHaveValue('<Subject 2>');
expect(within(dialog).getByLabelText('CAPTAIN speaker tag')).toHaveValue('S1');
expect(within(dialog).getByLabelText('CLERK speaker tag')).toHaveValue('S2');
expect(within(dialog).getByRole('option', { name: /Clip 1 of \d+/ })).toBeInTheDocument();
expect(within(dialog).getByText('Establishing')).toBeInTheDocument();
});
it('loads the chosen clip into the shot list and writes it on apply', async () => {
const user = userEvent.setup();
const onApply = renderEditor();
const dialog = await importScript(user);
await user.click(within(dialog).getByRole('button', { name: 'Load clip' }));
const boxes = (await screen.findAllByPlaceholderText('What the speaker says')) as HTMLTextAreaElement[];
expect(boxes[0].value).toBe('Sit down. <exhale> We need to talk about the ledger.');
expect(boxes[1].value).toBe('Yes, sir.');
await user.click(screen.getByRole('button', { name: 'Apply' }));
const [detailed, retention] = onApply.mock.calls[0];
expect(detailed).toContain('[Shot 1] Establishing shot.');
expect(detailed).toContain('(S1) <d>[English] Sit down. <exhale> We need to talk about the ledger.</d> (S2) <d>[English] Yes, sir.</d>');
expect(retention).toContain('<Subject 1> (appears in');
expect(retention).toContain('<Subject 2> (appears in');
});
it('warns that loading replaces the shots already in the list', async () => {
const user = userEvent.setup();
renderEditor('[Shot 1] Wide shot. A dock.\n\n[Shot 2] Close-up shot. A face.');
const dialog = await importScript(user);
expect(within(dialog).getByText('Replaces the 2 shots currently in the list.')).toBeInTheDocument();
});
it('reloading the same clip after edits restores the imported text', async () => {
const user = userEvent.setup();
renderEditor();
let dialog = await importScript(user);
await user.click(within(dialog).getByRole('button', { name: 'Load clip' }));
let boxes = (await screen.findAllByPlaceholderText('What the speaker says')) as HTMLTextAreaElement[];
await user.type(boxes[1], ' EDITED');
expect(boxes[1].value).toBe('Yes, sir. EDITED');
await user.click(screen.getByRole('button', { name: 'Import script…' }));
dialog = await screen.findByRole('dialog', { name: 'Import script' });
// Loading a clip advances the picker to the next one; choose clip 1 again.
await user.selectOptions(within(dialog).getByLabelText('Clip to load'), '0');
await user.click(within(dialog).getByRole('button', { name: 'Load clip' }));
boxes = (await screen.findAllByPlaceholderText('What the speaker says')) as HTMLTextAreaElement[];
expect(boxes[1].value).toBe('Yes, sir.');
});
it('advances to the next clip after loading one', async () => {
const user = userEvent.setup();
renderEditor();
let dialog = await importScript(user);
const picker = within(dialog).getByLabelText('Clip to load') as HTMLSelectElement;
expect(picker.options.length).toBeGreaterThan(1);
await user.click(within(dialog).getByRole('button', { name: 'Load clip' }));
await user.click(screen.getByRole('button', { name: 'Import script…' }));
dialog = await screen.findByRole('dialog', { name: 'Import script' });
expect((within(dialog).getByLabelText('Clip to load') as HTMLSelectElement).value).toBe('1');
});
it('explains when the text has no speaker turns', async () => {
const user = userEvent.setup();
renderEditor();
await user.click(screen.getByRole('button', { name: 'Import script…' }));
const dialog = await screen.findByRole('dialog', { name: 'Import script' });
await user.click(within(dialog).getByLabelText('Script'));
await user.paste('just some prose');
expect(within(dialog).getByRole('alert')).toHaveTextContent(/No "NAME: line" turns found/);
expect(within(dialog).getByRole('button', { name: 'Load clip' })).toBeDisabled();
});
});
@@ -0,0 +1,247 @@
'use client';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
import { buildClips, parseScript, scriptSpeakers, type ImportSpeaker } from '@/lib/h3ScriptImport';
import { SHOT_TYPES, shotLines, shotProse, type Shot } from '@/lib/h3Shots';
const TYPE_LABEL = Object.fromEntries(SHOT_TYPES.map((t) => [t.id, t.label]));
const DEFAULT_CLIP_SECONDS = 11;
export interface H3ScriptImportDialogProps {
open: boolean;
subjectLabels: string[];
/** shots currently in the list -- loading a clip replaces them */
existingShotCount: number;
onLoad: (shots: Shot[]) => void;
onClose: () => void;
}
function preview(shot: Shot): string {
const line = shotLines(shot).find((l) => l.text.trim());
const text = line ? `${line.speaker ? `${line.speaker}: ` : ''}“${line.text.trim()}”` : shotProse(shot);
return text.length > 90 ? `${text.slice(0, 89)}…` : text;
}
/**
* Paste (or load) a "NAME: line" script and pick one clip-sized chunk of it
* to load into the shot list. The script and speaker mapping persist between
* openings, so working through a long scene is: load clip, apply, next clip.
*/
export function H3ScriptImportDialog({
open,
subjectLabels,
existingShotCount,
onLoad,
onClose,
}: H3ScriptImportDialogProps) {
const [script, setScript] = React.useState('');
const [clipSecondsInput, setClipSecondsInput] = React.useState(String(DEFAULT_CLIP_SECONDS));
const [overrides, setOverrides] = React.useState<Record<string, Partial<ImportSpeaker>>>({});
const [clipIndex, setClipIndex] = React.useState(0);
const turns = React.useMemo(() => parseScript(script), [script]);
const names = React.useMemo(() => scriptSpeakers(turns), [turns]);
const speakers = React.useMemo(() => {
const map: Record<string, ImportSpeaker> = {};
names.forEach((name, i) => {
const o = overrides[name] ?? {};
map[name] = {
subject: 'subject' in o ? (o.subject ?? null) : (subjectLabels[i] ?? null),
tag: o.tag ?? `S${i + 1}`,
};
});
return map;
}, [names, overrides, subjectLabels]);
const parsedSeconds = Number(clipSecondsInput);
const targetSeconds = Number.isFinite(parsedSeconds) ? Math.min(Math.max(parsedSeconds, 4), 12) : DEFAULT_CLIP_SECONDS;
const clips = React.useMemo(
() => (turns.length ? buildClips(turns, { speakers, targetSeconds }) : []),
[turns, speakers, targetSeconds],
);
const selected = Math.min(clipIndex, Math.max(clips.length - 1, 0));
const clip = clips[selected];
function setOverride(name: string, patch: Partial<ImportSpeaker>) {
setOverrides((prev) => ({ ...prev, [name]: { ...prev[name], ...patch } }));
}
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setScript(await file.text());
setClipIndex(0);
}
function handleLoad() {
if (!clip) return;
onLoad(clip.shots);
setClipIndex(Math.min(selected + 1, clips.length - 1));
onClose();
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="flex max-h-[90vh] w-[94vw] max-w-[760px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b border-border px-5 py-4">
<DialogTitle>Import script</DialogTitle>
<DialogDescription>
Paste a script of NAME: line turns. It&apos;s split into clip-sized chunks; load one at a time into the
shot list, then set the camera and descriptions.
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-5">
<div className="flex flex-col gap-2">
<label htmlFor="script-import-text" className="text-xs font-semibold text-foreground">
Script
</label>
<Textarea
id="script-import-text"
value={script}
onChange={(e) => {
setScript(e.target.value);
setClipIndex(0);
}}
rows={8}
spellCheck={false}
placeholder={'MARK: First line...\n\nJORDAN: Reply...'}
className="font-mono text-xs"
/>
<Input
type="file"
accept=".md,.txt,.text"
aria-label="Load script file"
onChange={handleFile}
className="h-auto py-2 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-secondary file:px-2 file:py-1 file:text-sm file:text-secondary-foreground"
/>
</div>
{script.trim() && turns.length === 0 && (
<p role="alert" className="text-sm text-destructive">
No &quot;NAME: line&quot; turns found. Each turn should start with a speaker name and a colon.
</p>
)}
{names.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-foreground">Speakers</span>
{names.map((name) => (
<div key={name} className="grid grid-cols-[1fr_1fr_5rem] items-center gap-2">
<span className="truncate text-sm text-foreground">{name}</span>
<NativeSelect
aria-label={`${name} subject`}
value={speakers[name].subject ?? ''}
onChange={(e) => setOverride(name, { subject: e.target.value || null })}
>
<option value="">Not a tracked subject</option>
{subjectLabels.map((label) => (
<option key={label} value={label}>{label}</option>
))}
</NativeSelect>
<Input
aria-label={`${name} speaker tag`}
value={speakers[name].tag}
onChange={(e) => setOverride(name, { tag: e.target.value })}
className="font-mono"
/>
</div>
))}
{subjectLabels.length === 0 && (
<p className="text-xs text-muted-foreground">
No subjects are defined yet, so speakers can&apos;t be linked. Add them under Subject definitions
to track who appears in each shot.
</p>
)}
</div>
)}
{turns.length > 0 && (
<>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Clip length target (seconds)
<Input
type="number"
min={4}
max={12}
step={1}
value={clipSecondsInput}
onChange={(e) => {
setClipSecondsInput(e.target.value);
setClipIndex(0);
}}
className="w-28"
/>
<span>One generation runs about 11 s (277 frames at 24 fps), so each clip becomes its own job.</span>
</label>
<div className="flex flex-col gap-2">
<label htmlFor="script-import-clip" className="text-xs font-semibold text-foreground">
Clip to load
</label>
<NativeSelect
id="script-import-clip"
value={selected}
onChange={(e) => setClipIndex(Number(e.target.value))}
>
{clips.map((c, i) => (
<option key={i} value={i}>
Clip {i + 1} of {clips.length} · {c.shots.length} shot{c.shots.length === 1 ? '' : 's'}
{c.continuation ? ' · continues previous shot' : ''}
</option>
))}
</NativeSelect>
{clip?.continuation && (
<p className="rounded-lg border border-border bg-secondary/30 px-3 py-2 text-xs text-muted-foreground">
This clip continues the previous clip&apos;s last shot, cut short because that speech didn&apos;t
fit one generation. Generate it from the previous clip&apos;s last frame (as the start Image)
with the same references.
</p>
)}
{clip && (
<ol className="flex flex-col gap-1 rounded-xl border border-border bg-secondary/20 p-3">
{clip.shots.map((shot, i) => (
<li key={shot.id} className="flex gap-2 text-xs">
<span className="w-6 shrink-0 font-mono text-muted-foreground">{i + 1}</span>
<span className="w-24 shrink-0 font-medium text-foreground">{TYPE_LABEL[shot.type]}</span>
<span className="min-w-0 text-muted-foreground">{preview(shot)}</span>
</li>
))}
</ol>
)}
</div>
</>
)}
</div>
<DialogFooter className="items-center border-t border-border px-5 py-3">
{existingShotCount > 0 && clip && (
<span className="mr-auto text-xs text-muted-foreground">
Replaces the {existingShotCount} shot{existingShotCount === 1 ? '' : 's'} currently in the list.
</span>
)}
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="button" onClick={handleLoad} disabled={!clip}>Load clip</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,92 @@
'use client';
import * as React from 'react';
import { Textarea } from '@/components/ui/textarea';
import { H3DialoguePreview } from '@/components/jobs/H3DialoguePreview';
import { applyH3Preset, applyH3Tag, type H3Preset, type H3Tag } from '@/lib/h3Tags';
/** What the shared tag palette drives: the last-focused shot dialogue field. */
export interface H3ShotDialogueHandle {
insertTag: (tag: H3Tag) => void;
applyPreset: (preset: H3Preset) => void;
}
export interface H3ShotDialogueFieldProps {
/** The line's text. The parent owns it (and the undo history), so this is fully controlled. */
value: string;
/** Typing. */
onChange: (value: string) => void;
/** A tag or preset inserted from the palette -- a discrete edit, unlike typing. */
onInsert: (value: string) => void;
onFocus: () => void;
active?: boolean;
label?: string;
}
/**
* One spoken line: a textarea plus, once it contains tags, a styled preview.
* The shared palette inserts at the cursor through the ref. Undo/redo isn't
* here -- the shot list editor keeps one history for everything in it.
*/
export const H3ShotDialogueField = React.forwardRef<H3ShotDialogueHandle, H3ShotDialogueFieldProps>(
function H3ShotDialogueField({ value, onChange, onInsert, onFocus, active, label = 'Dialogue' }, ref) {
const taRef = React.useRef<HTMLTextAreaElement>(null);
// Selection to restore once the inserted text has rendered.
const pending = React.useRef<[number, number] | null>(null);
React.useEffect(() => {
if (pending.current && taRef.current) {
const [s, e] = pending.current;
taRef.current.focus();
taRef.current.setSelectionRange(s, e);
pending.current = null;
}
});
function selection(): [number, number] {
const ta = taRef.current;
return [ta?.selectionStart ?? value.length, ta?.selectionEnd ?? value.length];
}
React.useImperativeHandle(ref, () => ({
insertTag(tag) {
const [s, e] = selection();
const result = applyH3Tag(value, s, e, tag);
pending.current = [result.start, result.end];
onInsert(result.value);
},
applyPreset(preset) {
const [s, e] = selection();
const result = applyH3Preset(value, s, e, preset);
pending.current = [result.start, result.end];
onInsert(result.value);
},
}));
return (
<div className="flex flex-col gap-1">
<div className="text-xs text-muted-foreground">
{label}
{active && <span className="ml-2 text-accent-blue">tags insert here</span>}
</div>
<Textarea
ref={taRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
rows={2}
spellCheck={false}
placeholder="What the speaker says"
className="font-mono text-sm"
/>
{value.includes('<') && (
<H3DialoguePreview
text={value}
className="rounded-lg border border-border bg-card px-3 py-2 text-sm leading-7 text-foreground"
/>
)}
</div>
);
},
);
@@ -0,0 +1,690 @@
'use client';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
import { H3ShotDialogueField, type H3ShotDialogueHandle } from '@/components/jobs/H3ShotDialogueField';
import { H3ScriptImportDialog } from '@/components/jobs/H3ScriptImportDialog';
import { H3ShotStoryboard } from '@/components/jobs/H3ShotStoryboard';
import { H3TagPalette } from '@/components/jobs/H3TagPalette';
import type { H3Preset, H3Tag } from '@/lib/h3Tags';
import {
CAMERA_MOVEMENTS,
SHOT_TYPES,
detailedDescriptionFromShots,
formatCutTime,
linePart,
newLine,
newShot,
newTextPart,
parseCutTime,
parseDetailedDescription,
parseSubjectLabels,
resolveShotStarts,
retentionAnalysisFromShots,
shotLines,
withFreshIds,
type CameraMovement,
type DialogueLine,
type LinePart,
type ShotPart,
type Shot,
type ShotType,
} from '@/lib/h3Shots';
import { useUndoableState } from '@/hooks/useUndoableState';
import { cn } from '@/lib/utils';
export interface H3ShotListEditorProps {
open: boolean;
/** Raw subject_definitions text -- parsed for <Subject N> labels to track per shot. */
subjectDefinitions: string;
initialDetailedDescription: string;
initialRetentionAnalysis: string;
onApply: (detailedDescription: string, retentionAnalysis: string) => void;
onClose: () => void;
/** Where the shots get written, for the description text. */
targetLabel?: string;
/** False when there is no retention analysis to keep in sync (base-format and plain prompts). */
withRetention?: boolean;
}
/**
* Structured camera-direction editor for MiniMax-H3: an ordered shot list
* (type, movement, which subjects appear, action, optional dialogue) that
* writes into detailed_description's [Shot N] blocks and keeps
* retention_analysis's "appears in [...]" lines in sync automatically,
* instead of hand-maintaining both fields' free text in agreement.
*/
/** Cut-in time field: blank means "automatic" (shown as the placeholder); Enter or blur commits. */
function CutTimeInput({
value,
auto,
disabled,
onCommit,
}: {
value: number | null;
auto: number;
disabled?: boolean;
onCommit: (seconds: number | null) => void;
}) {
const show = (v: number | null) => (v == null ? '' : formatCutTime(v));
const [text, setText] = React.useState(show(value));
React.useEffect(() => setText(show(value)), [value]);
function commit() {
const trimmed = text.trim();
if (!trimmed) return onCommit(null);
const parsed = parseCutTime(trimmed);
if (parsed == null) return setText(show(value));
onCommit(parsed);
}
return (
<Input
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
}}
placeholder={disabled ? 'Opening shot' : formatCutTime(auto)}
disabled={disabled}
inputMode="decimal"
className="font-mono"
/>
);
}
interface EditorState {
shots: Shot[];
preamble: string;
}
export function H3ShotListEditor({
open,
subjectDefinitions,
initialDetailedDescription,
initialRetentionAnalysis,
onApply,
onClose,
targetLabel = 'Detailed description',
withRetention = true,
}: H3ShotListEditorProps) {
// One history covers everything editable here: the shots (structure, fields and
// every line of text) and the scene notes. Typing carries a key so a burst
// merges into one step; everything else is a step of its own.
const history = useUndoableState<EditorState>({ shots: [], preamble: '' });
const { shots, preamble } = history.state;
// Returning the same list (or text) hands the history back the same state object,
// which it treats as "nothing changed" and does not record.
const editShots = (fn: (shots: Shot[]) => Shot[], key: string | null = null) =>
history.set((s) => {
const next = fn(s.shots);
return next === s.shots ? s : { ...s, shots: next };
}, key);
const setPreamble = (value: string) =>
history.set((s) => (s.preamble === value ? s : { ...s, preamble: value }), 'preamble');
const [selectedId, setSelectedId] = React.useState<string | null>(null);
// The shared tag palette inserts into whichever shot's dialogue was last focused.
const [dialogueTargetId, setDialogueTargetId] = React.useState<string | null>(null);
const dialogueFields = React.useRef(new Map<string, H3ShotDialogueHandle>());
const [importOpen, setImportOpen] = React.useState(false);
const subjectLabels = React.useMemo(() => parseSubjectLabels(subjectDefinitions), [subjectDefinitions]);
// Re-seed from the field's current text each time the editor opens, the
// same "seed on the open transition only" guard H3DialogueEditor uses --
// otherwise a stray parent re-render while open could clobber in-progress edits.
const wasOpenRef = React.useRef(false);
React.useEffect(() => {
if (open && !wasOpenRef.current) {
const { preamble: seededPreamble, shots: seeded } = parseDetailedDescription(
initialDetailedDescription,
subjectLabels,
);
history.reset({ preamble: seededPreamble, shots: seeded });
setSelectedId(seeded[0]?.id ?? null);
setDialogueTargetId(null);
}
wasOpenRef.current = open;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
function addShot() {
const shot = newShot();
editShots((prev) => [...prev, shot]);
selectShot(shot.id);
}
// Storyboard panel -> jump to that shot's editor card.
function selectShot(id: string) {
setSelectedId(id);
requestAnimationFrame(() =>
document.getElementById(`shot-card-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }),
);
}
function removeShot(id: string) {
editShots((prev) => prev.filter((s) => s.id !== id));
setDialogueTargetId((cur) => (cur === id ? null : cur));
}
function insertTag(tag: H3Tag) {
if (dialogueTargetId) dialogueFields.current.get(dialogueTargetId)?.insertTag(tag);
}
function applyPreset(preset: H3Preset) {
if (dialogueTargetId) dialogueFields.current.get(dialogueTargetId)?.applyPreset(preset);
}
const partKey = (p: ShotPart) => (p.kind === 'text' ? p.id : p.line.id);
// An edit that changes nothing returns the same list and so records no undo step
// (a blur that re-commits the same value, for instance).
function updateBody(shotId: string, fn: (body: ShotPart[]) => ShotPart[], editKey: string | null = null) {
editShots((prev) => {
let changed = false;
const next = prev.map((s) => {
if (s.id !== shotId) return s;
const body = fn(s.body);
if (body === s.body) return s;
changed = true;
return { ...s, body };
});
return changed ? next : prev;
}, editKey);
}
function addLine(shotId: string) {
updateBody(shotId, (body) => {
const lastSpeaker = [...body].reverse().find((p): p is LinePart => p.kind === 'line')?.line.speaker;
return [...body, linePart(newLine({ speaker: lastSpeaker ?? 'S1' }))];
});
}
function addText(shotId: string) {
updateBody(shotId, (body) => [...body, newTextPart()]);
}
function removePart(shotId: string, key: string) {
updateBody(shotId, (body) => body.filter((p) => partKey(p) !== key));
setDialogueTargetId((cur) => (cur === key ? null : cur));
}
function movePart(shotId: string, key: string, dir: -1 | 1) {
updateBody(shotId, (body) => {
const i = body.findIndex((p) => partKey(p) === key);
const j = i + dir;
if (i < 0 || j < 0 || j >= body.length) return body;
const next = [...body];
[next[i], next[j]] = [next[j], next[i]];
return next;
});
}
function updateText(shotId: string, key: string, text: string) {
updateBody(
shotId,
(body) => {
const part = body.find((p) => p.kind === 'text' && p.id === key);
if (!part || (part.kind === 'text' && part.text === text)) return body;
return body.map((p) => (p === part ? { ...part, text } : p));
},
`text:${key}`,
);
}
/** `editKey` groups typing into one undo step; omit it for a discrete edit like a tag insertion. */
function updateLine(shotId: string, lineId: string, patch: Partial<DialogueLine>, editKey?: string) {
updateBody(
shotId,
(body) => {
const part = body.find((p) => p.kind === 'line' && p.line.id === lineId);
if (!part || part.kind !== 'line') return body;
const unchanged = (Object.keys(patch) as (keyof DialogueLine)[]).every((k) => part.line[k] === patch[k]);
return unchanged ? body : body.map((p) => (p === part ? { ...part, line: { ...part.line, ...patch } } : p));
},
editKey ?? null,
);
}
const undo = () => void history.undo();
const redo = () => void history.redo();
// Undo/redo for the whole list, from anywhere inside it -- including from a textbox,
// where the browser's own undo would only know about that one box. Not while the
// script import (nested inside this dialog) is open: its box keeps native undo.
function handleKeyDown(e: React.KeyboardEvent) {
if (importOpen || e.altKey || !(e.metaKey || e.ctrlKey)) return;
const k = e.key.toLowerCase();
if (k === 'z') {
e.preventDefault();
if (e.shiftKey) redo();
else undo();
} else if (k === 'y' && !e.shiftKey) {
e.preventDefault();
redo();
}
}
// An imported clip replaces the list wholesale, as one undoable step.
function loadImportedShots(imported: Shot[]) {
const next = withFreshIds(imported);
editShots(() => next);
setSelectedId(next[0]?.id ?? null);
setDialogueTargetId(null);
}
const dialogueTarget = (() => {
for (let i = 0; i < shots.length; i += 1) {
const lines = shotLines(shots[i]);
const li = lines.findIndex((l) => l.id === dialogueTargetId);
if (li >= 0) return { shot: i + 1, line: li + 1, multi: lines.length > 1 };
}
return null;
})();
function moveShot(id: string, dir: -1 | 1) {
editShots((prev) => {
const i = prev.findIndex((s) => s.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= prev.length) return prev;
const next = [...prev];
[next[i], next[j]] = [next[j], next[i]];
return next;
});
}
function updateShot(id: string, patch: Partial<Shot>) {
editShots((prev) => {
const shot = prev.find((s) => s.id === id);
const unchanged = shot && (Object.keys(patch) as (keyof Shot)[]).every((k) => shot[k] === patch[k]);
return !shot || unchanged ? prev : prev.map((s) => (s === shot ? { ...s, ...patch } : s));
});
}
function toggleSubject(id: string, label: string) {
editShots((prev) =>
prev.map((s) =>
s.id === id
? {
...s,
subjectIds: s.subjectIds.includes(label)
? s.subjectIds.filter((l) => l !== label)
: [...s.subjectIds, label],
}
: s,
),
);
}
const starts = React.useMemo(() => resolveShotStarts(shots), [shots]);
const previewDetailedDescription = React.useMemo(
() => detailedDescriptionFromShots(shots, preamble),
[shots, preamble],
);
const previewRetentionAnalysis = React.useMemo(
() => (withRetention ? retentionAnalysisFromShots(shots, subjectLabels, initialRetentionAnalysis) : ''),
[withRetention, shots, subjectLabels, initialRetentionAnalysis],
);
function handleApply() {
onApply(previewDetailedDescription, previewRetentionAnalysis);
onClose();
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent
onKeyDown={handleKeyDown}
className="flex h-[92vh] max-h-[920px] w-[96vw] max-w-[1100px] flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="border-b border-border px-5 py-4">
<DialogTitle>Shot list</DialogTitle>
<DialogDescription>
Build the scene shot by shot. Applying writes [Shot N] blocks into {targetLabel}
{withRetention ? ' and keeps Retention analysis\u2019s \u201cappears in\u201d lines in sync' : ''}.
</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 flex-col gap-2 border-b border-border bg-secondary/10 px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-foreground">Storyboard</span>
<span className="flex items-center gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
onClick={undo}
disabled={!history.canUndo}
title="Undo (Ctrl/Cmd+Z)"
>
↶ Undo
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={redo}
disabled={!history.canRedo}
title="Redo (Ctrl/Cmd+Shift+Z)"
>
↷ Redo
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setImportOpen(true)}>
Import script…
</Button>
</span>
</div>
<H3ShotStoryboard shots={shots} selectedId={selectedId} onSelect={selectShot} onAdd={addShot} />
</div>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Scene notes (optional, written before Shot 1 -- style, lighting, anything true of the whole clip)
<Textarea
value={preamble}
onChange={(e) => setPreamble(e.target.value)}
rows={2}
placeholder="e.g. Live-action, cinematic, shallow depth of field."
/>
</label>
{shots.length === 0 && (
<p className="rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground">
No shots yet.
</p>
)}
{shots.map((shot, i) => (
<div
key={shot.id}
id={`shot-card-${shot.id}`}
onFocusCapture={() => setSelectedId(shot.id)}
className={cn(
'flex flex-col gap-3 rounded-xl border bg-secondary/20 p-3',
shot.id === selectedId ? 'border-accent-blue' : 'border-border',
)}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">
Shot {i + 1}
{i > 0 && (
<span className="ml-2 font-mono text-xs font-normal text-muted-foreground">
cuts in at {formatCutTime(starts[i])}
</span>
)}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveShot(shot.id, -1)}
disabled={i === 0}
title="Move up"
>
↑
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveShot(shot.id, 1)}
disabled={i === shots.length - 1}
title="Move down"
>
↓
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => removeShot(shot.id)}>
Remove
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Shot type
<NativeSelect
value={shot.type}
onChange={(e) => updateShot(shot.id, { type: e.target.value as ShotType, freeLead: false })}
>
{SHOT_TYPES.map((t) => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</NativeSelect>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Camera movement
<NativeSelect
value={shot.movement}
onChange={(e) => updateShot(shot.id, { movement: e.target.value as CameraMovement, freeLead: false })}
>
{CAMERA_MOVEMENTS.map((m) => (
<option key={m.id} value={m.id}>{m.label}</option>
))}
</NativeSelect>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Cut time (blank = automatic)
<CutTimeInput
value={shot.startSeconds}
auto={starts[i]}
disabled={i === 0}
onCommit={(startSeconds) => updateShot(shot.id, { startSeconds })}
/>
</label>
</div>
{shot.freeLead && (
<p className="text-xs text-muted-foreground">
This shot keeps its original wording, so type and movement aren&apos;t written. Changing either
adds a generated lead-in in front of the text below.
</p>
)}
{subjectLabels.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Subjects in this shot</span>
<div className="flex flex-wrap gap-1.5">
{subjectLabels.map((label) => {
const active = shot.subjectIds.includes(label);
return (
<button
key={label}
type="button"
aria-pressed={active}
onClick={() => toggleSubject(shot.id, label)}
className={cn(
'rounded-full border px-2 py-0.5 font-mono text-[11px] transition-colors',
active
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
: 'border-input bg-card text-muted-foreground hover:bg-accent',
)}
>
{label}
</button>
);
})}
</div>
</div>
)}
<div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">
Description and dialogue, in the order they play. Dialogue is wrapped in &lt;d&gt; on apply.
</span>
{(() => {
let lineNo = 0;
let textNo = 0;
return shot.body.map((part, bi) => {
const key = partKey(part);
const noun = part.kind === 'line' ? `line ${(lineNo += 1)}` : `text ${(textNo += 1)}`;
const controls = (
<div className="mt-6 flex shrink-0 gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => movePart(shot.id, key, -1)}
disabled={bi === 0}
aria-label={`Move ${noun} up`}
title="Move up"
>
↑
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => movePart(shot.id, key, 1)}
disabled={bi === shot.body.length - 1}
aria-label={`Move ${noun} down`}
title="Move down"
>
↓
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removePart(shot.id, key)}
aria-label={`Remove ${noun}`}
title="Remove"
>
×
</Button>
</div>
);
if (part.kind === 'text') {
return (
<div key={key} className="flex items-start gap-2">
<label className="flex min-w-0 flex-1 flex-col gap-1 text-xs text-muted-foreground">
{textNo === 1 ? 'Description (composition, action, environment)' : `Text ${textNo}`}
<Textarea
value={part.text}
onChange={(e) => updateText(shot.id, key, e.target.value)}
rows={2}
placeholder="What's happening in this shot"
/>
</label>
{controls}
</div>
);
}
const line = part.line;
return (
<div key={key} className="flex items-start gap-2">
<Input
value={line.speaker}
onChange={(e) => updateLine(shot.id, line.id, { speaker: e.target.value }, `speaker:${line.id}`)}
placeholder="S1"
aria-label={`Line ${lineNo} speaker`}
className="mt-7 w-16 shrink-0 font-mono"
/>
<div className="min-w-0 flex-1">
<H3ShotDialogueField
ref={(handle) => {
if (handle) dialogueFields.current.set(line.id, handle);
else dialogueFields.current.delete(line.id);
}}
value={line.text}
onChange={(text) => updateLine(shot.id, line.id, { text }, `line:${line.id}`)}
onInsert={(text) => updateLine(shot.id, line.id, { text })}
onFocus={() => setDialogueTargetId(line.id)}
active={line.id === dialogueTargetId}
label={`Line ${lineNo}`}
/>
</div>
{controls}
</div>
);
});
})()}
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => addText(shot.id)}>
+ Add text
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => addLine(shot.id)}>
+ Add line
</Button>
</div>
</div>
</div>
))}
<Button type="button" variant="outline" onClick={addShot} className="self-start">
+ Add shot
</Button>
<div className="flex flex-col gap-2 rounded-xl border border-border bg-card p-3">
<span className="text-xs font-semibold text-foreground">Preview</span>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{targetLabel}
</span>
<pre className="whitespace-pre-wrap rounded-lg bg-secondary/30 p-2 font-mono text-[11px] text-foreground">
{previewDetailedDescription || '—'}
</pre>
</div>
{withRetention && (
<div className="flex flex-col gap-1">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Retention analysis
</span>
<pre className="whitespace-pre-wrap rounded-lg bg-secondary/30 p-2 font-mono text-[11px] text-foreground">
{previewRetentionAnalysis || '—'}
</pre>
</div>
)}
</div>
</div>
<aside className="flex max-h-56 shrink-0 flex-col border-t border-border md:max-h-none md:w-64 md:border-l md:border-t-0">
<p className="border-b border-border px-3 py-2 text-xs text-muted-foreground">
{dialogueTarget
? `Tags insert into Shot ${dialogueTarget.shot}${dialogueTarget.multi ? `, line ${dialogueTarget.line}` : ''} dialogue`
: 'Click into a shot\'s dialogue to insert tags'}
</p>
<H3TagPalette
onInsertTag={insertTag}
onApplyPreset={applyPreset}
disabled={!dialogueTarget}
className="overflow-y-auto p-3"
/>
</aside>
</div>
<DialogFooter className="border-t border-border px-5 py-3">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="button" onClick={handleApply}>Apply</Button>
</DialogFooter>
{/* Nested so Radix treats it as part of this dialog (see CreateJobModal). */}
<H3ScriptImportDialog
open={importOpen}
subjectLabels={subjectLabels}
existingShotCount={shots.length}
onLoad={loadImportedShots}
onClose={() => setImportOpen(false)}
/>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,214 @@
'use client';
import * as React from 'react';
import {
CAMERA_MOVEMENTS,
SHOT_TYPES,
shotLines,
shotProse,
type CameraMovement,
type Shot,
type ShotType,
} from '@/lib/h3Shots';
const TYPE_LABEL = Object.fromEntries(SHOT_TYPES.map((t) => [t.id, t.label])) as Record<ShotType, string>;
const MOVEMENT_LABEL = Object.fromEntries(CAMERA_MOVEMENTS.map((m) => [m.id, m.label])) as Record<
CameraMovement,
string
>;
// Schematic framing per shot type: `scale` sizes the figures and `baseY` is
// where their shoulders bottom out in the 160x90 frame, so a wide shot reads
// as small distant figures and a close-up as a head filling the frame.
const FRAMING: Partial<Record<ShotType, { scale: number; baseY: number }>> = {
establishing: { scale: 0.45, baseY: 72 },
wide: { scale: 0.8, baseY: 78 },
medium: { scale: 1.5, baseY: 92 },
'close-up': { scale: 2.6, baseY: 118 },
};
function figureXs(count: number): number[] {
if (count <= 1) return [80];
if (count === 2) return [55, 105];
return [38, 80, 122];
}
function Figure({ x, baseY, scale, opacity }: { x: number; baseY: number; scale: number; opacity: number }) {
return (
<g fill="currentColor" opacity={opacity}>
<circle cx={x} cy={baseY - 30 * scale} r={8 * scale} />
<ellipse cx={x} cy={baseY - 12 * scale} rx={14 * scale} ry={12 * scale} />
</g>
);
}
export function ShotFrame({ shot }: { shot: Shot }) {
const count = Math.min(Math.max(shot.subjectIds.length, 1), 3);
const empty = shot.subjectIds.length === 0;
const opacity = empty ? 0.25 : 0.6;
const framing = FRAMING[shot.type];
let scene: React.ReactNode;
if (shot.type === 'extreme-close-up') {
scene = (
<g fill="currentColor" opacity={opacity}>
<circle cx={80} cy={52} r={62} />
<ellipse cx={60} cy={42} rx={7} ry={4} className="fill-background" />
<ellipse cx={100} cy={42} rx={7} ry={4} className="fill-background" />
</g>
);
} else if (shot.type === 'over-the-shoulder') {
scene = (
<>
<Figure x={22} baseY={135} scale={3} opacity={0.85} />
<Figure x={116} baseY={80} scale={1} opacity={opacity} />
</>
);
} else if (shot.type === 'pov') {
scene = (
<>
<Figure x={80} baseY={72} scale={0.9} opacity={opacity} />
<path
d="M0 0H160V90H0Z M80 45m-52 0a52 40 0 1 0 104 0a52 40 0 1 0 -104 0"
fill="currentColor"
fillRule="evenodd"
opacity={0.3}
/>
<path d="M74 45H86M80 39V51" stroke="currentColor" strokeWidth={1.5} opacity={0.6} />
</>
);
} else if (shot.type === 'insert') {
scene = (
<g opacity={0.6} fill="none" stroke="currentColor" strokeWidth={2.5}>
<rect x={52} y={16} width={56} height={58} rx={8} />
<path d="M64 34H96M64 46H96M64 58H84" strokeLinecap="round" />
</g>
);
} else if (framing) {
scene = (
<>
{shot.type === 'establishing' && (
<path d="M0 66H160" stroke="currentColor" strokeWidth={1} opacity={0.35} />
)}
{figureXs(count).map((x, i) => (
<Figure
key={x}
x={shot.type === 'close-up' ? 80 : x}
baseY={framing.baseY}
scale={framing.scale}
opacity={opacity * (i === 0 ? 1 : 0.75)}
/>
))}
</>
);
}
return (
<svg viewBox="0 0 160 90" className="block h-full w-full text-foreground" aria-hidden>
{scene}
<MovementOverlay movement={shot.movement} />
</svg>
);
}
function MovementOverlay({ movement }: { movement: CameraMovement }) {
const g = (children: React.ReactNode) => (
<g
className="text-accent-blue"
stroke="currentColor"
fill="none"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</g>
);
switch (movement) {
case 'pan':
return g(<path d="M28 80H132M28 80l6-4M28 80l6 4M132 80l-6-4M132 80l-6 4" />);
case 'tilt':
return g(<path d="M146 18V72M146 18l-4 6M146 18l4 6M146 72l-4-6M146 72l4-6" />);
case 'dolly':
return g(<path d="M10 86L54 56M150 86L106 56M10 86H150" strokeDasharray="4 4" />);
case 'zoom':
return g(<path d="M16 26V14H28M132 14H144V26M16 64V76H28M132 76H144V64" />);
case 'handheld':
return g(
<rect x={7} y={7} width={146} height={76} rx={4} strokeDasharray="3 4" transform="rotate(-1.6 80 45)" />,
);
case 'tracking':
return g(
<>
<path d="M22 82H134M134 82l-6-4M134 82l-6 4" />
<circle cx={30} cy={82} r={3} fill="currentColor" />
</>,
);
case 'crane':
return g(<path d="M24 80L134 20M134 20h-9M134 20l-3 8" />);
default:
return null;
}
}
function subjectShortLabel(label: string): string {
const m = label.match(/(\d+)/);
return m ? `Subj ${m[1]}` : label;
}
export interface H3ShotPanelProps {
shot: Shot;
/** the number shown in the badge */
number: number | string;
/** time printed on the frame, e.g. "00:03.500" */
timeLabel: string;
}
/**
* The contents of one storyboard panel: badge, shot type and camera move, a
* schematic frame, who is in it, what is said, and the shot's prose. Callers
* supply the surrounding card (a button in the editor, plain in the scene view).
*/
export function H3ShotPanel({ shot, number, timeLabel }: H3ShotPanelProps) {
const spoken = shotLines(shot).filter((l) => l.text.trim());
return (
<>
<div className="flex items-center gap-1.5">
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-secondary text-[11px] font-semibold text-foreground">
{number}
</span>
<span className="truncate text-xs font-medium text-foreground">{TYPE_LABEL[shot.type]}</span>
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">{MOVEMENT_LABEL[shot.movement]}</span>
</div>
<div className="relative aspect-video overflow-hidden rounded-md border border-border bg-secondary/40">
<ShotFrame shot={shot} />
<span className="absolute bottom-1 left-1 rounded bg-background/80 px-1 font-mono text-[9px] text-muted-foreground">
{timeLabel}
</span>
</div>
{shot.subjectIds.length > 0 && (
<div className="flex flex-wrap gap-1">
{shot.subjectIds.map((label) => (
<span
key={label}
className="rounded-full border border-input bg-secondary/50 px-1.5 py-px font-mono text-[10px] text-muted-foreground"
>
{subjectShortLabel(label)}
</span>
))}
</div>
)}
{spoken.length > 0 && (
<p className="line-clamp-3 rounded-md bg-accent-blue/10 px-1.5 py-1 text-[11px] italic text-foreground">
{spoken.map((l) => `${l.speaker.trim() ? `${l.speaker.trim()}: ` : ''}“${l.text.trim()}”`).join(' ')}
</p>
)}
<p className="line-clamp-2 text-[11px] text-muted-foreground">{shotProse(shot) || 'No description yet'}</p>
</>
);
}
@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { H3ShotStoryboard } from './H3ShotStoryboard';
import { CAMERA_MOVEMENTS, SHOT_TYPES, makeShot, newLine, type Shot, type ShotInit } from '@/lib/h3Shots';
function shot(init: ShotInit): Shot {
return makeShot(init);
}
describe('H3ShotStoryboard', () => {
it('lays shots out in order with type, movement, subjects, and dialogue', () => {
const shots = [
shot({ type: 'establishing', description: 'Wide rooftop.', subjectIds: ['<Subject 1>', '<Subject 2>'] }),
shot({ type: 'close-up', movement: 'handheld', lines: [newLine({ speaker: 'S2', text: 'I had no choice.' }), newLine({ speaker: 'S1', text: 'Then go.' })], subjectIds: ['<Subject 2>'] }),
];
render(<H3ShotStoryboard shots={shots} selectedId={shots[1].id} onSelect={() => {}} onAdd={() => {}} />);
expect(screen.getByText('Establishing')).toBeInTheDocument();
expect(screen.getByText('Close-up')).toBeInTheDocument();
expect(screen.getByText('Handheld')).toBeInTheDocument();
expect(screen.getByText(/S2: “I had no choice.” S1: “Then go.”/)).toBeInTheDocument();
expect(screen.getAllByText('Subj 2')).toHaveLength(2);
const panels = screen.getAllByRole('button').filter((b) => b.hasAttribute('aria-current') || b.textContent?.match(/^\d/));
expect(panels[0]).toHaveTextContent(/^1Establishing/);
expect(screen.getByRole('button', { name: /Close-up/ })).toHaveAttribute('aria-current', 'true');
});
it('selects a shot on click and adds via the trailing tile', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const onAdd = vi.fn();
const shots = [shot({ description: 'First.' }), shot({ description: 'Second.' })];
render(<H3ShotStoryboard shots={shots} selectedId={null} onSelect={onSelect} onAdd={onAdd} />);
await user.click(screen.getByRole('button', { name: /Second\./ }));
expect(onSelect).toHaveBeenCalledWith(shots[1].id);
await user.click(screen.getByRole('button', { name: /Add shot/ }));
expect(onAdd).toHaveBeenCalled();
});
it('renders every shot type and camera movement without crashing', () => {
const shots = SHOT_TYPES.flatMap((t) =>
CAMERA_MOVEMENTS.map((m) => shot({ type: t.id, movement: m.id, subjectIds: ['<Subject 1>'] })),
);
const { container } = render(<H3ShotStoryboard shots={shots} selectedId={null} onSelect={() => {}} onAdd={() => {}} />);
expect(container.querySelectorAll('svg')).toHaveLength(SHOT_TYPES.length * CAMERA_MOVEMENTS.length);
});
});
@@ -0,0 +1,61 @@
'use client';
import * as React from 'react';
import { H3ShotPanel } from '@/components/jobs/H3ShotPanel';
import { formatCutTime, resolveShotStarts, type Shot } from '@/lib/h3Shots';
import { cn } from '@/lib/utils';
export interface H3ShotStoryboardProps {
shots: Shot[];
selectedId: string | null;
onSelect: (id: string) => void;
onAdd: () => void;
}
/**
* The shot list as a storyboard: one panel per shot, left to right in
* playback order. Panels are schematic (no generated imagery exists yet) --
* subject size reflects shot type and an overlay marks camera movement --
* so the sequence, who's in frame, and where the dialogue falls read at a
* glance without scrolling the per-shot editors.
*/
export function H3ShotStoryboard({ shots, selectedId, onSelect, onAdd }: H3ShotStoryboardProps) {
const starts = React.useMemo(() => resolveShotStarts(shots), [shots]);
return (
<div className="flex items-stretch gap-2 overflow-x-auto pb-1" aria-label="Storyboard">
{shots.map((shot, i) => {
const selected = shot.id === selectedId;
return (
<React.Fragment key={shot.id}>
{i > 0 && (
<span className="flex shrink-0 items-center text-muted-foreground/60" aria-hidden>
›
</span>
)}
<button
type="button"
onClick={() => onSelect(shot.id)}
aria-current={selected ? 'true' : undefined}
className={cn(
'flex w-52 shrink-0 flex-col gap-1.5 rounded-xl border bg-card p-2 text-left transition-colors hover:bg-accent/40',
selected ? 'border-accent-blue ring-2 ring-accent-blue/50' : 'border-border',
)}
>
<H3ShotPanel shot={shot} number={i + 1} timeLabel={formatCutTime(starts[i])} />
</button>
</React.Fragment>
);
})}
<button
type="button"
onClick={onAdd}
className="flex min-h-32 w-28 shrink-0 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-border text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
>
<span className="text-lg leading-none">+</span>
Add shot
</button>
</div>
);
}
@@ -0,0 +1,57 @@
'use client';
import * as React from 'react';
import { H3_TAG_CATEGORIES, type H3Tag } from '@/lib/h3Tags';
import { cn } from '@/lib/utils';
export interface H3TagConsoleProps {
/** Called with the tag to insert into whichever field is currently focused. */
onInsert: (tag: H3Tag) => void;
disabled?: boolean;
className?: string;
}
/**
* A microexpression "console" for MiniMax-H3 speech tags: click a tag to
* insert it at the cursor of whichever prompt field was last focused. Kept
* as plain buttons (not a menu) so every tag stays one click away.
*/
export function H3TagConsole({ onInsert, disabled, className }: H3TagConsoleProps) {
return (
<div
className={cn(
'flex flex-col gap-1.5 rounded-xl border border-input bg-secondary/40 p-2',
className,
)}
>
<span className="px-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Speech tags — click to insert at the cursor
</span>
<div className="flex flex-col gap-1.5">
{H3_TAG_CATEGORIES.map((category) => (
<div key={category.id} className="flex flex-wrap items-center gap-1">
<span className="mr-0.5 w-full shrink-0 text-[10px] text-muted-foreground/80 sm:w-auto">
{category.label}
</span>
{category.tags.map((tag) => (
<button
key={tag.id}
type="button"
title={`${tag.description} · e.g. "${tag.example}"`}
// Keep the target textarea focused (and its selection intact)
// so the click lands where the user was typing, not nowhere.
onMouseDown={(e) => e.preventDefault()}
onClick={() => onInsert(tag)}
disabled={disabled}
className="rounded-full border border-input bg-card px-2 py-0.5 font-mono text-[11px] text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{tag.label}
</button>
))}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,74 @@
'use client';
import * as React from 'react';
import { H3_PRESETS, H3_TAG_CATEGORIES, type H3Preset, type H3Tag } from '@/lib/h3Tags';
import { cn } from '@/lib/utils';
// Reuses the app's existing status hues as a purely visual category key --
// no status meaning implied, just four distinguishable accents that already
// work in both themes.
export const CATEGORY_STYLE: Record<string, { chip: string; dot: string }> = {
pace: { chip: 'border-blue-400/35 bg-blue-500/10 text-blue-900 dark:text-blue-100', dot: 'bg-blue-500' },
delivery: { chip: 'border-amber-400/35 bg-amber-500/10 text-amber-900 dark:text-amber-100', dot: 'bg-amber-500' },
vocal: { chip: 'border-emerald-400/35 bg-emerald-500/10 text-emerald-900 dark:text-emerald-100', dot: 'bg-emerald-500' },
body: { chip: 'border-rose-400/35 bg-rose-500/10 text-rose-900 dark:text-rose-100', dot: 'bg-rose-500' },
};
export interface H3TagPaletteProps {
onInsertTag: (tag: H3Tag) => void;
onApplyPreset: (preset: H3Preset) => void;
disabled?: boolean;
className?: string;
}
/** Full speech-tag palette + emotion presets. Buttons keep the target textarea focused so the click lands at its cursor. */
export function H3TagPalette({ onInsertTag, onApplyPreset, disabled, className }: H3TagPaletteProps) {
return (
<div className={className}>
{H3_TAG_CATEGORIES.map((cat) => {
const style = CATEGORY_STYLE[cat.id] ?? CATEGORY_STYLE.body;
return (
<div key={cat.id} className="mb-4">
<div className="mb-1.5 flex items-center gap-2 px-0.5">
<span className={cn('size-2 rounded-full', style.dot)} />
<span className="text-xs font-semibold text-foreground">{cat.label}</span>
</div>
<div className="flex flex-col gap-1">
{cat.tags.map((tag) => (
<button
key={tag.id}
type="button"
title={tag.example}
disabled={disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onInsertTag(tag)}
className="flex items-baseline justify-between gap-2 rounded-lg border border-input bg-card px-2.5 py-1.5 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="font-mono text-[11.5px] text-foreground">{tag.label}</span>
<span className="truncate text-[10.5px] text-muted-foreground">{tag.description}</span>
</button>
))}
</div>
</div>
);
})}
<div className="mb-1.5 mt-5 px-0.5 text-xs font-semibold text-foreground">Emotion presets</div>
<div className="flex flex-wrap gap-1.5">
{H3_PRESETS.map((p) => (
<button
key={p.label}
type="button"
disabled={disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onApplyPreset(p)}
className="rounded-full border border-input bg-card px-2.5 py-1 text-[11.5px] text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{p.label}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,158 @@
import * as React from 'react';
import { flushSync } from 'react-dom';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import CreateJobModal from './CreateJobModal';
import { createJob, getDatasets, getModels } from '@/lib/api';
import { defaultOptionsStore } from '@/stores/defaultOptions';
import { DEFAULT_OPTIONS } from '@/lib/defaultOptions';
vi.mock('sonner', () => ({ toast: { warning: vi.fn(), error: vi.fn(), success: vi.fn() } }));
vi.mock('@/lib/api', () => ({
createJob: vi.fn(),
getModels: vi.fn(),
getDatasets: vi.fn(),
uploadImage: vi.fn(),
getSettings: vi.fn(),
updateSettings: vi.fn(),
}));
const H3 = { id: 'minimax-h3/i2v', label: 'MiniMax H3', type: 'i2v' };
// A browser runs a microtask checkpoint -- so React flushes -- between the
// listeners of a single native event; jsdom's synchronous dispatch never does.
// Radix decides whether a parent dialog should dismiss from the layer stack at
// the moment its listener runs, so without this the bug can't show up here.
const wrapped = new WeakMap<object, EventListener>();
beforeEach(() => {
const realAdd = Document.prototype.addEventListener;
const realRemove = Document.prototype.removeEventListener;
vi.spyOn(document, 'addEventListener').mockImplementation(function (
this: Document,
type: string,
listener: unknown,
options?: unknown,
) {
if (typeof listener === 'function' && ['keydown', 'pointerdown', 'click'].includes(type)) {
const flushing: EventListener = (e) => {
(listener as EventListener)(e);
flushSync(() => {});
};
wrapped.set(listener, flushing);
return realAdd.call(this, type, flushing, options as AddEventListenerOptions);
}
return realAdd.call(this, type, listener as EventListener, options as AddEventListenerOptions);
} as typeof document.addEventListener);
vi.spyOn(document, 'removeEventListener').mockImplementation(function (
this: Document,
type: string,
listener: unknown,
options?: unknown,
) {
return realRemove.call(
this,
type,
(wrapped.get(listener as object) ?? listener) as EventListener,
options as EventListenerOptions,
);
} as typeof document.removeEventListener);
defaultOptionsStore.set({ options: DEFAULT_OPTIONS });
vi.mocked(getModels).mockResolvedValue([H3]);
vi.mocked(getDatasets).mockResolvedValue([]);
vi.mocked(createJob).mockResolvedValue({ id: 'j' } as never);
});
function Harness() {
const [open, setOpen] = React.useState(true);
return (
<>
<div data-testid="state">{open ? 'open' : 'closed'}</div>
<CreateJobModal
isOpen={open}
onClose={() => setOpen(false)}
onSuccess={() => {}}
jobType="inference"
workloadType="i2v"
/>
</>
);
}
type User = ReturnType<typeof userEvent.setup>;
type Closer = (user: User, dialog: HTMLElement) => Promise<void>;
async function renderH3Modal(user: User) {
render(<Harness />);
await screen.findByRole('option', { name: /MiniMax H3/ });
await user.selectOptions(screen.getByLabelText('Model'), H3.id);
}
// Radix marks every dialog under the topmost one aria-hidden, which also hides its
// accessible name, so the job dialog is identified by its title text instead.
const expectParentStillOpen = () => {
expect(screen.getByTestId('state')).toHaveTextContent('open');
expect(screen.getByText(/New Inference Job/)).toBeInTheDocument();
};
// [0] is the job dialog's overlay; the last is the topmost dialog's.
const topOverlay = () => [...document.querySelectorAll('.bg-black\\/70')].at(-1) as Element;
const CLOSERS: [string, Closer][] = [
['Cancel', (u, d) => u.click(within(d).getByRole('button', { name: 'Cancel' }))],
['the X button', (u, d) => u.click(within(d).getByRole('button', { name: 'Close' }))],
['Escape', (u) => u.keyboard('{Escape}')],
['a click on the overlay', (u) => u.click(topOverlay())],
];
describe('closing a dialog opened from the job form', () => {
describe.each([
['shot list', 'Open shot list', 'Shot list', [...CLOSERS, ['Apply', (u: User, d: HTMLElement) => u.click(within(d).getByRole('button', { name: 'Apply' }))] as [string, Closer]]],
['dialogue editor', 'Open dialogue editor', 'Dialogue editor', [...CLOSERS, ['Apply to field', (u: User, d: HTMLElement) => u.click(within(d).getByRole('button', { name: 'Apply to field' }))] as [string, Closer]]],
] as const)('%s', (_label, openButton, title, closers) => {
it.each(closers)('closing with %s leaves the job form open', async (_name, close) => {
const user = userEvent.setup();
await renderH3Modal(user);
await user.click(await screen.findByRole('button', { name: openButton }));
const child = await screen.findByRole('dialog', { name: title });
await close(user, child);
await waitFor(() => expect(screen.queryByRole('dialog', { name: title })).not.toBeInTheDocument());
expectParentStillOpen();
});
});
it('the job form is still usable afterwards', async () => {
const user = userEvent.setup();
await renderH3Modal(user);
await user.click(await screen.findByRole('button', { name: 'Open shot list' }));
const child = await screen.findByRole('dialog', { name: 'Shot list' });
await user.click(within(child).getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Shot list' })).not.toBeInTheDocument());
await user.type(screen.getByLabelText('Name (optional)'), 'still here');
expect(screen.getByLabelText('Name (optional)')).toHaveValue('still here');
// ...and the dialog can be opened again
await user.click(screen.getByRole('button', { name: 'Open shot list' }));
expect(await screen.findByRole('dialog', { name: 'Shot list' })).toBeInTheDocument();
});
describe('the script import inside the shot list', () => {
it.each(CLOSERS.slice(0, 4))('closing it with %s leaves the shot list and the job form open', async (_name, close) => {
const user = userEvent.setup();
await renderH3Modal(user);
await user.click(await screen.findByRole('button', { name: 'Open shot list' }));
const shotList = await screen.findByRole('dialog', { name: 'Shot list' });
await user.click(within(shotList).getByRole('button', { name: 'Import script…' }));
const importDialog = await screen.findByRole('dialog', { name: 'Import script' });
await close(user, importDialog);
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Import script' })).not.toBeInTheDocument());
expect(screen.getByRole('dialog', { name: 'Shot list' })).toBeInTheDocument();
expectParentStillOpen();
});
});
});
@@ -0,0 +1,253 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { H3ShotListEditor } from './H3ShotListEditor';
const TWO_SHOTS = '[Shot 1] Wide shot. A dock.\n[Shot 2] At 00:02.000, the shot cuts to a close-up shot. A face.';
const WITH_LINE = '[Shot 1] Wide shot. (S1) <d>[English] Hi.</d>';
type User = ReturnType<typeof userEvent.setup>;
function setup(detailed: string, extra: { open?: boolean } = {}) {
const onApply = vi.fn();
const ui = (open: boolean) => (
<H3ShotListEditor
open={open}
subjectDefinitions="<Subject 1> is a man."
initialDetailedDescription={detailed}
initialRetentionAnalysis=""
onApply={onApply}
onClose={() => {}}
/>
);
const view = render(ui(extra.open ?? true));
return { onApply, view, ui };
}
const undoButton = () => screen.getByRole('button', { name: /Undo/ });
const redoButton = () => screen.getByRole('button', { name: /Redo/ });
const applied = (onApply: ReturnType<typeof vi.fn>) => onApply.mock.calls.at(-1)?.[0] as string;
const apply = (user: User) => user.click(screen.getByRole('button', { name: 'Apply' }));
const lineBox = () => screen.getByPlaceholderText('What the speaker says') as HTMLTextAreaElement;
const ctrl = (key: string) => `{Control>}${key}{/Control}`;
describe('shot list undo/redo', () => {
it('starts with nothing to undo or redo', () => {
setup(TWO_SHOTS);
expect(undoButton()).toBeDisabled();
expect(redoButton()).toBeDisabled();
});
it('undoes and redoes moving a shot', async () => {
const user = userEvent.setup();
const { onApply } = setup(TWO_SHOTS);
await user.click(screen.getAllByRole('button', { name: '↑' })[1]);
expect(undoButton()).toBeEnabled();
await user.click(undoButton());
expect(redoButton()).toBeEnabled();
await apply(user);
expect(applied(onApply)).toBe(TWO_SHOTS);
});
it('redo reapplies what was undone', async () => {
const user = userEvent.setup();
const { onApply } = setup(TWO_SHOTS);
await user.click(screen.getAllByRole('button', { name: '↑' })[1]);
await user.click(undoButton());
await user.click(redoButton());
await apply(user);
expect(applied(onApply).startsWith('[Shot 1] Close-up shot. A face.')).toBe(true);
});
it('undoes removing a shot, dialogue and all', async () => {
const user = userEvent.setup();
const { onApply } = setup(`${WITH_LINE}\n[Shot 2] At 00:02.500, the shot cuts to a wide shot. B.`);
await user.click(screen.getAllByRole('button', { name: 'Remove' })[0]);
expect(screen.queryByPlaceholderText('What the speaker says')).not.toBeInTheDocument();
await user.click(undoButton());
expect(lineBox().value).toBe('Hi.');
await apply(user);
expect(applied(onApply)).toContain('(S1) <d>[English] Hi.</d>');
});
it('undoes an add, a type change, a subject toggle, and a cut time in turn', async () => {
const user = userEvent.setup();
const { onApply } = setup(TWO_SHOTS);
await user.click(screen.getAllByRole('button', { name: /Add shot/ })[0]);
await user.selectOptions(screen.getAllByLabelText('Shot type')[0], 'pov');
await user.click(screen.getAllByRole('button', { name: '<Subject 1>' })[0]);
const cut = screen.getAllByPlaceholderText(/\d\d:\d\d\.\d{3}/)[0] as HTMLInputElement;
await user.type(cut, '5{Enter}');
// four discrete steps, undone one at a time
for (let i = 0; i < 4; i += 1) await user.click(undoButton());
expect(undoButton()).toBeDisabled();
await apply(user);
expect(applied(onApply)).toBe(TWO_SHOTS);
});
it('committing a cut time that has not changed is not a step', async () => {
const user = userEvent.setup();
setup(TWO_SHOTS);
const cut = screen.getAllByPlaceholderText(/\d\d:\d\d\.\d{3}/)[0] as HTMLInputElement;
await user.type(cut, '5{Enter}');
await user.click(undoButton()); // the click blurs the box, which re-commits 00:05.000
expect(undoButton()).toBeDisabled();
});
it('a burst of typing is one step', async () => {
const user = userEvent.setup();
const { onApply } = setup(WITH_LINE);
await user.type(lineBox(), ' there my friend');
expect(lineBox().value).toBe('Hi. there my friend');
await user.click(undoButton());
expect(lineBox().value).toBe('Hi.');
expect(undoButton()).toBeDisabled();
await apply(user);
expect(applied(onApply)).toContain('<d>[English] Hi.</d>');
});
it('a tag inserted from the palette is its own step, separate from typing', async () => {
const user = userEvent.setup();
setup(WITH_LINE);
await user.type(lineBox(), ' there');
lineBox().setSelectionRange(lineBox().value.length, lineBox().value.length);
await user.click(screen.getByRole('button', { name: /<pause>/ }));
expect(lineBox().value).toBe('Hi. there <pause>');
await user.click(undoButton());
expect(lineBox().value).toBe('Hi. there');
await user.click(undoButton());
expect(lineBox().value).toBe('Hi.');
});
it('undoes edits to the description and the scene notes', async () => {
const user = userEvent.setup();
const { onApply } = setup('[Shot 1] Wide shot. A dock.');
await user.type(screen.getByPlaceholderText(/Live-action, cinematic/), 'Noir.');
await user.type(screen.getByPlaceholderText("What's happening in this shot"), ' At dawn.');
await user.click(undoButton());
await user.click(undoButton());
expect(undoButton()).toBeDisabled();
await apply(user);
expect(applied(onApply)).toBe('[Shot 1] Wide shot. A dock.');
});
it('typing in different boxes makes separate steps', async () => {
const user = userEvent.setup();
setup('[Shot 1] Wide shot. A dock.');
await user.type(screen.getByPlaceholderText(/Live-action, cinematic/), 'Noir.');
await user.type(screen.getByPlaceholderText("What's happening in this shot"), ' At dawn.');
await user.click(undoButton());
expect((screen.getByPlaceholderText("What's happening in this shot") as HTMLTextAreaElement).value).toBe('A dock.');
expect((screen.getByPlaceholderText(/Live-action, cinematic/) as HTMLTextAreaElement).value).toBe('Noir.');
});
it('a new edit clears what could be redone', async () => {
const user = userEvent.setup();
setup(TWO_SHOTS);
await user.click(screen.getAllByRole('button', { name: '↑' })[1]);
await user.click(undoButton());
expect(redoButton()).toBeEnabled();
await user.click(screen.getAllByRole('button', { name: /Add shot/ })[0]);
expect(redoButton()).toBeDisabled();
});
});
describe('shot list undo/redo shortcuts', () => {
it('Ctrl+Z from inside a textbox undoes the whole list, not just that box', async () => {
const user = userEvent.setup();
const { onApply } = setup(TWO_SHOTS);
await user.click(screen.getAllByRole('button', { name: '↑' })[1]); // move a shot
// focus a different textbox, then undo from there
const box = screen.getAllByPlaceholderText("What's happening in this shot")[0] as HTMLTextAreaElement;
await user.click(box);
await user.keyboard(ctrl('z'));
expect(undoButton()).toBeDisabled();
await apply(user);
expect(applied(onApply)).toBe(TWO_SHOTS);
});
it('redoes with Ctrl+Shift+Z and with Ctrl+Y', async () => {
const user = userEvent.setup();
setup(TWO_SHOTS);
await user.click(screen.getAllByRole('button', { name: '↑' })[1]);
const box = screen.getAllByPlaceholderText("What's happening in this shot")[0];
await user.click(box);
await user.keyboard(ctrl('z'));
expect(redoButton()).toBeEnabled();
await user.keyboard('{Control>}{Shift>}z{/Shift}{/Control}');
expect(redoButton()).toBeDisabled();
await user.keyboard(ctrl('z'));
await user.keyboard(ctrl('y'));
expect(redoButton()).toBeDisabled();
expect(undoButton()).toBeEnabled();
});
it('Cmd+Z works too', async () => {
const user = userEvent.setup();
setup(TWO_SHOTS);
await user.click(screen.getAllByRole('button', { name: '↑' })[1]);
await user.click(screen.getAllByPlaceholderText("What's happening in this shot")[0]);
await user.keyboard('{Meta>}z{/Meta}');
expect(undoButton()).toBeDisabled();
});
it('leaves the script import box to its own undo', async () => {
const user = userEvent.setup();
const { onApply } = setup(TWO_SHOTS);
await user.click(screen.getAllByRole('button', { name: '↑' })[1]);
expect(undoButton()).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Import script…' }));
const dialog = await screen.findByRole('dialog', { name: 'Import script' });
const box = within(dialog).getByLabelText('Script');
await user.click(box);
await user.keyboard(ctrl('z'));
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
// the shot list's history was untouched: the move is still there to undo
await user.click(await screen.findByRole('button', { name: /Undo/ }));
await apply(user);
expect(applied(onApply)).toBe(TWO_SHOTS);
});
});
describe('shot list history across imports and reopening', () => {
it('loading a script clip is one undoable step', async () => {
const user = userEvent.setup();
const { onApply } = setup(TWO_SHOTS);
await user.click(screen.getByRole('button', { name: 'Import script…' }));
const dialog = await screen.findByRole('dialog', { name: 'Import script' });
await user.click(within(dialog).getByLabelText('Script'));
await user.paste('CAPTAIN: Sit down and hear me out.\n\nCLERK: Yes, sir.');
await user.click(within(dialog).getByRole('button', { name: 'Load clip' }));
await screen.findAllByPlaceholderText('What the speaker says');
await user.click(undoButton());
expect(screen.queryByPlaceholderText('What the speaker says')).not.toBeInTheDocument();
await apply(user);
expect(applied(onApply)).toBe(TWO_SHOTS);
});
it('opening the editor again starts a fresh history', async () => {
const user = userEvent.setup();
const { view, ui } = setup(TWO_SHOTS);
await user.click(screen.getAllByRole('button', { name: '↑' })[1]);
expect(undoButton()).toBeEnabled();
view.rerender(ui(false));
view.rerender(ui(true));
expect(await screen.findByRole('button', { name: /Undo/ })).toBeDisabled();
});
});
@@ -0,0 +1,249 @@
'use client';
import * as React from 'react';
import { H3ShotPanel } from '@/components/jobs/H3ShotPanel';
import { Badge, type BadgeProps } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { NativeSelect } from '@/components/ui/native-select';
import { getJobVideoUrl } from '@/lib/api';
import { formatCutTime } from '@/lib/h3Shots';
import { formatClock, type Scene, type SceneClip } from '@/lib/scenes';
import type { Job } from '@/lib/types';
import { cn } from '@/lib/utils';
const BADGE_VARIANTS: Record<string, BadgeProps['variant']> = {
pending: 'secondary',
running: 'warning',
completed: 'success',
failed: 'destructive',
stopped: 'secondary',
};
const SEGMENT_TONE: Record<string, string> = {
completed: 'bg-emerald-500/70',
running: 'bg-amber-500/70',
failed: 'bg-rose-500/70',
};
/** Statuses a job can still be edited in (mirrors the Jobs page). */
export const EDITABLE_STATUSES = ['pending', 'failed', 'stopped'];
function clipLabel(clip: SceneClip): string {
return clip.job.name?.trim() || `Job ${clip.job.id.slice(0, 8)}`;
}
function takeLabel(job: Job, index: number, total: number): string {
const ms = job.created_at < 1e12 ? job.created_at * 1000 : job.created_at;
const when = new Date(ms).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return `Take ${index + 1} of ${total} · ${job.status} · ${when}`;
}
function scrollToClip(id: string) {
document.getElementById(`clip-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function Timeline({ scene }: { scene: Scene }) {
return (
<div className="flex h-4 w-full gap-px overflow-hidden rounded-md" role="group" aria-label="Scene timeline">
{scene.clips.map((clip) => (
<button
key={clip.job.id}
type="button"
onClick={() => scrollToClip(clip.job.id)}
aria-label={`Go to clip ${clip.position}: ${clipLabel(clip)}`}
title={`Clip ${clip.position} · ${clipLabel(clip)} · ${clip.job.status} · ${formatClock(clip.startSeconds)}–${formatClock(clip.startSeconds + clip.durationSeconds)}`}
style={{ flexGrow: Math.max(clip.durationSeconds, 0.1), flexBasis: 0 }}
className={cn(
'min-w-[3px] transition-opacity hover:opacity-70',
SEGMENT_TONE[clip.job.status] ?? 'bg-muted-foreground/40',
)}
/>
))}
</div>
);
}
function ClipPreview({ job }: { job: Job }) {
const isImage = job.output_path?.toLowerCase().endsWith('.png') ?? false;
const src = getJobVideoUrl(job.id);
return isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={job.name ?? 'Generated output'} className="max-h-56 rounded-md border border-border" />
) : (
<video
src={src}
aria-label={`Generated video for ${job.name ?? job.id}`}
className="max-h-56 rounded-md border border-border bg-black"
controls
muted
loop
playsInline
preload="metadata"
/>
);
}
function ClipRow({
clip,
firstShotNumber,
onOpenJob,
onChooseTake,
}: {
clip: SceneClip;
firstShotNumber: number;
onOpenJob: (job: Job) => void;
onChooseTake: (takeKey: string, jobId: string) => void;
}) {
const [previewing, setPreviewing] = React.useState(false);
const { job } = clip;
const canPreview = job.status === 'completed' && !!job.output_path;
const editable = EDITABLE_STATUSES.includes(job.status);
return (
<li id={`clip-${job.id}`} className="scroll-mt-32 rounded-xl border border-border bg-card p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary text-xs font-semibold text-foreground">
{clip.position}
</span>
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">{clipLabel(clip)}</h3>
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>{job.status}</Badge>
<span className="font-mono text-xs text-muted-foreground">
{formatClock(clip.startSeconds)}–{formatClock(clip.startSeconds + clip.durationSeconds)}
</span>
{clip.continuation && (
<Badge variant="outline" className="normal-case tracking-normal">
continues previous shot
</Badge>
)}
<span className="ml-auto flex items-center gap-2">
{clip.takeKey && clip.takes.length > 1 && (
<NativeSelect
aria-label={`Take for clip ${clip.position}`}
value={job.id}
onChange={(e) => onChooseTake(clip.takeKey as string, e.target.value)}
className="h-8 w-auto py-0 text-xs"
>
{clip.takes.map((take, i) => (
<option key={take.id} value={take.id}>
{takeLabel(take, i, clip.takes.length)}
</option>
))}
</NativeSelect>
)}
{canPreview && (
<Button type="button" size="sm" variant="outline" onClick={() => setPreviewing((p) => !p)}>
{previewing ? 'Hide video' : 'Preview'}
</Button>
)}
<Button type="button" size="sm" variant="outline" onClick={() => onOpenJob(job)}>
{editable ? 'Edit' : 'View'}
</Button>
</span>
</div>
{previewing && canPreview && (
<div className="mb-2">
<ClipPreview job={job} />
</div>
)}
{clip.shots.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-3 text-sm text-muted-foreground">
This job&apos;s prompt has no shots.
</p>
) : (
<div className="flex items-stretch gap-2 overflow-x-auto pb-1" aria-label={`Shots in ${clipLabel(clip)}`}>
{clip.shots.map((shot, i) => (
<div
key={shot.id}
className="flex w-52 shrink-0 flex-col gap-1.5 rounded-xl border border-border bg-background p-2"
>
<H3ShotPanel
shot={shot}
number={firstShotNumber + i}
timeLabel={formatCutTime(clip.shotStarts[i])}
/>
</div>
))}
</div>
)}
</li>
);
}
export interface SceneBoardProps {
scene: Scene;
onOpenJob: (job: Job) => void;
/** pick which re-run of a clip number to show */
onChooseTake: (takeKey: string, jobId: string) => void;
}
/** One scene: a timeline of its clips, then every clip's shots in order, numbered straight through. */
export function SceneBoard({ scene, onOpenJob, onChooseTake }: SceneBoardProps) {
let shotNumber = 1;
const statuses = Object.entries(scene.statusCounts);
// The header stays where it is until it reaches the top of the scrolling area,
// then sticks there. A 1px sentinel sitting just above it tells us which: once
// the sentinel has scrolled out of view, the header is stuck.
const sentinelRef = React.useRef<HTMLDivElement>(null);
const [stuck, setStuck] = React.useState(false);
React.useEffect(() => {
const el = sentinelRef.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(([entry]) => setStuck(!entry.isIntersecting));
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<section aria-label={scene.title} className="flex flex-col gap-4">
<div ref={sentinelRef} aria-hidden className="-mb-4 h-px" />
<div
data-stuck={stuck}
className={cn(
'sticky top-0 z-30 -mx-3 flex flex-col gap-2 bg-card px-3 pb-3 pt-2 transition-shadow',
stuck && 'border-b border-border shadow-md',
)}
>
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<h2 className="text-xl font-semibold text-foreground">{scene.title}</h2>
<p className="text-sm text-muted-foreground">
{scene.clips.length} clip{scene.clips.length === 1 ? '' : 's'} · {scene.shotCount} shot
{scene.shotCount === 1 ? '' : 's'} · about {formatClock(scene.totalSeconds)}
</p>
<span className="flex flex-wrap gap-1.5">
{statuses.map(([status, count]) => (
<Badge key={status} variant={BADGE_VARIANTS[status] ?? 'secondary'}>
{count} {status}
</Badge>
))}
</span>
</div>
<Timeline scene={scene} />
</div>
<ol className="flex list-none flex-col gap-3 p-0">
{scene.clips.map((clip) => {
const first = shotNumber;
shotNumber += clip.shots.length;
return (
<ClipRow
key={clip.takeKey ?? clip.job.id}
clip={clip}
firstShotNumber={first}
onOpenJob={onOpenJob}
onChooseTake={onChooseTake}
/>
);
})}
</ol>
</section>
);
}
@@ -13,6 +13,7 @@ const TAB_TITLES: Record<string, string> = {
'/distillation': 'Jobs',
'/datasets': 'Datasets',
'/gallery': 'Gallery',
'/scenes': 'Scenes',
'/gpus': 'GPUs',
'/settings': 'Settings',
};
@@ -150,6 +150,14 @@ export default function PrimarySidebar({
>
Datasets
</Link>
<Link
href="/scenes"
aria-current={pathname === '/scenes' ? 'page' : undefined}
onClick={onMobileClose}
className={cn(TAB_BASE, pathname === '/scenes' && TAB_ACTIVE)}
>
Scenes
</Link>
<Link
href="/gallery"
aria-current={pathname === '/gallery' ? 'page' : undefined}
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
// The app header is `fixed` at z-[100] (components/shell/Header.tsx).
const HEADER_Z = 100;
function zOf(el: Element | null): number {
const m = el?.className.match(/\bz-\[(\d+)\]/);
return m ? Number(m[1]) : 0;
}
describe('Dialog stacking', () => {
it('renders its overlay and content above the fixed app header', () => {
render(
<Dialog open>
<DialogContent>
<DialogTitle>Tall dialog</DialogTitle>
<DialogDescription>Body</DialogDescription>
</DialogContent>
</Dialog>,
);
const content = screen.getByRole('dialog');
const overlay = document.querySelector('[data-state="open"].bg-black\\/70');
expect(zOf(content)).toBeGreaterThan(HEADER_Z);
expect(zOf(overlay)).toBeGreaterThan(HEADER_Z);
});
});
@@ -11,6 +11,8 @@ const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
// Dialogs sit above the app header (z-[100], fixed). Below it, a tall dialog's
// top edge slides under the header and, being fixed itself, can't be scrolled clear.
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
@@ -18,7 +20,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/70 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-[110] bg-black/70 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
@@ -36,7 +38,7 @@ const DialogContent = React.forwardRef<
ref={ref}
data-modal=""
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-border bg-card p-6 text-card-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed left-1/2 top-1/2 z-[110] grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-border bg-card p-6 text-card-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
@@ -0,0 +1,140 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { COALESCE_MS, useUndoableState } from './useUndoableState';
describe('useUndoableState', () => {
let now = 1_000_000;
beforeEach(() => {
now = 1_000_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
});
afterEach(() => vi.restoreAllMocks());
const tick = (ms: number) => {
now += ms;
};
it('makes each unkeyed change its own step', () => {
const { result } = renderHook(() => useUndoableState({ n: 0 }));
act(() => result.current.set({ n: 1 }));
act(() => result.current.set({ n: 2 }));
expect(result.current.state.n).toBe(2);
act(() => void result.current.undo());
expect(result.current.state.n).toBe(1);
act(() => void result.current.undo());
expect(result.current.state.n).toBe(0);
expect(result.current.canUndo).toBe(false);
});
it('merges same-key edits made close together into one step', () => {
const { result } = renderHook(() => useUndoableState('a'));
act(() => result.current.set('ab', 'text'));
tick(100);
act(() => result.current.set('abc', 'text'));
tick(100);
act(() => result.current.set('abcd', 'text'));
act(() => void result.current.undo());
expect(result.current.state).toBe('a');
expect(result.current.canUndo).toBe(false);
});
it('starts a new step after a pause, or when the key changes', () => {
const { result } = renderHook(() => useUndoableState('a'));
act(() => result.current.set('ab', 'text'));
tick(COALESCE_MS + 1);
act(() => result.current.set('abc', 'text'));
act(() => result.current.set('abcX', 'other'));
act(() => void result.current.undo());
expect(result.current.state).toBe('abc');
act(() => void result.current.undo());
expect(result.current.state).toBe('ab');
act(() => void result.current.undo());
expect(result.current.state).toBe('a');
});
it('never merges an unkeyed change into typing, or typing into it', () => {
const { result } = renderHook(() => useUndoableState('a'));
act(() => result.current.set('ab', 'text'));
act(() => result.current.set('AB'));
act(() => result.current.set('ABc', 'text'));
act(() => void result.current.undo());
expect(result.current.state).toBe('AB');
act(() => void result.current.undo());
expect(result.current.state).toBe('ab');
});
it('typing after an undo begins a fresh step instead of merging into the undone one', () => {
const { result } = renderHook(() => useUndoableState('a'));
act(() => result.current.set('ab', 'text'));
act(() => void result.current.undo());
act(() => result.current.set('ax', 'text'));
expect(result.current.canUndo).toBe(true);
act(() => void result.current.undo());
expect(result.current.state).toBe('a');
});
it('redoes, and a new change clears what could be redone', () => {
const { result } = renderHook(() => useUndoableState(0));
act(() => result.current.set(1));
act(() => result.current.set(2));
act(() => void result.current.undo());
act(() => void result.current.undo());
expect(result.current.canRedo).toBe(true);
act(() => void result.current.redo());
expect(result.current.state).toBe(1);
act(() => result.current.set(9));
expect(result.current.canRedo).toBe(false);
expect(result.current.redo()).toBe(false);
});
it('returns false when there is nothing to undo or redo', () => {
const { result } = renderHook(() => useUndoableState(0));
expect(result.current.undo()).toBe(false);
expect(result.current.redo()).toBe(false);
});
it('chains functional updates made in the same event', () => {
const { result } = renderHook(() => useUndoableState(0));
act(() => {
result.current.set((n) => n + 1);
result.current.set((n) => n + 1);
});
expect(result.current.state).toBe(2);
act(() => void result.current.undo());
expect(result.current.state).toBe(1);
});
it('ignores a set that produces the same value', () => {
const { result } = renderHook(() => useUndoableState('x'));
act(() => result.current.set('x'));
expect(result.current.canUndo).toBe(false);
});
it('reset replaces the value and drops all history', () => {
const { result } = renderHook(() => useUndoableState(0));
act(() => result.current.set(1));
act(() => void result.current.undo());
act(() => result.current.reset(7));
expect(result.current.state).toBe(7);
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});
it('keeps at most 100 steps', () => {
const { result } = renderHook(() => useUndoableState(0));
for (let i = 1; i <= 130; i += 1) act(() => result.current.set(i));
let undone = 0;
while (result.current.canUndo) {
act(() => void result.current.undo());
undone += 1;
}
expect(undone).toBe(100);
expect(result.current.state).toBe(30);
});
});
@@ -0,0 +1,86 @@
'use client';
import * as React from 'react';
const MAX_HISTORY = 100;
// Edits made under the same key, this close together, merge into one undo step.
export const COALESCE_MS = 500;
interface Snapshot<T> {
present: T;
past: T[];
future: T[];
}
/**
* Undo/redo over a whole state value. Every `set` is one undo step, except
* that consecutive sets with the same `key` inside COALESCE_MS merge -- pass a
* key for typing so a burst is one step, and none for discrete changes (moves,
* adds, selects) so each is its own. Values are treated as immutable, so
* snapshots share structure and are cheap to keep.
*/
export function useUndoableState<T>(initial: T) {
const [snapshot, setSnapshot] = React.useState<Snapshot<T>>({ present: initial, past: [], future: [] });
// Mirrors the snapshot synchronously so several sets in one event chain correctly.
const ref = React.useRef(snapshot);
const last = React.useRef<{ key: string | null; at: number }>({ key: null, at: 0 });
const apply = React.useCallback((next: Snapshot<T>) => {
ref.current = next;
setSnapshot(next);
}, []);
const set = React.useCallback(
(update: T | ((prev: T) => T), key: string | null = null) => {
const cur = ref.current;
const next = typeof update === 'function' ? (update as (prev: T) => T)(cur.present) : update;
if (Object.is(next, cur.present)) return;
const now = Date.now();
const merges = key !== null && last.current.key === key && now - last.current.at < COALESCE_MS;
last.current = { key, at: now };
if (merges) {
apply({ ...cur, present: next });
} else {
apply({ present: next, past: [...cur.past.slice(-(MAX_HISTORY - 1)), cur.present], future: [] });
}
},
[apply],
);
const undo = React.useCallback(() => {
const cur = ref.current;
if (cur.past.length === 0) return false;
last.current = { key: null, at: 0 };
apply({ present: cur.past[cur.past.length - 1], past: cur.past.slice(0, -1), future: [cur.present, ...cur.future] });
return true;
}, [apply]);
const redo = React.useCallback(() => {
const cur = ref.current;
if (cur.future.length === 0) return false;
last.current = { key: null, at: 0 };
apply({ present: cur.future[0], past: [...cur.past, cur.present], future: cur.future.slice(1) });
return true;
}, [apply]);
/** Replace the value and forget all history (e.g. when an editor reopens). */
const reset = React.useCallback(
(value: T) => {
last.current = { key: null, at: 0 };
apply({ present: value, past: [], future: [] });
},
[apply],
);
return {
state: snapshot.present,
set,
undo,
redo,
reset,
canUndo: snapshot.past.length > 0,
canRedo: snapshot.future.length > 0,
};
}
@@ -0,0 +1,84 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useUndoableText } from './useUndoableText';
describe('useUndoableText', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('coalesces a typing burst into one undo step', () => {
const { result } = renderHook(() => useUndoableText('a'));
act(() => {
result.current.type('ab');
result.current.type('abc');
});
act(() => {
vi.advanceTimersByTime(600);
});
expect(result.current.canUndo).toBe(true);
let restored: string | null = null;
act(() => {
restored = result.current.undo();
});
expect(restored).toBe('a');
expect(result.current.text).toBe('a');
expect(result.current.canUndo).toBe(false);
});
it('gives a committed edit its own step and supports redo', () => {
const { result } = renderHook(() => useUndoableText('hi'));
act(() => result.current.commit('hi <pause>'));
expect(result.current.text).toBe('hi <pause>');
act(() => {
result.current.undo();
});
expect(result.current.text).toBe('hi');
expect(result.current.canRedo).toBe(true);
let redone: string | null = null;
act(() => {
redone = result.current.redo();
});
expect(redone).toBe('hi <pause>');
expect(result.current.text).toBe('hi <pause>');
});
it('flushes pending typing before a commit so undo steps stay separate', () => {
const { result } = renderHook(() => useUndoableText(''));
act(() => result.current.type('hello'));
act(() => result.current.commit('hello <pause>'));
act(() => {
result.current.undo();
});
expect(result.current.text).toBe('hello');
act(() => {
result.current.undo();
});
expect(result.current.text).toBe('');
});
it('returns null when there is nothing to undo or redo', () => {
const { result } = renderHook(() => useUndoableText('x'));
let undone: string | null = 'sentinel';
let redone: string | null = 'sentinel';
act(() => {
undone = result.current.undo();
redone = result.current.redo();
});
expect(undone).toBeNull();
expect(redone).toBeNull();
});
it('reset replaces the text and drops history', () => {
const { result } = renderHook(() => useUndoableText('a'));
act(() => result.current.commit('b'));
act(() => result.current.reset('fresh'));
expect(result.current.text).toBe('fresh');
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});
});
@@ -0,0 +1,109 @@
'use client';
import * as React from 'react';
const MAX_HISTORY = 100;
// How long a pause in typing has to be before it becomes its own undo step.
// Keeps undo from removing text one keystroke at a time while still giving
// each tag/preset insertion (the actual ask) a guaranteed, immediate step.
const TYPING_COALESCE_MS = 500;
/**
* Text state with undo/redo. Typing is coalesced into bursts; discrete edits
* (`commit`, e.g. a tag insertion) always get their own step. `textRef`
* mirrors `text` synchronously because history bookkeeping needs the true
* current value the instant an edit happens, not whatever the last render
* closed over.
*/
export function useUndoableText(initial: string) {
const [text, setTextState] = React.useState(initial);
const textRef = React.useRef(initial);
const [past, setPast] = React.useState<string[]>([]);
const [future, setFuture] = React.useState<string[]>([]);
// Start of the in-progress typing burst that hasn't become a history step
// yet; flushed into `past` once typing pauses or a discrete edit happens.
const typingBaseRef = React.useRef<string | null>(null);
const typingTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const setText = React.useCallback((next: string) => {
textRef.current = next;
setTextState(next);
}, []);
const pushHistory = React.useCallback((previous: string) => {
setPast((p) => (p.length >= MAX_HISTORY ? [...p.slice(1), previous] : [...p, previous]));
setFuture([]);
}, []);
const clearTypingTimer = React.useCallback(() => {
if (typingTimerRef.current) {
clearTimeout(typingTimerRef.current);
typingTimerRef.current = null;
}
}, []);
const flushTypingCheckpoint = React.useCallback(() => {
clearTypingTimer();
if (typingBaseRef.current != null && typingBaseRef.current !== textRef.current) {
pushHistory(typingBaseRef.current);
}
typingBaseRef.current = null;
}, [clearTypingTimer, pushHistory]);
/** Free typing: coalesced into one undo step per burst. */
const type = React.useCallback(
(next: string) => {
if (typingBaseRef.current == null) typingBaseRef.current = textRef.current;
setText(next);
clearTypingTimer();
typingTimerRef.current = setTimeout(flushTypingCheckpoint, TYPING_COALESCE_MS);
},
[setText, clearTypingTimer, flushTypingCheckpoint],
);
/** Discrete edit (tag/preset insertion): always its own undo step. */
const commit = React.useCallback(
(next: string) => {
flushTypingCheckpoint();
pushHistory(textRef.current);
setText(next);
},
[flushTypingCheckpoint, pushHistory, setText],
);
/** Returns the restored text (so the caller can place the caret), or null if nothing to undo. */
function undo(): string | null {
flushTypingCheckpoint();
if (past.length === 0) return null;
const prev = past[past.length - 1];
setPast(past.slice(0, -1));
setFuture([...future, textRef.current]);
setText(prev);
return prev;
}
function redo(): string | null {
if (future.length === 0) return null;
const next = future[future.length - 1];
setFuture(future.slice(0, -1));
setPast([...past, textRef.current]);
setText(next);
return next;
}
/** Replace the text and drop all history (e.g. when an editor is reopened). */
const reset = React.useCallback(
(value: string) => {
setText(value);
setPast([]);
setFuture([]);
typingBaseRef.current = null;
clearTypingTimer();
},
[setText, clearTypingTimer],
);
React.useEffect(() => clearTypingTimer, [clearTypingTimer]);
return { text, type, commit, undo, redo, reset, canUndo: past.length > 0, canRedo: future.length > 0 };
}
@@ -0,0 +1,171 @@
/**
* Ballpark spoken-duration estimate for MiniMax-H3 dialogue text, plus the
* lexer/tree used to render a styled "performance preview" of the tags.
*
* Numbers here are heuristic (syllable counting + fixed per-tag seconds from
* `H3_TAGS[].seconds`), not measured against the model -- good enough to
* flag "this clip is clearly too long for one generation," calibrated
* against a real render via `calibrationFactor`.
*/
import { H3_TAGS, type H3Tag } from "@/lib/h3Tags";
/** Bare tag name, e.g. "<long pause>" -> "long pause". */
function bareName(bracketed: string): string {
return bracketed.replace(/[<>/]/g, "").trim();
}
const WRAP_TAG_NAMES = new Set(H3_TAGS.filter((t) => t.kind === "wrap").map((t) => bareName(t.open)));
const TAG_SECONDS: Record<string, number> = Object.fromEntries(
H3_TAGS.map((t) => [bareName(t.open), t.seconds ?? 0]),
);
/** Bare tag name -> its registry entry, so callers (the preview renderer) can look up category/description. */
export const TAG_BY_NAME: Record<string, H3Tag> = Object.fromEntries(H3_TAGS.map((t) => [bareName(t.open), t]));
/* -------------------------------------------------------------------- */
/* Lexer / tree — mirrors the tag registry so preview & estimate always */
/* agree with what the console/editor can actually insert. */
/* -------------------------------------------------------------------- */
export type LexToken =
| { t: "text"; v: string }
| { t: "point"; name: string }
| { t: "open"; name: string }
| { t: "close"; name: string };
const TAG_RE = /<(\/?)([a-zA-Z ]+?)>/g;
export function lexDialogue(text: string): LexToken[] {
const tokens: LexToken[] = [];
let last = 0;
let m: RegExpExecArray | null;
TAG_RE.lastIndex = 0;
while ((m = TAG_RE.exec(text))) {
if (m.index > last) tokens.push({ t: "text", v: text.slice(last, m.index) });
const closing = m[1] === "/";
const name = m[2].trim();
if (WRAP_TAG_NAMES.has(name)) tokens.push({ t: closing ? "close" : "open", name });
else if (!closing) tokens.push({ t: "point", name }); // stray/unknown closers are ignored
last = TAG_RE.lastIndex;
}
if (last < text.length) tokens.push({ t: "text", v: text.slice(last) });
return tokens;
}
export interface DialogueTextNode {
t: "text";
v: string;
}
export interface DialoguePointNode {
t: "point";
name: string;
}
export interface DialogueSpanNode {
t: "span";
name: string;
children: DialogueNode[];
}
export type DialogueNode = DialogueTextNode | DialoguePointNode | DialogueSpanNode;
export function dialogueTree(text: string): DialogueNode[] {
const root: { children: DialogueNode[] } = { children: [] };
const stack: { children: DialogueNode[] }[] = [root];
for (const tk of lexDialogue(text)) {
const top = stack[stack.length - 1];
if (tk.t === "text") top.children.push({ t: "text", v: tk.v });
else if (tk.t === "point") top.children.push({ t: "point", name: tk.name });
else if (tk.t === "open") {
const node: DialogueSpanNode = { t: "span", name: tk.name, children: [] };
top.children.push(node);
stack.push(node);
} else if (tk.t === "close" && stack.length > 1) stack.pop();
}
return root.children;
}
/* -------------------------------------------------------------------- */
/* Duration estimate */
/* -------------------------------------------------------------------- */
export const SPEECH_RATES = { Measured: 3.2, Natural: 4.0, Quick: 4.8 } as const; // syllables/sec
export type SpeechRate = keyof typeof SPEECH_RATES;
export function syllables(word: string): number {
let w = word.toLowerCase().replace(/[^a-z]/g, "");
if (!w) return 0;
if (w.length <= 3) return 1;
w = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "");
w = w.replace(/^y/, "");
const m = w.match(/[aeiouy]{1,2}/g);
return m ? m.length : 1;
}
export interface DurationEstimate {
total: number;
speech: number;
punct: number;
tags: number;
syl: number;
words: number;
low: number;
high: number;
}
export function estimateDuration(text: string, rate: number, calib: number): DurationEstimate {
const spoken = text.replace(/<\/?[a-zA-Z ]+?>/g, " ");
const words = spoken.split(/\s+/).filter(Boolean);
const syl = words.reduce((n, w) => n + syllables(w), 0);
const speech = syl / rate;
const sentence = (spoken.match(/[.!?]/g) || []).length * 0.35;
const commas = (spoken.match(/[,;:]/g) || []).length * 0.18;
const ellipsis = (spoken.match(/\.\.\.|…/g) || []).length * 0.5;
const dashes = (spoken.match(/—/g) || []).length * 0.2;
const punct = sentence + commas + ellipsis + dashes;
let tags = 0;
const re = /<(\/?)([a-zA-Z ]+?)>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
if (m[1] === "/") continue;
tags += TAG_SECONDS[m[2].trim()] ?? 0;
}
const total = (speech + punct + tags) * calib;
return {
total,
speech: speech * calib,
punct: punct * calib,
tags: tags * calib,
syl,
words: words.length,
low: total * 0.86,
high: total * 1.16,
};
}
/** Find the best sentence/pause boundary at which to split text that runs past `ceiling` seconds. */
export function suggestSplit(text: string, rate: number, calib: number, ceiling: number): number {
const re = /(<\/?[a-zA-Z ]+?>)|([.!?]+)|([,;:]+)|(\s+)|([^\s<.!?,;:]+)/g;
let cum = 0;
let boundary = 0;
let m: RegExpExecArray | null;
const budget = ceiling - 1.5;
while ((m = re.exec(text))) {
if (m[1]) {
const closing = m[1].startsWith("</");
const name = bareName(m[1]);
if (!closing) cum += (TAG_SECONDS[name] ?? 0) * calib;
if (name === "pause" || name === "long pause") boundary = re.lastIndex;
} else if (m[2]) {
cum += 0.35 * calib;
boundary = re.lastIndex;
} else if (m[3]) {
cum += 0.18 * calib;
} else if (m[5]) {
cum += (syllables(m[5]) / rate) * calib;
}
if (cum > budget && boundary > 0) return boundary;
}
return boundary > 0 ? boundary : -1;
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { readField, shotTarget, writeField, writeShotTarget } from "@/lib/h3PromptFields";
const BASE = [
"For the target video, <Picture 1> is fully referenced.",
"",
"integrated_multimodal_description: [Shot 1] A close-up. He (S1) says: <d>[English] Hi.</d> <cutoff>",
"",
"overall_soundscape: Room tone.",
"",
"non_diegetic_music: N/A",
].join("\n");
const SECTIONS = [
"subject_definitions:",
"<Subject 1> is the courier.",
"",
"retention_analysis:",
"<Subject 1> (appears in [Shot 1]): fully_preserved - x.",
"",
"detailed_description:",
"[Shot 1] Wide shot. A rooftop.",
"[Shot 2] At 00:02.000, the shot cuts to a close-up shot. A face.",
"",
"overall_soundscape:",
"Rain.",
].join("\n");
describe("readField / writeField", () => {
it("reads a field whose text follows the heading on the same line", () => {
expect(readField(BASE, "integrated_multimodal_description")).toBe(
"[Shot 1] A close-up. He (S1) says: <d>[English] Hi.</d> <cutoff>",
);
});
it("reads a field whose text starts on the line after the heading", () => {
expect(readField(SECTIONS, "detailed_description")).toBe(
"[Shot 1] Wide shot. A rooftop.\n[Shot 2] At 00:02.000, the shot cuts to a close-up shot. A face.",
);
expect(readField(SECTIONS, "subject_definitions")).toBe("<Subject 1> is the courier.");
});
it("returns null for a field that isn't there", () => {
expect(readField(BASE, "detailed_description")).toBeNull();
});
it("rewrites only that field, leaving the rest byte-identical (inline style)", () => {
const out = writeField(BASE, "integrated_multimodal_description", "[Shot 1] New.");
expect(out).toBe(BASE.replace("[Shot 1] A close-up. He (S1) says: <d>[English] Hi.</d> <cutoff>", "[Shot 1] New."));
});
it("rewrites only that field (own-line style)", () => {
const out = writeField(SECTIONS, "detailed_description", "[Shot 1] Only.");
expect(out).toContain("detailed_description:\n[Shot 1] Only.\n\noverall_soundscape:\nRain.");
expect(out.startsWith("subject_definitions:\n<Subject 1> is the courier.")).toBe(true);
});
it("writing a field's own text back is a no-op", () => {
for (const [prompt, field] of [
[BASE, "integrated_multimodal_description"],
[SECTIONS, "detailed_description"],
[SECTIONS, "retention_analysis"],
] as const) {
expect(writeField(prompt, field, readField(prompt, field) as string)).toBe(prompt);
}
});
it("ignores a heading that isn't at the start of a line", () => {
expect(readField("see detailed_description: not a header", "detailed_description")).toBeNull();
});
});
describe("shotTarget", () => {
it("prefers integrated_multimodal_description", () => {
expect(shotTarget(BASE).field).toBe("integrated_multimodal_description");
});
it("falls back to detailed_description", () => {
expect(shotTarget(SECTIONS).field).toBe("detailed_description");
});
it("treats a plain prompt as the shot text itself", () => {
const t = shotTarget("A toy car drives into a plush dog.");
expect(t).toEqual({ field: null, body: "A toy car drives into a plush dog." });
expect(writeShotTarget("A toy car drives into a plush dog.", t, "[Shot 1] X.")).toBe("[Shot 1] X.");
});
});
@@ -0,0 +1,77 @@
/**
* Locate and rewrite a single named field inside a raw MiniMax-H3 prompt,
* leaving every other character of the prompt untouched. Handles both header
* styles seen in real prompts: the heading on its own line (six-section
* reference format) and the heading followed by its text on the same line
* (`integrated_multimodal_description: [Shot 1] ...`, the base format).
*/
const FIELD_NAMES = [
"subject_definitions",
"summary",
"retention_analysis",
"detailed_description",
"integrated_multimodal_description",
"overall_soundscape",
"non_diegetic_music",
] as const;
export type H3PromptFieldName = (typeof FIELD_NAMES)[number];
const HEADER_RE = new RegExp(`^(${FIELD_NAMES.join("|")}):[ \\t]*`, "gm");
interface Span {
start: number;
end: number;
}
function locate(prompt: string, field: H3PromptFieldName): Span | null {
const headers = [...prompt.matchAll(HEADER_RE)];
const i = headers.findIndex((h) => h[1] === field);
if (i < 0) return null;
const header = headers[i];
let start = (header.index ?? 0) + header[0].length;
// Own-line style: nothing follows the colon, so the body starts on the next line.
const newline = prompt.slice(start).match(/^\r?\n/);
if (newline) start += newline[0].length;
const nextHeader = i + 1 < headers.length ? (headers[i + 1].index ?? prompt.length) : prompt.length;
const end = start + Math.max(0, prompt.slice(start, nextHeader).trimEnd().length);
return { start, end: Math.max(start, end) };
}
/** The field's text exactly as written, or null if the prompt has no such field. */
export function readField(prompt: string, field: H3PromptFieldName): string | null {
const span = locate(prompt, field);
return span ? prompt.slice(span.start, span.end) : null;
}
/** Replace only the field's text. Returns the prompt unchanged if the field isn't there. */
export function writeField(prompt: string, field: H3PromptFieldName, body: string): string {
const span = locate(prompt, field);
return span ? prompt.slice(0, span.start) + body + prompt.slice(span.end) : prompt;
}
export interface ShotTarget {
/** the field the [Shot N] blocks live in, or null when the whole prompt is the shot text */
field: "integrated_multimodal_description" | "detailed_description" | null;
body: string;
}
/**
* Where a prompt's shots live: the base format's integrated_multimodal_description,
* else the reference format's detailed_description, else the prompt as a whole.
*/
export function shotTarget(prompt: string): ShotTarget {
for (const field of ["integrated_multimodal_description", "detailed_description"] as const) {
const body = readField(prompt, field);
if (body !== null) return { field, body };
}
return { field: null, body: prompt };
}
/** Write shot text back to wherever shotTarget found it. */
export function writeShotTarget(prompt: string, target: ShotTarget, body: string): string {
return target.field ? writeField(prompt, target.field, body) : body;
}
@@ -0,0 +1,238 @@
import { describe, expect, it } from "vitest";
import {
buildClips,
convertDirections,
parseScript,
scriptSpeakers,
type ImportSpeaker,
} from "@/lib/h3ScriptImport";
import { detailedDescriptionFromShots, shotLines, shotProse } from "@/lib/h3Shots";
// Original test material -- not from any real screenplay.
const SCRIPT = [
"INT. HARBOR OFFICE - DAY",
"",
"CAPTAIN: Sit down. (EXHALES) We need to talk about the ledger.",
"",
"CLERK: Yes, sir.",
"",
"CAPTAIN: Three crates are missing, and nobody on this dock saw a thing.",
"Not one of you. That does not happen by accident.",
"",
"CLERK: I counted twice.",
"",
"CAPTAIN: (WHISPERING) Then count a third time.",
"",
"CLERK: (TAPPING TABLE) Right away.",
].join("\n");
const SPEAKERS: Record<string, ImportSpeaker> = {
CAPTAIN: { subject: "<Subject 1>", tag: "S1" },
CLERK: { subject: "<Subject 2>", tag: "S2" },
};
describe("parseScript", () => {
it("reads NAME: line turns and ignores headings between turns", () => {
const turns = parseScript(SCRIPT);
expect(turns.map((t) => t.speaker)).toEqual(["CAPTAIN", "CLERK", "CAPTAIN", "CLERK", "CAPTAIN", "CLERK"]);
expect(turns.some((t) => t.text.includes("HARBOR"))).toBe(false);
});
it("joins continuation lines directly under a turn", () => {
const turns = parseScript(SCRIPT);
expect(turns[2].text).toBe(
"Three crates are missing, and nobody on this dock saw a thing. Not one of you. That does not happen by accident.",
);
});
it("tolerates markdown bold speaker names", () => {
expect(parseScript("**MARK:** hello there")).toEqual([{ speaker: "MARK", text: "hello there" }]);
});
it("lists speakers in order of first appearance", () => {
expect(scriptSpeakers(parseScript(SCRIPT))).toEqual(["CAPTAIN", "CLERK"]);
});
it("returns nothing for text with no speaker turns", () => {
expect(parseScript("just some prose without turns")).toEqual([]);
});
});
describe("convertDirections", () => {
it("turns known parentheticals into speech tags in place", () => {
expect(convertDirections("Sit down. (EXHALES) We talk.").text).toBe("Sit down. <exhale> We talk.");
expect(convertDirections("Well (BEAT) fine.").text).toBe("Well <pause> fine.");
});
it("wraps the line for a whispering direction", () => {
expect(convertDirections("(WHISPERING) Keep it down.").text).toBe("<whisper>Keep it down.</whisper>");
});
it("keeps unmapped directions out of the dialogue but reports them", () => {
const r = convertDirections("(TAPPING TABLE) Right away.");
expect(r.text).toBe("Right away.");
expect(r.directions).toEqual(["tapping table"]);
});
});
describe("buildClips", () => {
it("opens the first clip with an establishing shot", () => {
const [clip] = buildClips(parseScript(SCRIPT), { speakers: SPEAKERS, targetSeconds: 60 });
expect(clip.shots[0].type).toBe("establishing");
expect(shotLines(clip.shots[0])).toHaveLength(0);
expect(clip.shots[0].subjectIds).toEqual(["<Subject 1>", "<Subject 2>"]);
});
it("folds a short reaction into the shot it reacts to", () => {
const [clip] = buildClips(parseScript(SCRIPT), { speakers: SPEAKERS, targetSeconds: 60 });
const first = clip.shots[1];
expect(shotLines(first).map((l) => [l.speaker, l.text])).toEqual([
["S1", "Sit down. <exhale> We need to talk about the ledger."],
["S2", "Yes, sir."],
]);
expect(first.type).toBe("medium");
expect(first.subjectIds).toEqual(["<Subject 1>", "<Subject 2>"]);
});
it("uses a close-up when only one person speaks in a shot", () => {
const [clip] = buildClips([{ speaker: "CAPTAIN", text: "Nobody leaves until the count is done." }], {
speakers: SPEAKERS,
targetSeconds: 60,
});
const shot = clip.shots[1];
expect(shot.type).toBe("close-up");
expect(shot.subjectIds).toEqual(["<Subject 1>"]);
expect(shotProse(shot)).toBe("Close on <Subject 1>, speaking.");
});
it("records unmapped stage directions on the shot they belong to", () => {
const [clip] = buildClips(parseScript(SCRIPT), { speakers: SPEAKERS, targetSeconds: 60 });
const last = clip.shots[clip.shots.length - 1];
expect(shotLines(last).map((l) => l.text)).toEqual(["<whisper>Then count a third time.</whisper>", "Right away."]);
expect(shotProse(last)).toContain("Stage direction: tapping table.");
});
it("splits a long scene into several clips, each with dialogue", () => {
const clips = buildClips(parseScript(SCRIPT), { speakers: SPEAKERS, targetSeconds: 5 });
expect(clips.length).toBeGreaterThan(1);
for (const c of clips) expect(c.shots.some((s) => shotLines(s).length > 0)).toBe(true);
expect(clips[1].shots[0].type).not.toBe("establishing");
});
it("keeps every spoken word across clips", () => {
const clips = buildClips(parseScript(SCRIPT), { speakers: SPEAKERS, targetSeconds: 5 });
const all = clips.flatMap((c) => c.shots.flatMap((s) => shotLines(s).map((l) => l.text))).join(" ");
for (const word of ["ledger", "crates", "accident", "counted", "third", "Right away"]) {
expect(all).toContain(word);
}
});
it("serializes to valid [Shot N] text with speaker-tagged dialogue", () => {
const [clip] = buildClips(parseScript(SCRIPT), { speakers: SPEAKERS, targetSeconds: 60 });
const text = detailedDescriptionFromShots(clip.shots);
expect(text).toContain("[Shot 1] Establishing shot.");
expect(text).toContain("(S1) <d>[English] Sit down. <exhale> We need to talk about the ledger.</d>");
expect(text).toContain("(S2) <d>[English] Yes, sir.</d>");
});
describe("speaker tags per clip", () => {
it("numbers speakers by who speaks first in each clip, not across the whole script", () => {
const turns = [
{ speaker: "CAPTAIN", text: "Every one of these lines is long enough to stand as its own turn today." },
{ speaker: "CLERK", text: "And every reply here is long enough to stand as its own turn as well." },
{ speaker: "CLERK", text: "The clerk speaks again first when the next clip begins, with a full sentence." },
{ speaker: "CAPTAIN", text: "And then the captain answers the clerk with another complete sentence here." },
];
const clips = buildClips(turns, { speakers: SPEAKERS, targetSeconds: 8 });
expect(clips.length).toBeGreaterThan(1);
for (const c of clips) {
const first = c.shots.flatMap((s) => shotLines(s))[0];
expect(first.speaker).toBe("S1");
}
// a clip that opens on the clerk maps the clerk to S1 and the captain to S2
const clerkFirst = clips.find((c) => shotLines(c.shots.find((s) => shotLines(s).length)!)[0].text.startsWith("The clerk"));
const speakers = clerkFirst!.shots.flatMap((s) => shotLines(s).map((l) => l.speaker));
expect(new Set(speakers).size).toBeLessThanOrEqual(2);
});
it("leaves custom speaker tags alone", () => {
const clips = buildClips(
[{ speaker: "CLERK", text: "A line for the clerk that is long enough to stand alone here." }],
{ speakers: { CLERK: { subject: "<Subject 2>", tag: "CLERK" }, CAPTAIN: SPEAKERS.CAPTAIN }, targetSeconds: 11 },
);
expect(shotLines(clips[0].shots[1])[0].speaker).toBe("CLERK");
});
});
describe("clip boundaries", () => {
const longSpeech = Array.from({ length: 12 }, (_, i) => `This is sentence number ${i + 1} of a long speech.`).join(" ");
it("keeps a long turn as one continuous shot when a clip can hold it", () => {
const clips = buildClips([{ speaker: "CAPTAIN", text: longSpeech }], { speakers: SPEAKERS, targetSeconds: 60 });
expect(clips).toHaveLength(1);
const dialogueShots = clips[0].shots.filter((s) => shotLines(s).length > 0);
expect(dialogueShots).toHaveLength(1);
expect(shotLines(dialogueShots[0])[0].text).toBe(longSpeech);
});
it("only splits inside a shot when the turn is longer than a whole clip, and marks the follow-on clips", () => {
const clips = buildClips([{ speaker: "CAPTAIN", text: longSpeech }], { speakers: SPEAKERS, targetSeconds: 11 });
expect(clips.length).toBeGreaterThan(1);
expect(clips[0].continuation).toBe(false);
for (const c of clips.slice(1)) {
expect(c.continuation).toBe(true);
expect(shotProse(c.shots[0])).toContain("Continuing the same shot");
expect(c.shots[0].type).toBe("close-up");
}
const spoken = clips.flatMap((c) => c.shots.flatMap((s) => shotLines(s).map((l) => l.text))).join(" ");
expect(spoken).toBe(longSpeech);
});
it("marks speech that runs past the end of a clip with <cutoff>, and only there", () => {
const clips = buildClips([{ speaker: "CAPTAIN", text: longSpeech }], { speakers: SPEAKERS, targetSeconds: 11 });
const endings = clips.map((c) => detailedDescriptionFromShots(c.shots));
for (const text of endings.slice(0, -1)) expect(text).toMatch(/<\/d> <cutoff>$/);
expect(endings[endings.length - 1]).not.toContain("<cutoff>");
// the marker is outside the spoken text, so it never leaks into the dialogue itself
const spoken = clips.flatMap((c) => c.shots.flatMap((s) => shotLines(s).map((l) => l.text))).join(" ");
expect(spoken).not.toContain("cutoff");
});
it("breaks between separate turns at a cut, not as a continuation", () => {
const turns = Array.from({ length: 6 }, () => ({
speaker: "CAPTAIN",
text: "Every one of these turns is a complete thought that takes a while to say aloud.",
}));
const clips = buildClips(turns, { speakers: SPEAKERS, targetSeconds: 11 });
expect(clips.length).toBeGreaterThan(1);
expect(clips.every((c) => !c.continuation)).toBe(true);
});
it("re-wraps a whispered line on every piece so no tag is left unmatched", () => {
const clips = buildClips([{ speaker: "CAPTAIN", text: `(WHISPERING) ${longSpeech}` }], {
speakers: SPEAKERS,
targetSeconds: 11,
});
const texts = clips.flatMap((c) => c.shots.flatMap((s) => shotLines(s).map((l) => l.text)));
expect(texts.length).toBeGreaterThan(1);
for (const t of texts) {
expect(t.startsWith("<whisper>")).toBe(true);
expect(t.endsWith("</whisper>")).toBe(true);
}
});
it("puts a trailing reaction with the last piece of a split speech", () => {
const clips = buildClips(
[
{ speaker: "CAPTAIN", text: longSpeech },
{ speaker: "CLERK", text: "Understood." },
],
{ speakers: SPEAKERS, targetSeconds: 11 },
);
const lastShot = clips[clips.length - 1].shots.at(-1)!;
expect(shotLines(lastShot).map((l) => l.speaker)).toEqual(["S1", "S2"]);
const earlier = clips.slice(0, -1).flatMap((c) => c.shots.flatMap((s) => shotLines(s).map((l) => l.speaker)));
expect(earlier.every((sp) => sp === "S1")).toBe(true);
});
});
});
@@ -0,0 +1,332 @@
/**
* Turns a plain dialogue script ("NAME: line" turns) into clip-sized shot
* lists for the shot editor. Mechanical work only -- speakers, dialogue,
* stage-direction-to-tag mapping, grouping turns into shots, and splitting
* the whole scene into clips that fit one generation. Camera choices are
* defaults for the user to refine; a script says nothing about them.
*/
import { SPEECH_RATES, estimateDuration } from "@/lib/h3DialogueEstimate";
import { estimateSpokenSeconds, makeShot, newLine, newTextPart, shotLines, type Shot } from "@/lib/h3Shots";
import { H3_TAGS } from "@/lib/h3Tags";
export interface ScriptTurn {
speaker: string;
text: string;
}
// "MARK: ..." -- 1-3 capitalized words, so prose like "note: ..." or scene
// headings without a colon are never mistaken for a speaker.
const SPEAKER_LINE_RE = /^([A-Z][A-Za-z0-9.'_-]*(?: [A-Z][A-Za-z0-9.'_-]*){0,2}):\s*(.*)$/;
function stripMarkdown(line: string): string {
return line
.replace(/^\s*(?:[>*-]\s+)+/, "")
.replace(/\*\*|__/g, "")
.trim();
}
/**
* A speaker line starts a turn; non-blank lines directly after it continue
* that turn. A blank line ends the turn, so scene headings and action lines
* between turns are ignored rather than glued onto someone's dialogue.
*/
export function parseScript(script: string): ScriptTurn[] {
const turns: ScriptTurn[] = [];
let continuing = false;
for (const raw of script.split(/\r?\n/)) {
const line = stripMarkdown(raw);
if (!line) {
continuing = false;
continue;
}
const m = line.match(SPEAKER_LINE_RE);
if (m) {
turns.push({ speaker: m[1], text: m[2].trim() });
continuing = true;
} else if (continuing && turns.length > 0) {
const last = turns[turns.length - 1];
last.text = `${last.text} ${line}`.trim();
}
}
return turns.filter((t) => t.text);
}
/** Speaker names in order of first appearance. */
export function scriptSpeakers(turns: ScriptTurn[]): string[] {
const seen: string[] = [];
for (const t of turns) if (!seen.includes(t.speaker)) seen.push(t.speaker);
return seen;
}
/* ---------------------------------------------------------------- */
/* Stage directions -> speech tags */
/* ---------------------------------------------------------------- */
const TAG_BY_ID = Object.fromEntries(H3_TAGS.map((t) => [t.id, t]));
// Order matters: the more specific phrase must be tried before its substring.
const DIRECTION_TAGS: [RegExp, string][] = [
[/\blong pause\b/, "long-pause"],
[/\bcatch(?:es)? (?:his |her |their )?breath\b/, "catches-breath"],
[/\bexhal/, "exhale"],
[/\binhal/, "inhale"],
[/\bsigh/, "sighs"],
[/\blaugh/, "laughs"],
[/\bchuckl/, "chuckle"],
[/\bgasp/, "gasp"],
[/\bcough/, "coughs"],
[/\bclears? (?:his |her |their )?throat\b/, "clears-throat"],
[/\bsniff/, "sniff"],
[/\b(?:pause|beat)\b/, "pause"],
[/\bstutter|\bstammer/, "stutter"],
[/\bbreath/, "breath"],
];
export interface ConvertedLine {
text: string;
/** parentheticals with no matching tag, kept as stage directions for the shot description */
directions: string[];
}
/** Rewrites "(EXHALES)"-style parentheticals as speech tags; "(WHISPERING)" wraps the whole line. */
export function convertDirections(text: string): ConvertedLine {
const directions: string[] = [];
let wrapWhisper = false;
let out = text.replace(/\(([^)]*)\)/g, (_m, inner: string) => {
const key = inner.trim().toLowerCase();
if (/\bwhisper/.test(key)) {
wrapWhisper = true;
return " ";
}
for (const [re, id] of DIRECTION_TAGS) {
const tag = TAG_BY_ID[id];
if (tag && tag.kind === "point" && re.test(key)) return ` ${tag.open} `;
}
if (key) directions.push(key);
return " ";
});
out = out
.replace(/\s+/g, " ")
.replace(/\s+([.,!?…])/g, "$1")
.trim();
const whisper = TAG_BY_ID.whisper;
if (wrapWhisper && whisper?.close && out) out = `${whisper.open}${out}${whisper.close}`;
return { text: out, directions };
}
/* ---------------------------------------------------------------- */
/* Shots and clips */
/* ---------------------------------------------------------------- */
export interface ImportSpeaker {
/** <Subject N> label this speaker is, or null when they aren't a tracked subject */
subject: string | null;
/** speaker tag written as "(S1)" */
tag: string;
}
export interface ImportOptions {
speakers: Record<string, ImportSpeaker>;
/** rough length one clip should fit, in seconds */
targetSeconds: number;
}
export interface ImportClip {
shots: Shot[];
/**
* True when this clip picks up in the middle of the previous clip's last
* shot (a speech too long for one generation). Its first shot continues
* that one instead of cutting to something new, so it should be generated
* from the previous clip's last frame. False means the boundary is a cut.
*/
continuation: boolean;
}
const ESTABLISHING_SECONDS = 1.5;
const REACTION_MAX_WORDS = 4;
// Space left in a clip is only worth starting a forced mid-shot split in if
// it can hold at least this much of the speech.
const MIN_PART_SECONDS = 3;
function spokenSeconds(text: string): number {
return estimateDuration(text, SPEECH_RATES.Natural, 1).total;
}
function wordCount(text: string): number {
return (text.replace(/<[^>]*>/g, " ").match(/[\p{L}\p{N}'’-]+/gu) ?? []).length;
}
/**
* Split one over-long line at sentence boundaries into parts that fit the
* given budgets (first part, then every later part). An outer wrapping tag
* like <whisper>...</whisper> is re-applied to each part so none is left
* with an unmatched tag.
*/
function splitToBudgets(text: string, firstBudget: number, laterBudget: number): string[] {
const wrap = text.match(/^<([a-z ]+)>([\s\S]*)<\/\1>$/);
const inner = wrap ? wrap[2] : text;
const sentences = inner.match(/[^.!?…]+(?:[.!?…]+|$)\s*/g) ?? [inner];
const parts: string[] = [];
let current = "";
let budget = firstBudget;
for (const sentence of sentences) {
const candidate = `${current}${sentence}`;
if (current && spokenSeconds(candidate) > budget) {
parts.push(current.trim());
current = sentence;
budget = laterBudget;
} else {
current = candidate;
}
}
if (current.trim()) parts.push(current.trim());
return wrap ? parts.map((p) => `<${wrap[1]}>${p}</${wrap[1]}>`) : parts;
}
interface Beat {
/** the first line is the turn the shot is about; later lines are short reactions */
lines: { speaker: string; text: string }[];
directions: string[];
}
function beatSeconds(beat: Beat): number {
return estimateSpokenSeconds(beat.lines.map((l) => l.text));
}
function titleCase(name: string): string {
return name.toLowerCase().replace(/(^|[\s'-])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toUpperCase());
}
function joinNatural(items: string[]): string {
return items.length <= 2 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
}
function shotFromBeat(
beat: Beat,
opts: ImportOptions,
totalSeconds: number,
continued = false,
truncated = false,
): Shot {
const speakers = [...new Set(beat.lines.map((l) => l.speaker))];
const subjectIds = [
...new Set(speakers.map((s) => opts.speakers[s]?.subject).filter((s): s is string => Boolean(s))),
];
const named = speakers.map((s) => opts.speakers[s]?.subject ?? titleCase(s));
let description: string;
if (continued) {
description = `Continuing the same shot on ${joinNatural(named)}, still speaking.`;
} else {
description =
speakers.length > 1
? `${joinNatural(named)} in frame, exchanging lines.`
: `Close on ${named[0]}, speaking.`;
}
if (beat.directions.length) description += ` Stage direction: ${beat.directions.join("; ")}.`;
const shot = makeShot({
type: speakers.length > 1 ? "medium" : "close-up",
movement: totalSeconds >= 5 ? "dolly" : "static",
subjectIds,
description,
lines: beat.lines.map((l) => newLine({ speaker: opts.speakers[l.speaker]?.tag ?? "", text: l.text })),
});
// The guide's marker for speech truncated by the video ending: this clip
// stops mid-speech and the next one picks it up.
if (truncated) shot.body.push(newTextPart("<cutoff>"));
return shot;
}
/** One shot per speaking turn; a short reaction ("Yeah.") rides along in the shot it reacts to. */
function groupBeats(turns: ScriptTurn[]): Beat[] {
const beats: Beat[] = [];
for (const turn of turns) {
const { text, directions } = convertDirections(turn.text);
if (!text) continue;
const last = beats[beats.length - 1];
if (last && wordCount(text) <= REACTION_MAX_WORDS) {
last.lines.push({ speaker: turn.speaker, text });
last.directions.push(...directions);
} else {
beats.push({ lines: [{ speaker: turn.speaker, text }], directions: [...directions] });
}
}
return beats;
}
/**
* The guide assigns speaker IDs by who first speaks in *each video*, so a
* clip that opens on the second character must call them S1. Renumber a
* clip's default S1/S2/... tags in order of first appearance; custom tags
* (anything not of the form S<n>) are left alone.
*/
function renumberSpeakerTags(clip: ImportClip): void {
const lines = clip.shots.flatMap((s) => shotLines(s));
const seen: string[] = [];
for (const l of lines) if (l.speaker && !seen.includes(l.speaker)) seen.push(l.speaker);
if (seen.length === 0 || !seen.every((t) => /^S\d+$/.test(t))) return;
const renamed = new Map(seen.map((t, i) => [t, `S${i + 1}`]));
for (const l of lines) if (l.speaker) l.speaker = renamed.get(l.speaker) ?? l.speaker;
}
/**
* Pack shots into clips that fit `targetSeconds`. Clips break only at cuts
* (a cut should bring new information), so a turn is never divided just to
* balance lengths. The one exception is a single turn longer than a whole
* clip: it is split at sentence breaks, and each later piece starts a
* `continuation` clip. The first clip opens with an establishing shot.
*/
export function buildClips(turns: ScriptTurn[], opts: ImportOptions): ImportClip[] {
const beats = groupBeats(turns);
const allSubjects = [
...new Set(Object.values(opts.speakers).map((s) => s.subject).filter((s): s is string => Boolean(s))),
];
const establishing = makeShot({
type: "establishing",
subjectIds: allSubjects,
description: `Establishing view of the setting${allSubjects.length ? ` with ${joinNatural(allSubjects)} in frame` : ""}.`,
});
const clips: ImportClip[] = [];
let current: ImportClip = { shots: [establishing], continuation: false };
let seconds = ESTABLISHING_SECONDS;
const hasDialogue = () => current.shots.some((s) => shotLines(s).length > 0);
const close = (continuation: boolean) => {
clips.push(current);
current = { shots: [], continuation };
seconds = 0;
};
for (const beat of beats) {
const total = beatSeconds(beat);
if (total <= opts.targetSeconds) {
if (hasDialogue() && seconds + total > opts.targetSeconds) close(false);
current.shots.push(shotFromBeat(beat, opts, total));
seconds += total;
continue;
}
// Longer than any clip can hold: the split has to fall inside this shot.
if (current.shots.length > 0 && opts.targetSeconds - seconds < MIN_PART_SECONDS) close(false);
const [main, ...reactions] = beat.lines;
const parts = splitToBudgets(main.text, opts.targetSeconds - seconds, opts.targetSeconds);
parts.forEach((part, k) => {
if (k > 0) close(true);
const lines = [{ ...main, text: part }, ...(k === parts.length - 1 ? reactions : [])];
const pieceBeat: Beat = { lines, directions: k === 0 ? beat.directions : [] };
current.shots.push(shotFromBeat(pieceBeat, opts, total, k > 0, k < parts.length - 1));
seconds += beatSeconds(pieceBeat);
});
}
if (current.shots.length > 0 && (clips.length === 0 || hasDialogue())) clips.push(current);
for (const clip of clips) renumberSpeakerTags(clip);
return clips;
}
@@ -0,0 +1,336 @@
import { describe, expect, it } from "vitest";
import {
detailedDescriptionFromShots,
formatCutTime,
makeShot,
newLine,
parseCutTime,
parseDetailedDescription,
parseSubjectLabels,
resolveShotStarts,
retentionAnalysisFromShots,
shotLines,
shotProse,
shotsFromDetailedDescription,
type Shot,
type ShotInit,
} from "@/lib/h3Shots";
function shot(init: ShotInit): Shot {
return makeShot(init);
}
describe("parseSubjectLabels", () => {
it("returns ordered, de-duplicated <Subject N> labels", () => {
const text = "<Subject 1> is the dog.\n<Subject 2> is the cat. <Subject 1> again.";
expect(parseSubjectLabels(text)).toEqual(["<Subject 1>", "<Subject 2>"]);
});
it("returns an empty list when there are none", () => {
expect(parseSubjectLabels("no subjects here")).toEqual([]);
});
});
describe("detailedDescriptionFromShots", () => {
it("serializes shot type, movement, description, and dialogue", () => {
const shots = [
shot({
type: "close-up",
movement: "handheld",
description: "She looks up.",
lines: [newLine({ speaker: "S1", text: "I found it." })],
}),
];
expect(detailedDescriptionFromShots(shots)).toBe(
"[Shot 1] Close-up shot, handheld. She looks up. (S1) <d>[English] I found it.</d>",
);
});
it("omits the movement clause for a static camera", () => {
const shots = [shot({ type: "wide", movement: "static", description: "An empty street." })];
expect(detailedDescriptionFromShots(shots)).toBe("[Shot 1] Wide shot. An empty street.");
});
it("numbers shots in order, one per line, and marks later shots with a cut time", () => {
const shots = [shot({ description: "First." }), shot({ description: "Second." })];
const out = detailedDescriptionFromShots(shots);
expect(out).toBe(
"[Shot 1] Medium shot. First.\n[Shot 2] At 00:02.000, the shot cuts to a medium shot. Second.",
);
});
it("uses the right article and movement in the cut wording", () => {
const shots = [
shot({ type: "wide" }),
shot({ type: "establishing", startSeconds: 3 }),
shot({ type: "extreme-close-up", movement: "dolly", startSeconds: 5.25 }),
shot({ type: "pov", startSeconds: 7 }),
];
const out = detailedDescriptionFromShots(shots).split("\n");
expect(out[1]).toBe("[Shot 2] At 00:03.000, the shot cuts to an establishing shot.");
expect(out[2]).toBe("[Shot 3] At 00:05.250, the shot cuts to an extreme close-up shot, dolly.");
expect(out[3]).toBe("[Shot 4] At 00:07.000, the shot cuts to a POV shot.");
});
it("writes the preamble before the first shot", () => {
const out = detailedDescriptionFromShots([shot({ description: "A." })], "Keep the film-noir look.");
expect(out).toBe("Keep the film-noir look.\n[Shot 1] Medium shot. A.");
});
});
describe("cut times", () => {
it("formats and parses MM:SS.mmm", () => {
expect(formatCutTime(3.5)).toBe("00:03.500");
expect(formatCutTime(75.04)).toBe("01:15.040");
expect(parseCutTime("00:03.500")).toBe(3.5);
expect(parseCutTime("0:03.5")).toBe(3.5);
expect(parseCutTime("3.5")).toBe(3.5);
expect(parseCutTime("1:15")).toBe(75);
expect(parseCutTime("soon")).toBeNull();
});
it("starts the opening shot at 0 and follows estimated shot lengths", () => {
const shots = [
shot({ lines: [newLine({ text: "A short line." })] }),
shot({}),
shot({}),
];
const starts = resolveShotStarts(shots);
expect(starts[0]).toBe(0);
expect(starts[1]).toBeGreaterThanOrEqual(2);
expect(starts[2]).toBeCloseTo(starts[1] + 2, 3);
});
it("honors an explicit time and keeps times strictly increasing", () => {
const shots = [shot({}), shot({ startSeconds: 4 }), shot({ startSeconds: 1 })];
const starts = resolveShotStarts(shots);
expect(starts[1]).toBe(4);
expect(starts[2]).toBeGreaterThan(starts[1]);
});
});
describe("shotsFromDetailedDescription", () => {
it("round-trips type, movement, dialogue, speaker, and subjects", () => {
const original = [
shot({
type: "extreme-close-up",
movement: "dolly",
description: "Her hand trembles.",
lines: [newLine({ speaker: "S1", text: "Wait." })],
subjectIds: ["<Subject 1>"],
}),
];
const serialized = detailedDescriptionFromShots(original);
const withSubject = serialized.replace("Her hand trembles.", "Her hand trembles, <Subject 1> hesitates.");
const parsed = shotsFromDetailedDescription(withSubject, ["<Subject 1>", "<Subject 2>"]);
expect(parsed).toHaveLength(1);
expect(parsed[0].type).toBe("extreme-close-up");
expect(parsed[0].movement).toBe("dolly");
expect(shotLines(parsed[0])).toHaveLength(1);
expect(shotLines(parsed[0])[0].speaker).toBe("S1");
expect(shotLines(parsed[0])[0].text).toBe("Wait.");
expect(parsed[0].subjectIds).toEqual(["<Subject 1>"]);
});
it("keeps every dialogue line of an exchange, in order", () => {
const original = [
shot({
description: "They talk.",
lines: [
newLine({ speaker: "S1", text: "Ready?" }),
newLine({ speaker: "S2", text: "<pause> Always." }),
newLine({ speaker: "S1", text: "Then go." }),
],
}),
];
const serialized = detailedDescriptionFromShots(original);
expect(serialized).toBe(
"[Shot 1] Medium shot. They talk. (S1) <d>[English] Ready?</d> (S2) <d>[English] <pause> Always.</d> (S1) <d>[English] Then go.</d>",
);
const parsed = shotsFromDetailedDescription(serialized, []);
expect(shotProse(parsed[0])).toBe("They talk.");
expect(shotLines(parsed[0]).map((l) => [l.speaker, l.text])).toEqual([
["S1", "Ready?"],
["S2", "<pause> Always."],
["S1", "Then go."],
]);
});
it("round-trips a non-English language marker instead of relabelling it English", () => {
const original = [shot({ lines: [newLine({ speaker: "S1", text: "你好。", language: "Chinese" })] })];
const serialized = detailedDescriptionFromShots(original);
expect(serialized).toContain("<d>[Chinese] 你好。</d>");
expect(shotLines(shotsFromDetailedDescription(serialized, [])[0])[0].language).toBe("Chinese");
});
it("round-trips a multi-shot list without pinning the automatic cut times", () => {
const original = [
shot({ type: "establishing", description: "A rooftop." }),
shot({
type: "close-up",
movement: "handheld",
description: "He turns.",
lines: [newLine({ speaker: "S2", text: "I had no choice." })],
}),
shot({ type: "wide", movement: "dolly", description: "They stand apart." }),
];
const serialized = detailedDescriptionFromShots(original);
const parsed = shotsFromDetailedDescription(serialized, []);
expect(parsed.map((p) => [p.type, p.movement, p.freeLead])).toEqual([
["establishing", "static", false],
["close-up", "handheld", false],
["wide", "dolly", false],
]);
expect(parsed.every((p) => p.startSeconds === null)).toBe(true);
expect(detailedDescriptionFromShots(parsed)).toBe(serialized);
});
it("pins a cut time that differs from the automatic one", () => {
const parsed = shotsFromDetailedDescription(
"[Shot 1] Wide shot. A dock.\n[Shot 2] At 00:07.400, the shot cuts to a close-up shot. A face.",
[],
);
expect(parsed[1].startSeconds).toBe(7.4);
});
it("keeps hand-written shots verbatim, with the preamble and cut times", () => {
const handWritten = [
"The target video keeps the live-action style of <Video 1> unchanged.",
"[Shot 1] A handheld medium shot holds <Subject 1> at frame left, half-occluded by plastic sheeting.",
"[Shot 2] At 00:03.800, the shot cuts to a low tracking shot behind the boots of <Subject 1> as he sprints.",
"[Shot 3] At 00:07.400, the camera cuts to a close front-on tracking shot. He (S1) says: <d>[English] Go.</d> <cutoff>",
].join("\n");
const { preamble, shots } = parseDetailedDescription(handWritten, ["<Subject 1>"]);
expect(preamble).toBe("The target video keeps the live-action style of <Video 1> unchanged.");
expect(shots).toHaveLength(3);
expect(shots.every((s) => s.freeLead)).toBe(true);
expect(shots.map((s) => s.startSeconds)).toEqual([null, 3.8, 7.4]);
expect(shots[0].subjectIds).toEqual(["<Subject 1>"]);
// Dialogue in a hand-written shot is pulled out into an editable line...
expect(shotLines(shots[2]).map((l) => [l.speaker, l.text])).toEqual([["", "Go."]]);
expect(detailedDescriptionFromShots(shots, preamble)).toBe(handWritten);
});
it("keeps a trailing tag after the dialogue it modifies", () => {
const text =
"[Shot 1] He (S1) says: <d>[English] <breath> I think you're... <i>sorry</i>.</d> <cutoff>";
const [shot1] = shotsFromDetailedDescription(text, []);
expect(shot1.freeLead).toBe(true);
expect(shot1.body.map((p) => p.kind)).toEqual(["text", "line", "text"]);
expect(shotLines(shot1)[0].text).toBe("<breath> I think you're... <i>sorry</i>.");
expect(detailedDescriptionFromShots([shot1])).toBe(text);
});
it("keeps interleaved dialogue in place, whitespace and all, in hand-written shots", () => {
const text = "[Shot 1] She waits.\n (S1) <d>[English] Ready?</d>\nHe nods. (S2) <d>[English] Ready.</d> The door opens.";
const [s1] = shotsFromDetailedDescription(text, []);
expect(s1.body.map((p) => p.kind)).toEqual(["text", "line", "text", "line", "text"]);
expect(detailedDescriptionFromShots([s1])).toBe(text);
});
it("keeps dialogue between prose in shots this editor wrote too", () => {
const text = "[Shot 1] Wide shot. He looks up. (S1) <d>[English] Hello.</d> Then he leaves.";
const [s1] = shotsFromDetailedDescription(text, []);
expect(s1.freeLead).toBe(false);
expect(s1.body.map((p) => p.kind)).toEqual(["text", "line", "text"]);
expect(detailedDescriptionFromShots([s1])).toBe(text);
});
it("edits a line without moving the text or lines around it", () => {
const text = "[Shot 1] Wide shot. A. (S1) <d>[English] One.</d> B. (S2) <d>[English] Two.</d> C.";
const [s1] = shotsFromDetailedDescription(text, []);
const second = shotLines(s1)[1];
second.text = "Changed.";
expect(detailedDescriptionFromShots([s1])).toBe(
"[Shot 1] Wide shot. A. (S1) <d>[English] One.</d> B. (S2) <d>[English] Changed.</d> C.",
);
});
it("puts a newly added line at the end and separates it from the prose", () => {
const text = "[Shot 1] Wide shot. He speaks. (S1) <d>[English] First.</d> He pauses.";
const [s1] = shotsFromDetailedDescription(text, []);
s1.body.push({ kind: "line", line: newLine({ speaker: "S1", text: "Later." }) });
expect(detailedDescriptionFromShots([s1])).toBe(
"[Shot 1] Wide shot. He speaks. (S1) <d>[English] First.</d> He pauses. (S1) <d>[English] Later.</d>",
);
});
it("reordering blocks changes the written order", () => {
const [s1] = shotsFromDetailedDescription(
"[Shot 1] Wide shot. (S1) <d>[English] One.</d> (S2) <d>[English] Two.</d>",
[],
);
const lineBlocks = s1.body.filter((p) => p.kind === "line");
s1.body = [...s1.body.filter((p) => p.kind !== "line"), lineBlocks[1], lineBlocks[0]];
expect(detailedDescriptionFromShots([s1])).toBe(
"[Shot 1] Wide shot. (S2) <d>[English] Two.</d> (S1) <d>[English] One.</d>",
);
});
it("recovers type and movement from the guide's cut wording", () => {
const parsed = shotsFromDetailedDescription(
"[Shot 1] Wide shot.\n[Shot 2] At 00:04.000, the camera cuts to an over-the-shoulder shot, handheld. Two people talk.",
[],
);
expect(parsed[1].type).toBe("over-the-shoulder");
expect(parsed[1].movement).toBe("handheld");
expect(shotProse(parsed[1])).toBe("Two people talk.");
expect(parsed[1].freeLead).toBe(false);
});
it("falls back to defaults for arbitrary prose, keeping it as the description", () => {
const parsed = shotsFromDetailedDescription("A toy car drives into a plush dog.", []);
expect(parsed).toHaveLength(1);
expect(parsed[0].type).toBe("medium");
expect(parsed[0].movement).toBe("static");
expect(shotProse(parsed[0])).toBe("A toy car drives into a plush dog.");
});
it("returns an empty list for blank text", () => {
expect(shotsFromDetailedDescription("", [])).toEqual([]);
});
});
describe("retentionAnalysisFromShots", () => {
it("generates an appears-in line per subject with shots", () => {
const shots = [
shot({ subjectIds: ["<Subject 1>"] }),
shot({ subjectIds: ["<Subject 1>", "<Subject 2>"] }),
];
const out = retentionAnalysisFromShots(shots, ["<Subject 1>", "<Subject 2>"], "");
expect(out).toBe(
[
"<Subject 1> (appears in [Shot 1], [Shot 2]): fully_preserved - what is retained.",
"<Subject 2> (appears in [Shot 2]): fully_preserved - what is retained.",
].join("\n"),
);
});
it("preserves an existing retention note instead of overwriting it", () => {
const shots = [shot({ subjectIds: ["<Subject 1>"] })];
const previous = "<Subject 1> (appears in [Shot 1]): fully_preserved - fur and collar retained.";
const out = retentionAnalysisFromShots(shots, ["<Subject 1>"], previous);
expect(out).toBe("<Subject 1> (appears in [Shot 1]): fully_preserved - fur and collar retained.");
});
it("passes through lines it doesn't own, like audio references", () => {
const shots = [shot({ subjectIds: ["<Subject 1>"] })];
const previous = [
"<Subject 1> (appears in [Shot 3]): fully_preserved - old note.",
"<Audio 1>: reference - how it guides the audio.",
].join("\n");
const out = retentionAnalysisFromShots(shots, ["<Subject 1>"], previous);
expect(out).toContain("<Subject 1> (appears in [Shot 1]): fully_preserved - old note.");
expect(out).toContain("<Audio 1>: reference - how it guides the audio.");
});
it("omits a subject with no shots rather than emitting an empty appears-in", () => {
const shots = [shot({ subjectIds: [] })];
const out = retentionAnalysisFromShots(shots, ["<Subject 1>"], "");
expect(out).toBe("");
});
});
+478
View File
@@ -0,0 +1,478 @@
/**
* MiniMax-H3 shot list: a structured camera-direction editor that serializes
* into `detailed_description`'s `[Shot N]` blocks and keeps
* `retention_analysis`'s "appears in [...]" lines in sync, per the model's
* reference prompt guide (see h3Prompt.ts / h3References.ts for the six
* guided sections this plugs into).
*/
import { SPEECH_RATES, estimateDuration } from "@/lib/h3DialogueEstimate";
export type ShotType =
| "establishing"
| "wide"
| "medium"
| "close-up"
| "extreme-close-up"
| "over-the-shoulder"
| "pov"
| "insert";
export type CameraMovement =
| "static"
| "pan"
| "tilt"
| "dolly"
| "zoom"
| "handheld"
| "tracking"
| "crane";
export interface DialogueLine {
/** stable key for React lists -- not part of the serialized prompt */
id: string;
/** speaker tag without parens, e.g. "S1" -- blank omits the "(S1)" prefix */
speaker: string;
/** spoken text only (speech tags allowed); wrapped in <d>[language] ...</d> on serialize */
text: string;
/** language marker written inside <d>; recovered on parse so non-English dialogue round-trips */
language: string;
}
/** A run of prose inside a shot (composition, action, environment, delivery notes). */
export interface TextPart {
kind: "text";
/** stable key for React lists -- not part of the serialized prompt */
id: string;
text: string;
}
/** One spoken line, written as `(S1) <d>[English] ...</d>`. Its position among the parts is where it plays. */
export interface LinePart {
kind: "line";
line: DialogueLine;
}
export type ShotPart = TextPart | LinePart;
export interface Shot {
/** stable key for React lists -- not part of the serialized prompt */
id: string;
type: ShotType;
movement: CameraMovement;
/** <Subject N> labels (from subject_definitions) that appear in this shot */
subjectIds: string[];
/**
* The shot's content in the order it reads: prose and spoken lines
* interleaved. This one list is the only record of where each line falls,
* so a `<d>` block stays mid-sentence if that is where it was written.
*/
body: ShotPart[];
/** cut-in time in seconds; null = computed from the preceding shots. Ignored for the opening shot. */
startSeconds: number | null;
/**
* The description carries its own lead-in (text opened from a hand-written
* prompt) so type/movement are not written and the wording is kept verbatim.
*/
freeLead: boolean;
}
export const SHOT_TYPES: { id: ShotType; label: string }[] = [
{ id: "establishing", label: "Establishing" },
{ id: "wide", label: "Wide" },
{ id: "medium", label: "Medium" },
{ id: "close-up", label: "Close-up" },
{ id: "extreme-close-up", label: "Extreme close-up" },
{ id: "over-the-shoulder", label: "Over-the-shoulder" },
{ id: "pov", label: "POV" },
{ id: "insert", label: "Insert" },
];
export const CAMERA_MOVEMENTS: { id: CameraMovement; label: string }[] = [
{ id: "static", label: "Static" },
{ id: "pan", label: "Pan" },
{ id: "tilt", label: "Tilt" },
{ id: "dolly", label: "Dolly" },
{ id: "zoom", label: "Zoom" },
{ id: "handheld", label: "Handheld" },
{ id: "tracking", label: "Tracking" },
{ id: "crane", label: "Crane" },
];
const SHOT_TYPE_LABEL = Object.fromEntries(SHOT_TYPES.map((t) => [t.id, t.label])) as Record<ShotType, string>;
const MOVEMENT_LABEL = Object.fromEntries(CAMERA_MOVEMENTS.map((m) => [m.id, m.label])) as Record<
CameraMovement,
string
>;
const SHOT_TYPE_BY_LABEL = Object.fromEntries(SHOT_TYPES.map((t) => [t.label.toLowerCase(), t.id])) as Record<
string,
ShotType
>;
const MOVEMENT_BY_LABEL = Object.fromEntries(
CAMERA_MOVEMENTS.map((m) => [m.label.toLowerCase(), m.id]),
) as Record<string, CameraMovement>;
let shotSeq = 0;
export function newShot(): Shot {
shotSeq += 1;
return {
id: `shot-${Date.now()}-${shotSeq}`,
type: "medium",
movement: "static",
subjectIds: [],
body: [newTextPart()],
startSeconds: null,
freeLead: false,
};
}
let partSeq = 0;
export function newTextPart(text = ""): TextPart {
partSeq += 1;
return { kind: "text", id: `text-${Date.now()}-${partSeq}`, text };
}
export function linePart(line: DialogueLine): LinePart {
return { kind: "line", line };
}
/** Every spoken line of the shot, in playback order. */
export function shotLines(shot: Shot): DialogueLine[] {
return shot.body.flatMap((p) => (p.kind === "line" ? [p.line] : []));
}
/** The shot's prose with the dialogue left out. */
export function shotProse(shot: Shot): string {
return shot.body
.flatMap((p) => (p.kind === "text" ? [p.text.trim()] : []))
.filter(Boolean)
.join(" ");
}
/** A body of one prose part followed by the given lines -- the common "description, then dialogue" shape. */
export function makeBody(description = "", lines: DialogueLine[] = []): ShotPart[] {
return [newTextPart(description), ...lines.map(linePart)];
}
export interface ShotInit extends Partial<Omit<Shot, "id" | "body">> {
description?: string;
lines?: DialogueLine[];
body?: ShotPart[];
}
/** Build a shot from a description and lines (or an explicit body). */
export function makeShot(init: ShotInit = {}): Shot {
const { description, lines, body, ...rest } = init;
return { ...newShot(), ...rest, body: body ?? makeBody(description ?? "", lines ?? []) };
}
let lineSeq = 0;
export function newLine(patch: Partial<Omit<DialogueLine, "id">> = {}): DialogueLine {
lineSeq += 1;
return { id: `line-${Date.now()}-${lineSeq}`, speaker: "", text: "", language: "English", ...patch };
}
/** Copy shots with brand-new ids so components keyed by id (which own their own text state) remount instead of showing stale edits. */
export function withFreshIds(shots: Shot[]): Shot[] {
return shots.map((s) => ({
...s,
id: newShot().id,
subjectIds: [...s.subjectIds],
body: s.body.map((p) =>
p.kind === "text" ? { ...p, id: newTextPart().id } : { kind: "line" as const, line: { ...p.line, id: newLine().id } },
),
}));
}
/** Ordered, de-duplicated <Subject N> labels mentioned in subject_definitions. */
export function parseSubjectLabels(subjectDefinitions: string): string[] {
const seen: string[] = [];
const re = /<Subject\s+\d+>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(subjectDefinitions))) {
if (!seen.includes(m[0])) seen.push(m[0]);
}
return seen;
}
/* ---------------------------------------------------------------- */
/* Cut times */
/* ---------------------------------------------------------------- */
const MIN_SHOT_SECONDS = 2;
const LINE_GAP_SECONDS = 0.5;
/** Ballpark seconds a shot with these spoken lines runs; drives auto cut times. */
export function estimateSpokenSeconds(texts: string[]): number {
const spoken = texts.filter((t) => t.trim());
const speech = spoken.reduce((n, t) => n + estimateDuration(t, SPEECH_RATES.Natural, 1).total, 0);
return Math.max(MIN_SHOT_SECONDS, speech + (spoken.length ? LINE_GAP_SECONDS : 0));
}
export function estimateShotSeconds(shot: Shot): number {
return estimateSpokenSeconds(shotLines(shot).map((l) => l.text));
}
/**
* Cut-in time of every shot: the opening shot is 0, later ones use their
* explicit time or follow the previous shot's estimated length. Times are
* kept strictly increasing, as the guide requires.
*/
export function resolveShotStarts(shots: Shot[]): number[] {
const starts: number[] = [];
shots.forEach((shot, i) => {
if (i === 0) {
starts.push(0);
return;
}
const prev = starts[i - 1];
let start = shot.startSeconds ?? prev + estimateShotSeconds(shots[i - 1]);
if (start <= prev) start = prev + 0.1;
starts.push(Math.round(start * 1000) / 1000);
});
return starts;
}
/** MM:SS.mmm, the format the guide uses for cut times ("At 00:03.500, ..."). */
export function formatCutTime(seconds: number): string {
const totalMs = Math.max(0, Math.round(seconds * 1000));
const pad = (n: number, w: number) => String(n).padStart(w, "0");
const totalSec = Math.floor(totalMs / 1000);
return `${pad(Math.floor(totalSec / 60), 2)}:${pad(totalSec % 60, 2)}.${pad(totalMs % 1000, 3)}`;
}
/** Accepts "3.5", "0:03.5", or "00:03.500"; returns seconds, or null if unreadable. */
export function parseCutTime(text: string): number | null {
const m = text.trim().match(/^(?:(\d+):)?(\d+(?:\.\d+)?)$/);
if (!m) return null;
return (m[1] ? Number(m[1]) * 60 : 0) + Number(m[2]);
}
/* ---------------------------------------------------------------- */
/* Serialize */
/* ---------------------------------------------------------------- */
/** `raw` keeps a line's text exactly as stored (hand-written shots); otherwise it is trimmed. */
function partText(part: ShotPart, raw = false): string {
if (part.kind === "text") return part.text;
const { speaker, language, text } = part.line;
if (!text.trim()) return "";
return `${speaker.trim() ? `(${speaker.trim()}) ` : ""}<d>[${language.trim() || "English"}] ${raw ? text : text.trim()}</d>`;
}
/** Concatenate pieces, adding one space only where neither side already has whitespace. */
function joinPieces(pieces: string[]): string {
let out = "";
for (const piece of pieces) {
if (!piece) continue;
if (out && !/\s$/.test(out) && !/^\s/.test(piece)) out += " ";
out += piece;
}
return out;
}
function serializeShot(shot: Shot, n: number, start: number): string {
// [Shot 1] has no timestamp; later shots mark their cut time.
const cut = n > 1 ? `At ${formatCutTime(start)}, ` : "";
if (shot.freeLead) {
// Text parts keep their exact whitespace, so hand-written wording round-trips byte for byte.
return `[Shot ${n}] ${cut}${joinPieces(shot.body.map((p) => partText(p, true)))}`.trim();
}
const body = shot.body
.map((p) => partText(p).trim())
.filter(Boolean)
.join(" ");
const label = SHOT_TYPE_LABEL[shot.type];
const movementPart = shot.movement === "static" ? "" : `, ${MOVEMENT_LABEL[shot.movement].toLowerCase()}`;
const lead =
n > 1
? `the shot cuts to ${/^[aeiou]/i.test(label) ? "an" : "a"} ${label === "POV" ? label : label.toLowerCase()} shot${movementPart}.`
: `${label} shot${movementPart}.`;
return [`[Shot ${n}] ${cut}${lead}`, body].filter(Boolean).join(" ");
}
/**
* Serialize the shot list into detailed_description's [Shot N] format:
* `[Shot 1] ...` then `[Shot N] At MM:SS.mmm, the shot cuts to ...`. Optional
* `preamble` (style notes that come before the first shot) is written first.
*/
export function detailedDescriptionFromShots(shots: Shot[], preamble = ""): string {
const starts = resolveShotStarts(shots);
return [preamble.trim(), ...shots.map((s, i) => serializeShot(s, i + 1, starts[i]))]
.filter(Boolean)
.join("\n");
}
/* ---------------------------------------------------------------- */
/* Parse */
/* ---------------------------------------------------------------- */
const CUT_TIME_RE = /^At (\d+):(\d{2})(?:\.(\d{1,3}))?,\s*/i;
// "[the shot cuts to] [a] <type> shot[, <movement>]." -- the lead-in this
// editor writes, recovered on parse so type/movement survive a reopen.
const LEAD_RE =
/^(?:(?:the (?:shot|camera) (?:cuts|transitions|changes|switches) to |cuts to )?(?:an? )?)([a-zA-Z -]+?) shot(?:,\s*([a-zA-Z]+))?\.\s*/i;
const DIALOGUE_RE = /(?:\(([^)]+)\)\s*)?<d>(?:\[([^\]]*)\]\s*)?([\s\S]*?)<\/d>/g;
/**
* Split shot text at its `<d>` blocks into an ordered body. `exact` keeps the
* surrounding text untouched (whitespace included) so it can be rebuilt
* byte for byte; otherwise prose is trimmed and empty runs dropped.
*/
function splitBody(text: string, exact: boolean): ShotPart[] {
const parts: ShotPart[] = [];
const pushText = (raw: string) => {
const t = exact ? raw : raw.trim();
if (t) parts.push(newTextPart(t));
};
let last = 0;
for (const m of text.matchAll(DIALOGUE_RE)) {
const start = m.index ?? 0;
pushText(text.slice(last, start));
parts.push(
linePart(
newLine({
speaker: m[1]?.trim() ?? "",
language: m[2]?.trim() || "English",
text: exact ? (m[3] ?? "") : (m[3] ?? "").trim(),
}),
),
);
last = start + m[0].length;
}
pushText(text.slice(last));
return parts;
}
function parseShotBlock(block: string, subjectLabels: string[]): { shot: Shot; time: number | null } {
const shot = newShot();
shot.subjectIds = subjectLabels.filter((label) => block.includes(label));
let rest = block;
let time: number | null = null;
const t = rest.match(CUT_TIME_RE);
if (t) {
time = Number(t[1]) * 60 + Number(t[2]) + (t[3] ? Number(`0.${t[3]}`) : 0);
rest = rest.slice(t[0].length);
}
const lead = rest.match(LEAD_RE);
const typeId = lead ? SHOT_TYPE_BY_LABEL[lead[1].trim().toLowerCase()] : undefined;
if (lead && typeId) {
shot.type = typeId;
const moveLabel = lead[2]?.trim().toLowerCase();
if (moveLabel && MOVEMENT_BY_LABEL[moveLabel]) shot.movement = MOVEMENT_BY_LABEL[moveLabel];
rest = rest.slice(lead[0].length);
shot.body = splitBody(rest, false);
} else {
// Hand-written wording: keep it exactly as written. Dialogue is still
// pulled out into editable lines, but each stays where it was written.
shot.freeLead = true;
shot.body = splitBody(rest, true);
}
if (shot.body.length === 0) shot.body = [newTextPart()];
return { shot, time };
}
/**
* Split an existing detailed_description back into shots so opening the
* builder on a job someone already wrote doesn't discard it. Shots are found
* by their line-start [Shot N] markers; anything before the first is kept as
* the preamble. Only wording this editor generated is decomposed into
* type/movement/dialogue -- anything else is kept verbatim.
*/
export function parseDetailedDescription(
text: string,
subjectLabels: string[],
): { preamble: string; shots: Shot[] } {
const markers = [...text.matchAll(/^\[Shot\s+\d+\][ \t]*/gm)];
if (markers.length === 0) {
const body = text.trim();
if (!body) return { preamble: "", shots: [] };
const { shot } = parseShotBlock(body, subjectLabels);
shot.freeLead = true;
shot.body = splitBody(body, true);
return { preamble: "", shots: [shot] };
}
const preamble = text.slice(0, markers[0].index).trim();
const parsed = markers.map((m, i) => {
const from = (m.index ?? 0) + m[0].length;
const to = i + 1 < markers.length ? (markers[i + 1].index ?? text.length) : text.length;
return parseShotBlock(text.slice(from, to).trim(), subjectLabels);
});
const shots = parsed.map((p) => p.shot);
// Only pin a cut time when it differs from what the editor would compute,
// so text this editor wrote keeps flowing as earlier shots change.
const starts: number[] = [0];
shots.forEach((shot, i) => {
if (i === 0) return;
const auto = starts[i - 1] + estimateShotSeconds(shots[i - 1]);
const time = parsed[i].time;
if (time == null) {
starts.push(auto);
return;
}
starts.push(time);
if (Math.abs(time - auto) > 0.001) shot.startSeconds = time;
});
return { preamble, shots };
}
export function shotsFromDetailedDescription(text: string, subjectLabels: string[]): Shot[] {
return parseDetailedDescription(text, subjectLabels).shots;
}
const RETENTION_LINE_RE = /^(<[^>]+>)\s*\(appears in [^)]*\):\s*(.*)$/gm;
const DEFAULT_RETENTION_NOTE = "fully_preserved - what is retained.";
/** label -> free-text retention note (everything after the colon), for lines this parser recognizes. */
function parseRetentionNotes(retentionAnalysis: string): Map<string, string> {
const notes = new Map<string, string>();
let m: RegExpExecArray | null;
RETENTION_LINE_RE.lastIndex = 0;
while ((m = RETENTION_LINE_RE.exec(retentionAnalysis))) notes.set(m[1], m[2].trim());
return notes;
}
/**
* Rebuild retention_analysis's shot-tracked lines from the shot list. Each
* subject's existing retention note (the free text after the colon) is kept
* untouched -- only the "(appears in [...])" shot list is regenerated, using
* a sensible default note for a subject that's newly shot-tracked. Anything
* this shot list doesn't own (untracked subjects, audio-reference lines,
* free text) passes through unchanged rather than being discarded.
*/
export function retentionAnalysisFromShots(
shots: Shot[],
subjectLabels: string[],
previousRetentionAnalysis: string,
): string {
const existingNotes = parseRetentionNotes(previousRetentionAnalysis);
const regenerated = new Set<string>();
const shotLines: string[] = [];
for (const label of subjectLabels) {
const shotNums = shots
.map((s, i) => (s.subjectIds.includes(label) ? i + 1 : null))
.filter((n): n is number => n !== null);
if (shotNums.length === 0) continue;
regenerated.add(label);
const note = existingNotes.get(label) ?? DEFAULT_RETENTION_NOTE;
shotLines.push(`${label} (appears in ${shotNums.map((n) => `[Shot ${n}]`).join(", ")}): ${note}`);
}
const passthroughLines = previousRetentionAnalysis
.split("\n")
.map((l) => l.trim())
.filter((line) => {
if (!line) return false;
const m = line.match(/^(<[^>]+>)\s*\(appears in/);
return !(m && regenerated.has(m[1]));
});
return [...shotLines, ...passthroughLines].join("\n");
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
applyH3Preset,
applyH3Tag,
H3_PRESETS,
H3_TAG_CATEGORIES,
H3_TAGS,
type H3Preset,
type H3Tag,
} from "@/lib/h3Tags";
const pointTag = H3_TAGS.find((t) => t.id === "pause") as H3Tag;
const wrapTag = H3_TAGS.find((t) => t.id === "emphasis") as H3Tag;
const emptyWrapTag = H3_TAGS.find((t) => t.id === "smacks-lips") as H3Tag;
describe("H3 tag catalogue", () => {
it("gives every tag a unique id", () => {
const ids = H3_TAGS.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("gives wrap tags a close tag and point tags none", () => {
for (const tag of H3_TAGS) {
if (tag.kind === "wrap") expect(tag.close).toBeTruthy();
else expect(tag.close).toBeUndefined();
}
});
it("flattens to the same tags the categories declare", () => {
const flattened = H3_TAG_CATEGORIES.flatMap((c) => c.tags);
expect(H3_TAGS).toEqual(flattened);
});
});
describe("applyH3Tag: point tags", () => {
it("inserts at the cursor with padding on both sides", () => {
const result = applyH3Tag("Okay, so. This is me.", 9, 9, pointTag);
expect(result.value).toBe("Okay, so. <pause> This is me.");
// cursor collapses to just past the inserted token
expect(result.value.slice(result.start, result.end)).toBe("");
expect(result.value.slice(0, result.start)).toBe("Okay, so. <pause> ".slice(0, result.start));
});
it("does not double up whitespace already present", () => {
const result = applyH3Tag("Okay, so. This is me.", 10, 10, pointTag);
expect(result.value).toBe("Okay, so. <pause> This is me.");
});
it("inserts cleanly at the very start and end of the text", () => {
expect(applyH3Tag("Hello.", 0, 0, pointTag).value).toBe("<pause> Hello.");
expect(applyH3Tag("Hello.", 6, 6, pointTag).value).toBe("Hello. <pause>");
});
it("inserts before a selection, keeping the selected text", () => {
const result = applyH3Tag("Hello there.", 6, 11, pointTag);
expect(result.value).toBe("Hello <pause> there.");
expect(result.value.slice(result.start, result.end)).toBe("there");
});
});
describe("applyH3Tag: wrap tags", () => {
it("wraps a selection and collapses the cursor after the close tag", () => {
const result = applyH3Tag("I was not expecting that.", 6, 9, wrapTag);
expect(result.value).toBe("I was <i>not</i> expecting that.");
expect(result.start).toBe(result.end);
expect(result.value.slice(0, result.start)).toBe("I was <i>not</i>");
});
it("inserts an empty open+close pair with the cursor between them when nothing is selected", () => {
const result = applyH3Tag("Don't tell anyone.", 0, 0, wrapTag);
expect(result.value).toBe("<i></i>Don't tell anyone.");
expect(result.start).toBe("<i>".length);
expect(result.end).toBe(result.start);
});
it("supports an intentionally empty pair, as used for a sentence-final effect", () => {
const result = applyH3Tag("Okay.", 0, 0, emptyWrapTag);
expect(result.value).toBe("<smacks lips></smacks lips>Okay.");
});
});
describe("applyH3Tag: out-of-range selection", () => {
it("clamps a selection beyond the text length", () => {
const result = applyH3Tag("Hi.", 10, 20, pointTag);
expect(result.value).toBe("Hi. <pause>");
});
});
describe("applyH3Preset", () => {
const insertPreset = H3_PRESETS.find((p) => p.label === "Nervous") as H3Preset;
const wrapPreset = H3_PRESETS.find((p) => p.label === "Intimate") as H3Preset;
it("inserts with a leading space when the cursor follows text", () => {
const r = applyH3Preset("Well", 4, 4, insertPreset);
expect(r.value).toBe("Well <uh> <stutter> ");
expect(r.start).toBe(r.value.length);
expect(r.end).toBe(r.value.length);
});
it("adds no leading space at the start or after whitespace", () => {
expect(applyH3Preset("", 0, 0, insertPreset).value).toBe("<uh> <stutter> ");
expect(applyH3Preset("Well ", 5, 5, insertPreset).value).toBe("Well <uh> <stutter> ");
});
it("wraps a selection and keeps it selected", () => {
const r = applyH3Preset("say it softly now", 4, 13, wrapPreset);
expect(r.value).toBe("say <whisper>it softly</whisper> now");
expect(r.value.slice(r.start, r.end)).toBe("it softly");
});
it("wraps an empty selection with the caret between the tags", () => {
const r = applyH3Preset("hi ", 3, 3, wrapPreset);
expect(r.value).toBe("hi <whisper></whisper>");
expect(r.start).toBe(r.end);
expect(r.value.slice(0, r.start)).toBe("hi <whisper>");
});
});
+409
View File
@@ -0,0 +1,409 @@
/**
* MiniMax-H3 speech tags: the inline markers the model recognizes inside
* `<d>[English] dialogue</d>` blocks (and elsewhere in a prompt) to shape
* delivery -- pauses, breath, emphasis, and non-verbal sounds. `applyH3Tag`
* is the pure insertion logic; the tag/category tables drive the console UI.
*/
export type H3TagKind = "point" | "wrap";
export interface H3Tag {
/** stable key for React lists and lookups */
id: string;
/** short button label, usually the bare tag name */
label: string;
description: string;
/** short usage example shown as a tooltip */
example: string;
kind: H3TagKind;
/** point: the literal token inserted, e.g. "<pause>". wrap: the opening tag. */
open: string;
/** wrap only: the closing tag, e.g. "</i>". */
close?: string;
/**
* Rough seconds this tag adds to spoken duration, used only for the
* dialogue editor's length estimate -- ballpark figures, not measured.
* Delivery-only tags (emphasis, whisper, softer) add ~0.
*/
seconds?: number;
}
export interface H3TagCategory {
id: string;
label: string;
tags: H3Tag[];
}
export const H3_TAG_CATEGORIES: H3TagCategory[] = [
{
id: "pace",
label: "Pace & breath",
tags: [
{
id: "pause",
label: "<pause>",
description: "Short pause",
example: "Okay, so. <pause> This is just me talking.",
kind: "point",
open: "<pause>",
seconds: 0.45,
},
{
id: "long-pause",
label: "<long pause>",
description: "Longer pause",
example: "I mean... <long pause> I don't even know.",
kind: "point",
open: "<long pause>",
seconds: 1.2,
},
{
id: "breath",
label: "<breath>",
description: "Breathing sound",
example: "And then... <breath> it just happened.",
kind: "point",
open: "<breath>",
seconds: 0.55,
},
{
id: "inhale",
label: "<inhale>",
description: "In-breath",
example: "<inhale> Alright, let's do this.",
kind: "point",
open: "<inhale>",
seconds: 0.5,
},
{
id: "exhale",
label: "<exhale>",
description: "Out-breath",
example: "<exhale> Okay then.",
kind: "point",
open: "<exhale>",
seconds: 0.6,
},
{
id: "catches-breath",
label: "<catches breath>",
description: "Out of breath",
example: "Wait... <catches breath> hold on a sec.",
kind: "point",
open: "<catches breath>",
seconds: 0.8,
},
{
id: "deep-breath",
label: "<deep breath>",
description: "Calming down",
example: "<deep breath> Okay. I can do this.",
kind: "point",
open: "<deep breath>",
seconds: 1.2,
},
{
id: "pant",
label: "<pant>",
description: "Panting (single)",
example: "Run... <pant> run now!",
kind: "point",
open: "<pant>",
seconds: 0.7,
},
{
id: "pants",
label: "<pants>",
description: "Panting (continued)",
example: "Run... <pants> run now!",
kind: "point",
open: "<pants>",
seconds: 0.7,
},
],
},
{
id: "delivery",
label: "Emphasis & delivery",
tags: [
{
id: "emphasis",
label: "<i>…</i>",
description: "Emphasize the wrapped words (1-4 words)",
example: "I was <i>not</i> expecting that.",
kind: "wrap",
open: "<i>",
close: "</i>",
seconds: 0,
},
{
id: "whisper",
label: "<whisper>…</whisper>",
description: "Whisper delivery",
example: "<whisper>Don't tell anyone this.</whisper>",
kind: "wrap",
open: "<whisper>",
close: "</whisper>",
seconds: 0,
},
{
id: "softer",
label: "<softer>",
description: "Quieter delivery",
example: "<softer> I don't think I can say it.",
kind: "point",
open: "<softer>",
seconds: 0,
},
{
id: "humming",
label: "<humming>…</humming>",
description: "Humming a tune",
example: "<humming>da-da-da-beautiful-day.</humming>",
kind: "wrap",
open: "<humming>",
close: "</humming>",
seconds: 2,
},
],
},
{
id: "vocal",
label: "Vocalizations",
tags: [
{
id: "laughs",
label: "<laughs>",
description: "Laughing",
example: "That's... <laughs> that's actually funny.",
kind: "point",
open: "<laughs>",
seconds: 1.1,
},
{
id: "chuckle",
label: "<chuckle>",
description: "Laughing (light)",
example: "That's... <chuckle> that's actually funny.",
kind: "point",
open: "<chuckle>",
seconds: 0.7,
},
{
id: "sighs",
label: "<sighs>",
description: "Sigh",
example: "<sighs> I really tried.",
kind: "point",
open: "<sighs>",
seconds: 0.85,
},
{
id: "gasp",
label: "<gasp>",
description: "Sharp intake",
example: "<gasp> Oh my God.",
kind: "point",
open: "<gasp>",
seconds: 0.5,
},
{
id: "phew",
label: "<phew>",
description: "Relief",
example: "<phew> That was close.",
kind: "point",
open: "<phew>",
seconds: 0.6,
},
{
id: "mhm",
label: "<mhm>",
description: "Agreement sound",
example: "Yeah, <mhm> exactly.",
kind: "point",
open: "<mhm>",
seconds: 0.4,
},
{
id: "uh",
label: "<uh>",
description: "Filler / hesitation",
example: "So, like... <uh> what was I saying?",
kind: "point",
open: "<uh>",
seconds: 0.35,
},
],
},
{
id: "body",
label: "Disfluency & body",
tags: [
{
id: "stutter",
label: "<stutter>",
description: "Stutters the words that follow",
example: "<stutter> I ca can't believe that.",
kind: "point",
open: "<stutter>",
seconds: 0.45,
},
{
id: "coughs",
label: "<coughs>",
description: "Cough",
example: "<coughs> Sorry, one sec.",
kind: "point",
open: "<coughs>",
seconds: 0.6,
},
{
id: "clears-throat",
label: "<clears throat>",
description: "Throat clear",
example: "<clears throat> So anyway...",
kind: "point",
open: "<clears throat>",
seconds: 0.8,
},
{
id: "sniff",
label: "<sniff>",
description: "Sniffing",
example: "<sniff> It's just... really sad.",
kind: "point",
open: "<sniff>",
seconds: 0.45,
},
{
id: "smacks-lips",
label: "<smacks lips>",
description:
"Lip smack; leave empty to trigger at the end of the sentence, or wrap words to place it there",
example: "<smacks lips></smacks lips> Okay.",
kind: "wrap",
open: "<smacks lips>",
close: "</smacks lips>",
seconds: 0.3,
},
],
},
];
export const H3_TAGS: H3Tag[] = H3_TAG_CATEGORIES.flatMap((c) => c.tags);
export interface H3TagApplyResult {
value: string;
/** selection to restore focus to after the insertion (collapsed unless text was wrapped). */
start: number;
end: number;
}
function needsLeadingSpace(before: string): boolean {
return before.length > 0 && !/\s$/.test(before);
}
function needsTrailingSpace(after: string): boolean {
return after.length > 0 && !/^\s/.test(after);
}
/**
* Insert `tag` into `value` at the given selection.
*
* Point tags are inserted at the selection start, padded with a space on
* either side where the surrounding text doesn't already have one, and the
* cursor collapses to just past the inserted token.
*
* Wrap tags wrap the current selection (cursor lands just past the closing
* tag), or insert an empty `open+close` pair with the cursor left between
* them when nothing is selected, ready to type into.
*/
export function applyH3Tag(
value: string,
selectionStart: number,
selectionEnd: number,
tag: H3Tag,
): H3TagApplyResult {
const start = Math.max(0, Math.min(selectionStart, value.length));
const end = Math.max(start, Math.min(selectionEnd, value.length));
const before = value.slice(0, start);
const selected = value.slice(start, end);
const after = value.slice(end);
if (tag.kind === "point") {
const lead = needsLeadingSpace(before) ? " " : "";
const trailingContext = selected.length > 0 ? selected : after;
const trail = needsTrailingSpace(trailingContext) ? " " : "";
const token = `${lead}${tag.open}${trail}`;
const cursor = before.length + token.length;
return {
value: before + token + selected + after,
start: cursor,
end: cursor + selected.length,
};
}
const close = tag.close ?? "";
if (selected.length > 0) {
const cursor = before.length + tag.open.length + selected.length + close.length;
return {
value: before + tag.open + selected + close + after,
start: cursor,
end: cursor,
};
}
const cursor = before.length + tag.open.length;
return {
value: before + tag.open + close + after,
start: cursor,
end: cursor,
};
}
/** One-click bundles of tags for common delivery beats (dialogue editor + shot list). */
export interface H3Preset {
label: string;
/** literal text inserted at the cursor (a leading space is added when needed). */
insert?: string;
/** wrap the selection between these two tokens instead of inserting. */
wrap?: [string, string];
}
export const H3_PRESETS: H3Preset[] = [
{ label: "Cracking", insert: "<catches breath> <stutter> " },
{ label: "Nervous", insert: "<uh> <stutter> " },
{ label: "Relief", insert: "<phew> <exhale> " },
{ label: "Cold turn", insert: "<long pause> <softer> " },
{ label: "Intimate", wrap: ["<whisper>", "</whisper>"] },
{ label: "Amused", insert: "<chuckle> " },
];
/** Pure counterpart of `applyH3Tag` for presets: same {value, selection} contract. */
export function applyH3Preset(
value: string,
selectionStart: number,
selectionEnd: number,
preset: H3Preset,
): H3TagApplyResult {
const start = Math.max(0, Math.min(selectionStart, value.length));
const end = Math.max(start, Math.min(selectionEnd, value.length));
const before = value.slice(0, start);
const mid = value.slice(start, end);
const after = value.slice(end);
if (preset.wrap) {
const [open, close] = preset.wrap;
const innerStart = before.length + open.length;
return {
value: before + open + mid + close + after,
start: innerStart,
end: innerStart + mid.length,
};
}
const lead = before.length && !/\s$/.test(before) ? " " : "";
const chunk = lead + (preset.insert ?? "");
const caret = before.length + chunk.length;
return { value: before + chunk + after, start: caret, end: caret };
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { parseJobJson } from "@/lib/jobJson";
const ok = (text: string) => {
const r = parseJobJson(text);
if (!r.ok) throw new Error(r.error);
return r;
};
describe("parseJobJson", () => {
it("joins a prompt given as lines and carries the settings over", () => {
const r = ok(
JSON.stringify({
model_id: "MiniMaxAI/MiniMax-H3",
name: "demo",
workload_type: "i2v",
prompt: ["summary:", "Hello.", "", "detailed_description:", "[Shot 1] A."],
references: [{ source: "/data/a.png", media_type: "image" }],
guidance_scale: 1,
num_frames: 243,
fps: 24,
num_gpus: 4,
use_fsdp_inference: true,
}),
);
expect(r.workloadType).toBe("i2v");
expect(r.job.prompt).toBe("summary:\nHello.\n\ndetailed_description:\n[Shot 1] A.");
expect(r.job).toMatchObject({
model_id: "MiniMaxAI/MiniMax-H3",
name: "demo",
guidance_scale: 1,
num_frames: 243,
num_gpus: 4,
use_fsdp_inference: true,
references: [{ source: "/data/a.png", media_type: "image" }],
});
});
it("accepts a plain string prompt", () => {
expect(ok(JSON.stringify({ model_id: "m", prompt: "one line" })).job.prompt).toBe("one line");
});
it("infers the workload when it isn't declared", () => {
expect(ok(JSON.stringify({ model_id: "m", prompt: "p" })).workloadType).toBe("t2v");
expect(
ok(JSON.stringify({ model_id: "m", prompt: "p", references: [{ source: "/a/b.mp4" }] })).workloadType,
).toBe("i2v");
expect(ok(JSON.stringify({ model_id: "m", prompt: "p", image_path: "/a.png" })).workloadType).toBe("i2v");
});
it("infers a reference's media type from its extension", () => {
const r = ok(
JSON.stringify({
model_id: "m",
prompt: "p",
references: [{ source: "/a/b.mp4" }, { source: "/a/c.wav" }, { source: "/a/d.png" }],
}),
);
expect(r.job.references?.map((x) => x.media_type)).toEqual(["video", "audio", "image"]);
});
it("ignores settings of the wrong type instead of passing them through", () => {
const r = ok(JSON.stringify({ model_id: "m", prompt: "p", num_frames: "many", use_fsdp_inference: "yes", extra: 1 }));
expect(r.job.num_frames).toBeUndefined();
expect(r.job.use_fsdp_inference).toBeUndefined();
expect(r.job).not.toHaveProperty("extra");
});
it.each([
["not json", "That file isn't valid JSON."],
["[1,2]", "Expected a JSON object describing one job."],
[JSON.stringify({ prompt: "p" }), "The job file has no model_id."],
[JSON.stringify({ model_id: "m", prompt: 5 }), "prompt must be text or a list of text lines."],
[JSON.stringify({ model_id: "m", prompt: "p", references: [{}] }), "references[0] needs a source path."],
])("rejects %s", (text, error) => {
expect(parseJobJson(text)).toEqual({ ok: false, error });
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* Read a job file (the `ref2va_*.json` shape) into the create-job form's
* job shape. Keys match the API's CreateJobRequest; `prompt` may be a string
* or a list of lines (joined with newlines), since job files keep it as lines
* for readability.
*/
import type { JobLike } from "@/lib/jobToFields";
export type JobJsonResult =
| { ok: true; job: JobLike; workloadType: "t2v" | "i2v" | "t2i" }
| { ok: false; error: string };
const WORKLOADS = ["t2v", "i2v", "t2i"] as const;
const MEDIA_TYPES = ["image", "video", "audio"] as const;
const VIDEO_EXT = /\.(mp4|mov|mkv|webm|avi)$/i;
const AUDIO_EXT = /\.(wav|mp3|flac|m4a|ogg)$/i;
const STRING_KEYS = ["name", "image_path", "last_image_path", "negative_prompt"] as const;
const NUMBER_KEYS = [
"num_inference_steps",
"num_frames",
"height",
"width",
"guidance_scale",
"guidance_rescale",
"fps",
"seed",
"num_gpus",
"vsa_sparsity",
"tp_size",
"sp_size",
] as const;
const BOOLEAN_KEYS = [
"dit_cpu_offload",
"dit_layerwise_offload",
"text_encoder_cpu_offload",
"vae_cpu_offload",
"image_encoder_cpu_offload",
"use_fsdp_inference",
"enable_torch_compile",
] as const;
function inferMediaType(source: string): (typeof MEDIA_TYPES)[number] {
if (VIDEO_EXT.test(source)) return "video";
if (AUDIO_EXT.test(source)) return "audio";
return "image";
}
export function parseJobJson(text: string): JobJsonResult {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return { ok: false, error: "That file isn't valid JSON." };
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return { ok: false, error: "Expected a JSON object describing one job." };
}
const data = raw as Record<string, unknown>;
if (typeof data.model_id !== "string" || !data.model_id.trim()) {
return { ok: false, error: "The job file has no model_id." };
}
let prompt: string;
if (typeof data.prompt === "string") prompt = data.prompt;
else if (Array.isArray(data.prompt) && data.prompt.every((l) => typeof l === "string")) {
prompt = (data.prompt as string[]).join("\n");
} else {
return { ok: false, error: "prompt must be text or a list of text lines." };
}
const references: { source: string; media_type: string }[] = [];
if (data.references !== undefined && data.references !== null) {
if (!Array.isArray(data.references)) return { ok: false, error: "references must be a list." };
for (const [i, ref] of data.references.entries()) {
const r = ref as Record<string, unknown> | null;
if (!r || typeof r.source !== "string" || !r.source) {
return { ok: false, error: `references[${i}] needs a source path.` };
}
const mediaType =
typeof r.media_type === "string" && (MEDIA_TYPES as readonly string[]).includes(r.media_type)
? r.media_type
: inferMediaType(r.source);
references.push({ source: r.source, media_type: mediaType });
}
}
const job: JobLike = { id: `imported-${Date.now()}`, model_id: data.model_id.trim(), prompt, references };
const target = job as unknown as Record<string, unknown>;
for (const key of STRING_KEYS) if (typeof data[key] === "string") target[key] = data[key];
for (const key of NUMBER_KEYS) if (typeof data[key] === "number" && Number.isFinite(data[key])) target[key] = data[key];
for (const key of BOOLEAN_KEYS) if (typeof data[key] === "boolean") target[key] = data[key];
const declared = (WORKLOADS as readonly string[]).includes(data.workload_type as string)
? (data.workload_type as (typeof WORKLOADS)[number])
: null;
const workloadType = declared ?? (references.length > 0 || job.image_path ? "i2v" : "t2v");
job.workload_type = workloadType;
return { ok: true, job, workloadType };
}
@@ -0,0 +1,250 @@
import { describe, expect, it } from "vitest";
import { UNNAMED_SCENE, buildScenes, clipSeconds, formatClock, parseSceneName, shotsOfJob } from "@/lib/scenes";
import { shotLines, shotProse } from "@/lib/h3Shots";
import type { Job } from "@/lib/types";
function job(over: Partial<Job> & { id: string }): Job {
return {
model_id: "m",
prompt: "",
status: "pending",
created_at: 0,
started_at: null,
finished_at: null,
error: null,
output_path: null,
log_file_path: null,
num_inference_steps: 50,
num_frames: 240,
fps: 24,
height: 768,
width: 1344,
guidance_scale: 1,
seed: 0,
num_gpus: 1,
progress: 0,
progress_msg: "",
phase: "",
...over,
};
}
const SECTIONS = (detailed: string, subjects = "<Subject 1> is a man.") =>
["subject_definitions:", subjects, "", "detailed_description:", detailed].join("\n");
describe("parseSceneName", () => {
it.each([
["wolf-lunch-clip-01", "wolf-lunch", 1],
["wolf-lunch-clip-15", "wolf-lunch", 15],
["closeup-fictional-actor-monologue-shot2", "closeup-fictional-actor-monologue", 2],
["Rooftop Standoff part 3", "Rooftop Standoff", 3],
["scene_a_07", "scene_a", 7],
])("%s -> stem %s, clip %i", (name, title, order) => {
expect(parseSceneName(name)).toEqual({ key: title.toLowerCase(), title, order });
});
it("treats a name with no trailing number as its own scene", () => {
expect(parseSceneName("Rooftop Standoff")).toEqual({ key: "rooftop standoff", title: "Rooftop Standoff", order: null });
});
it("keeps the word scene as part of the name", () => {
expect(parseSceneName("rooftop-scene-2")).toEqual({ key: "rooftop-scene", title: "rooftop-scene", order: 2 });
});
it("does not treat a bare number or number-only suffix as a stem", () => {
expect(parseSceneName("42").order).toBeNull();
expect(parseSceneName("clip-3").order).toBeNull();
});
it("puts blank or missing names in the unnamed group", () => {
expect(parseSceneName(undefined).key).toBe(UNNAMED_SCENE);
expect(parseSceneName(" ").key).toBe(UNNAMED_SCENE);
});
it("groups names that differ only in case", () => {
expect(parseSceneName("Wolf-Lunch-Clip-1").key).toBe(parseSceneName("wolf-lunch-clip-2").key);
});
});
describe("shotsOfJob", () => {
it("reads shots and dialogue out of a six-section prompt", () => {
const shots = shotsOfJob({
prompt: SECTIONS(
"[Shot 1] Wide shot. A dock.\n[Shot 2] At 00:02.000, the shot cuts to a close-up shot. (S1) <d>[English] Hi.</d>",
),
});
expect(shots).toHaveLength(2);
expect(shots[1].type).toBe("close-up");
expect(shotLines(shots[1])[0].text).toBe("Hi.");
expect(shots[0].subjectIds).toEqual([]);
});
it("reads shots out of a base-format integrated_multimodal_description", () => {
const shots = shotsOfJob({
prompt: "For the target video, <Picture 1> is used.\n\nintegrated_multimodal_description: [Shot 1] He (S1) says: <d>[English] Sorry.</d> <cutoff>\n\noverall_soundscape: Quiet.",
});
expect(shots).toHaveLength(1);
expect(shotLines(shots[0]).map((l) => l.text)).toEqual(["Sorry."]);
});
it("shows a plain prompt as a single shot", () => {
const shots = shotsOfJob({ prompt: "A toy car drives into a plush dog." });
expect(shots).toHaveLength(1);
expect(shotProse(shots[0])).toBe("A toy car drives into a plush dog.");
});
it("returns no shots for an empty prompt", () => {
expect(shotsOfJob({ prompt: "" })).toEqual([]);
});
});
describe("buildScenes", () => {
const three = [
job({ id: "c", name: "wolf-lunch-clip-03", created_at: 3, status: "pending", prompt: "[Shot 1] Wide shot. C." }),
job({ id: "a", name: "wolf-lunch-clip-01", created_at: 1, status: "completed", num_frames: 240, prompt: "[Shot 1] Wide shot. A.\n[Shot 2] At 00:02.000, the shot cuts to a close-up shot. A2." }),
job({ id: "b", name: "wolf-lunch-clip-02", created_at: 2, status: "completed", num_frames: 120, fps: 24, prompt: "[Shot 1] Close-up shot. B." }),
];
it("groups by stem and orders clips by their number, not creation order", () => {
const [scene] = buildScenes(three);
expect(scene.title).toBe("wolf-lunch");
expect(scene.clips.map((c) => c.job.id)).toEqual(["a", "b", "c"]);
expect(scene.clips.map((c) => c.position)).toEqual([1, 2, 3]);
});
it("lays every shot on one timeline using each clip's frames and fps", () => {
const [scene] = buildScenes(three);
expect(scene.clips.map((c) => c.startSeconds)).toEqual([0, 10, 15]);
expect(scene.totalSeconds).toBe(25); // 240/24 + 120/24 + 240/24 = 10 + 5 + 10
});
it("counts shots and statuses across the scene", () => {
const [scene] = buildScenes(three);
expect(scene.shotCount).toBe(4);
expect(scene.statusCounts).toEqual({ completed: 2, pending: 1 });
});
it("offsets each shot's cut time by the clip's start", () => {
const [scene] = buildScenes(three);
const second = scene.clips[1];
expect(second.shotStarts).toEqual([10]);
const first = scene.clips[0];
expect(first.shotStarts[0]).toBe(0);
expect(first.shotStarts[1]).toBe(2);
});
it("keeps separate scenes apart, newest first, with unnamed jobs last", () => {
const scenes = buildScenes([
job({ id: "1", name: "old-scene-1", created_at: 5 }),
job({ id: "2", name: "new-scene-1", created_at: 50 }),
job({ id: "3", name: "", created_at: 100 }),
]);
expect(scenes.map((s) => s.title)).toEqual(["new-scene", "old-scene", "Unnamed jobs"]);
});
it("puts clips without a number after numbered ones, oldest first", () => {
const [scene] = buildScenes([
job({ id: "x", name: "wolf-1", created_at: 9 }),
job({ id: "y", name: "wolf", created_at: 1 }),
job({ id: "z", name: "wolf-2", created_at: 2 }),
]);
// "wolf" shares the stem with wolf-1 / wolf-2, so it joins them -- last, having no number
expect(scene.clips.map((c) => c.job.id)).toEqual(["x", "z", "y"]);
});
it("flags a clip that continues the previous shot", () => {
const [scene] = buildScenes([
job({ id: "a", name: "s-1", prompt: "[Shot 1] Close-up shot. A speech. <cutoff>" }),
job({ id: "b", name: "s-2", prompt: "[Shot 1] Close-up shot, dolly. Continuing the same shot on <Subject 1>, still speaking." }),
job({ id: "c", name: "s-3", prompt: "summary:\n[reference generation] It continues the previous shot without a cut.\n\ndetailed_description:\n[Shot 1] Wide shot. X." }),
job({ id: "d", name: "s-4", prompt: "[Shot 1] Wide shot. Fresh." }),
]);
expect(scene.clips.map((c) => c.continuation)).toEqual([false, true, true, false]);
});
it("falls back to 24 fps when a job has none", () => {
expect(clipSeconds({ num_frames: 240, fps: undefined })).toBe(10);
expect(clipSeconds({ num_frames: 240, fps: 0 })).toBe(10);
expect(clipSeconds({ num_frames: 240, fps: 30 })).toBe(8);
});
it("returns no scenes for no jobs", () => {
expect(buildScenes([])).toEqual([]);
});
});
describe("formatClock", () => {
it("formats m:ss and h:mm:ss", () => {
expect(formatClock(0)).toBe("0:00");
expect(formatClock(9.6)).toBe("0:10");
expect(formatClock(65)).toBe("1:05");
expect(formatClock(3725)).toBe("1:02:05");
expect(formatClock(-4)).toBe("0:00");
});
});
describe("takes of the same clip", () => {
const take = (id: string, n: number, over: Partial<Job> = {}) =>
job({ id, name: `wolf-lunch-clip-0${n}`, prompt: `[Shot 1] Wide shot. ${id}.`, num_frames: 240, ...over });
it("counts re-runs of a clip number once, not as extra clips", () => {
const [scene] = buildScenes([
take("a1", 1, { created_at: 1, status: "completed" }),
take("a2", 1, { created_at: 2, status: "completed" }),
take("a3", 1, { created_at: 3, status: "completed" }),
take("b1", 2, { created_at: 4, status: "completed" }),
]);
expect(scene.clips).toHaveLength(2);
expect(scene.totalSeconds).toBe(20);
expect(scene.shotCount).toBe(2);
expect(scene.statusCounts).toEqual({ completed: 2 });
expect(scene.clips[0].takes.map((j) => j.id)).toEqual(["a1", "a2", "a3"]);
});
it("shows the most recent completed take, even over a newer failed one", () => {
const [scene] = buildScenes([
take("old", 1, { created_at: 1, status: "completed", finished_at: 10 }),
take("good", 1, { created_at: 2, status: "completed", finished_at: 20 }),
take("bad", 1, { created_at: 3, status: "failed", finished_at: 30 }),
]);
expect(scene.clips[0].job.id).toBe("good");
});
it("falls back to the newest take when none completed", () => {
const [scene] = buildScenes([
take("p1", 1, { created_at: 1, status: "failed" }),
take("p2", 1, { created_at: 2, status: "pending" }),
]);
expect(scene.clips[0].job.id).toBe("p2");
});
it("shows the take the user chose, and lays the timeline out from it", () => {
const jobs = [
take("short", 1, { created_at: 1, status: "completed", num_frames: 120 }),
take("long", 1, { created_at: 2, status: "completed", num_frames: 480 }),
take("next", 2, { created_at: 3 }),
];
const [auto] = buildScenes(jobs);
expect(auto.clips[0].job.id).toBe("long");
expect(auto.clips[1].startSeconds).toBe(20);
const [chosen] = buildScenes(jobs, { [auto.clips[0].takeKey as string]: "short" });
expect(chosen.clips[0].job.id).toBe("short");
expect(chosen.clips[1].startSeconds).toBe(5);
});
it("ignores a choice that isn't one of the takes", () => {
const jobs = [take("a", 1, { status: "completed" })];
const [scene] = buildScenes(jobs, { "wolf-lunch#1": "no-such-job" });
expect(scene.clips[0].job.id).toBe("a");
});
it("keeps each unnumbered job as its own clip", () => {
const [scene] = buildScenes([
job({ id: "u1", name: "solo", created_at: 1 }),
job({ id: "u2", name: "solo", created_at: 2 }),
]);
expect(scene.clips.map((c) => c.job.id)).toEqual(["u1", "u2"]);
expect(scene.clips.every((c) => c.takeKey === null)).toBe(true);
});
});
+182
View File
@@ -0,0 +1,182 @@
/**
* Group jobs into scenes and lay their shots out on one timeline.
*
* A long scene is generated as many short clips, one job each. Nothing on the
* server links them, so the link is the job name: `wolf-lunch-clip-01`,
* `wolf-lunch-clip-02`, ... share the stem `wolf-lunch` and are ordered by the
* trailing number. Everything else -- shots, dialogue, subjects -- is read back
* out of each job's own prompt, so no backend support is needed.
*/
import { readField, shotTarget } from "@/lib/h3PromptFields";
import {
parseDetailedDescription,
parseSubjectLabels,
resolveShotStarts,
shotProse,
type Shot,
} from "@/lib/h3Shots";
import type { Job } from "@/lib/types";
export const UNNAMED_SCENE = "__unnamed__";
const DEFAULT_FPS = 24;
// "<stem>[-_. ][clip|part|pt|shot|take][-_. ]<number>". "scene" is deliberately not
// a clip word: it is far more likely part of the scene's own name ("rooftop-scene-2").
const CLIP_SUFFIX_RE = /^(.*?)[\s._-]*(?:clip|part|pt|shot|take)?[\s._-]*(\d+)$/i;
export interface SceneName {
key: string;
title: string;
/** the clip number from the end of the name, or null when there isn't one */
order: number | null;
}
export function parseSceneName(name: string | undefined | null): SceneName {
const trimmed = (name ?? "").trim();
if (!trimmed) return { key: UNNAMED_SCENE, title: "Unnamed jobs", order: null };
const m = trimmed.match(CLIP_SUFFIX_RE);
const stem = m ? m[1].replace(/[\s._-]+$/, "") : "";
if (m && stem) return { key: stem.toLowerCase(), title: stem, order: Number(m[2]) };
return { key: trimmed.toLowerCase(), title: trimmed, order: null };
}
/** The shots a job's prompt describes, wherever they live in it (base, reference, or plain prompt). */
export function shotsOfJob(job: Pick<Job, "prompt">): Shot[] {
const prompt = job.prompt ?? "";
const labels = parseSubjectLabels(readField(prompt, "subject_definitions") ?? "");
return parseDetailedDescription(shotTarget(prompt).body, labels).shots;
}
export interface SceneClip {
/** the take shown for this clip */
job: Job;
/**
* Every job that claims this clip number, oldest first. Re-running a clip
* gives the same name again, so these are takes of one clip, not extra clips.
*/
takes: Job[];
/** identifies this clip for choosing a take; null for a clip with no number */
takeKey: string | null;
/** 1-based place in the scene */
position: number;
shots: Shot[];
/** seconds from the start of the scene to the start of this clip */
startSeconds: number;
durationSeconds: number;
/** each shot's cut-in time measured from the start of the scene */
shotStarts: number[];
/** this clip picks up in the middle of the previous clip's last shot */
continuation: boolean;
}
export interface Scene {
key: string;
title: string;
clips: SceneClip[];
totalSeconds: number;
shotCount: number;
statusCounts: Record<string, number>;
/** most recent created_at among the clips, for ordering scenes */
latest: number;
}
export function clipSeconds(job: Pick<Job, "num_frames" | "fps">): number {
const fps = job.fps && job.fps > 0 ? job.fps : DEFAULT_FPS;
return Math.max(0, (job.num_frames ?? 0) / fps);
}
function isContinuation(job: Job, shots: Shot[]): boolean {
if (/continues the previous shot/i.test(job.prompt ?? "")) return true;
return shots.length > 0 && /^Continuing the same shot/i.test(shotProse(shots[0]));
}
function createdAt(job: Job): number {
return job.created_at ?? 0;
}
/** The take to show: the one the user chose, else the most recent completed one, else the newest. */
function pickTake(takes: Job[], choice?: string): Job {
const chosen = choice ? takes.find((j) => j.id === choice) : undefined;
if (chosen) return chosen;
const recent = (a: Job, b: Job) => (b.finished_at ?? createdAt(b)) - (a.finished_at ?? createdAt(a));
const completed = takes.filter((j) => j.status === "completed").sort(recent);
return completed[0] ?? [...takes].sort((a, b) => createdAt(b) - createdAt(a))[0];
}
/**
* Group inference jobs into scenes, each with its clips in order and a
* scene-wide timeline. `choices` maps a clip's `takeKey` to the job id of the
* take to show in place of the default.
*/
export function buildScenes(jobs: Job[], choices: Record<string, string> = {}): Scene[] {
const groups = new Map<string, { title: string; entries: { job: Job; order: number | null }[] }>();
for (const job of jobs) {
const { key, title, order } = parseSceneName(job.name);
const group = groups.get(key) ?? { title, entries: [] };
group.entries.push({ job, order });
groups.set(key, group);
}
const scenes: Scene[] = [];
for (const [key, { title, entries }] of groups) {
// One clip per clip number (its takes together); each unnumbered job stands alone, after the numbered ones.
const byNumber = new Map<number, Job[]>();
const unnumbered: Job[][] = [];
for (const { job, order } of entries) {
if (order === null) unnumbered.push([job]);
else byNumber.set(order, [...(byNumber.get(order) ?? []), job]);
}
const slots: { takes: Job[]; takeKey: string | null }[] = [
...[...byNumber.entries()].sort(([a], [b]) => a - b).map(([order, takes]) => ({ takes, takeKey: `${key}#${order}` })),
...unnumbered.sort((a, b) => createdAt(a[0]) - createdAt(b[0])).map((takes) => ({ takes, takeKey: null })),
];
let elapsed = 0;
const statusCounts: Record<string, number> = {};
const clips: SceneClip[] = slots.map(({ takes, takeKey }, i) => {
const ordered = [...takes].sort((a, b) => createdAt(a) - createdAt(b));
const job = pickTake(ordered, takeKey ? choices[takeKey] : undefined);
const shots = shotsOfJob(job);
const durationSeconds = clipSeconds(job);
const clip: SceneClip = {
job,
takes: ordered,
takeKey,
position: i + 1,
shots,
startSeconds: elapsed,
durationSeconds,
shotStarts: resolveShotStarts(shots).map((t) => elapsed + t),
continuation: isContinuation(job, shots),
};
elapsed += durationSeconds;
statusCounts[job.status] = (statusCounts[job.status] ?? 0) + 1;
return clip;
});
scenes.push({
key,
title,
clips,
totalSeconds: elapsed,
shotCount: clips.reduce((n, c) => n + c.shots.length, 0),
statusCounts,
latest: Math.max(0, ...entries.map((e) => createdAt(e.job))),
});
}
// Newest scene first; the catch-all group of unnamed jobs goes last.
return scenes.sort((a, b) => {
if ((a.key === UNNAMED_SCENE) !== (b.key === UNNAMED_SCENE)) return a.key === UNNAMED_SCENE ? 1 : -1;
return b.latest - a.latest;
});
}
/** m:ss, or h:mm:ss past an hour -- for clip ranges and runtimes. */
export function formatClock(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const sec = String(total % 60).padStart(2, "0");
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
}
+2
View File
@@ -18,6 +18,8 @@ export interface Job {
log_file_path: string | null;
num_inference_steps: number;
num_frames: number;
/** frames per second; the API sends it, older cached objects may not have it */
fps?: number;
height: number;
width: number;
guidance_scale: number;
+17
View File
@@ -16,6 +16,23 @@ if (!globalThis.crypto?.randomUUID) {
});
}
// jsdom's Blob/File predate Blob.text(); real browsers all have it.
if (!Blob.prototype.text) {
Blob.prototype.text = function text(this: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsText(this);
});
};
}
// jsdom doesn't implement scrollIntoView.
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = vi.fn();
}
if (!URL.createObjectURL) {
URL.createObjectURL = vi.fn(() => 'blob:mock-url');
}