Add dialogue/shot editor
This commit is contained in:
@@ -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'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't match MiniMax'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'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 "NAME: line" 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'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's last shot, cut short because that speech didn't
|
||||
fit one generation. Generate it from the previous clip'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'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 <d> 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'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("");
|
||||
});
|
||||
});
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user