Add last-clip, cutting, color grading

This commit is contained in:
kevin314
2026-09-28 11:47:24 +00:00
parent d377f463f1
commit fab9b3159c
21 changed files with 2417 additions and 58 deletions
+45 -7
View File
@@ -18,6 +18,7 @@ apps/fastvideo_studio/
├── server.py / job_runner.py / database.py # FastAPI backend + job lifecycle
├── job_queue.py # Queue scheduling rules + last-frame references (GPU-free)
├── merge.py # Join a scene's clips into one video (ffmpeg stream copy, GPU-free)
├── trim.py # Cut a clip to an in/out range, non-destructively (GPU-free)
├── mock_server.py # In-memory API mock for e2e tests
├── models/ # Pydantic request models (shared with the mock)
├── training_config.py # Studio workloads → fastvideo/train YAML configs
@@ -104,13 +105,23 @@ once, not per job). A worker holds one model at a time: a job with a different
model or settings replaces it, and a failed or stopped job unloads it so the next
one starts clean. Each extra worker loads its own copy of the model.
A job can start from another job's last frame by holding a reference whose
source is `job-last-frame:<job id>`. The frame is extracted when the job runs,
so the reference can be added before that job has finished, and the queue holds
the job until it has. If that job fails, is stopped, or is deleted, the waiting
job fails with a message saying so (and so does anything waiting on *it*); fix
the earlier job and queue them again. Starting such a job directly while its
source is unfinished is refused with a 409.
A job can start from how another job's video ended, two ways:
* **Last frame** -- a reference whose source is `job-last-frame:<job id>`. A
hard, exact opening frame: the prompt is rewritten to say the shot begins
from it.
* **Last clip** -- a reference whose source is `job-last-clip:<job id>`, and
`media_type: "video"`. The trailing ~1s of the source job's video instead of
a single still, so the model has real motion to continue rather than a pose
to guess it from. Costs one of H3's 3 video-reference slots instead of one
of its 9 image slots.
Either is extracted when the job runs, so the reference can be added before
that job has finished, and the queue holds the job until it has. If that job
fails, is stopped, or is deleted, the waiting job fails with a message saying
so (and so does anything waiting on *it*); fix the earlier job and queue them
again. Starting such a job directly while its source is unfinished is refused
with a 409.
### Merging a scene
@@ -126,6 +137,29 @@ last video frame, because H3's audio runs a few milliseconds longer and would
otherwise leave a small gap at every join. It uses `ffmpeg` from `PATH`,
`$FASTVIDEO_FFMPEG_BIN`, or the copy bundled with `imageio-ffmpeg`.
### Editing a clip's video
**Edit video** on a finished clip in Scenes cuts it to a chosen in/out range and
adjusts its brightness, contrast and saturation, in place -- other jobs'
last-frame/last-clip references, and a later merge, all just read the job's
`output_path`, so nothing else needs to know a clip was edited. Frame-accurate
(re-encoded, not stream-copied), so the cut isn't limited to the source's
keyframes. Audio is cut to the same range and carried over too (re-encoded to
AAC regardless of the source's codec), unless the source has none.
The untouched original is always kept alongside it the first time a clip is
edited, and **every edit always re-renders from that original** with the full
set of values shown in the dialog -- range and color together, in one pass.
That's what lets adjusting either one leave the other in place instead of each
one silently discarding the other, and it's also why color always reopens at
neutral (0 brightness, 1 contrast, 1 saturation): nothing is stored server-side
about which values produced the current video, only the video itself. The range
fields do reopen at the clip's current length, so leaving color alone and
re-applying keeps a previous trim -- unless that trim didn't start at 0, in
which case reopening shows `0` to the current length rather than the original
window, and re-applying would shift it. **Restore original** undoes every edit
in one step and is the reliable way to start over.
### API Endpoints
| Method | Path | Description |
@@ -139,7 +173,11 @@ otherwise leave a small gap at every join. It uses `ffmpeg` from `PATH`,
| `POST` | `/api/jobs/queue` | Queue jobs (`{"job_ids": [...]}`), in order |
| `POST` | `/api/jobs/{id}/queue` | Queue one job |
| `POST` | `/api/jobs/{id}/dequeue` | Take a queued job back out of the queue |
| `POST` | `/api/jobs/{id}/last-frame` | Save the job's last frame, for another job to start from |
| `POST` | `/api/jobs/{id}/last-clip` | Save the job's trailing ~1s, for another job to continue from |
| `POST` | `/api/scenes/merge` | Join finished jobs' videos (`{"job_ids": [...], "name": ""}`) in order |
| `POST` | `/api/jobs/{id}/trim` | Cut a job's video to a range and/or adjust its color (`{"start_seconds": 0, "end_seconds": null, "brightness": 0, "contrast": 1, "saturation": 1}`), in place |
| `POST` | `/api/jobs/{id}/restore-video`| Undo every edit, back to the original video |
| `GET` | `/api/merged/{filename}` | Stream a merged scene video |
| `DELETE` | `/api/jobs/{id}` | Delete a job |
| `GET` | `/api/jobs/{id}/video` | Stream the generated video/image |
+80 -3
View File
@@ -1,13 +1,18 @@
# SPDX-License-Identifier: Apache-2.0
"""Save the final frame of a finished clip, so the next clip can start from it.
"""Save the end of a finished clip, so the next clip can continue from it.
Kept apart from ``server.py`` because it needs nothing from FastVideo -- only
PyAV -- and so can be exercised without a GPU.
Two forms: the final frame alone (a still, for a hard "opens on this exact
picture" continuation), or a short trailing clip (a few hundred milliseconds of
video, attached as an H3 video reference so the model has actual motion to
continue rather than guessing it from one still). Kept apart from ``server.py``
because it needs nothing from FastVideo -- only PyAV -- and so can be exercised
without a GPU.
"""
from __future__ import annotations
import os
from fractions import Fraction
from pathlib import Path
from typing import Any
@@ -67,3 +72,75 @@ def last_frame_for_job(job: Any, upload_dir: str) -> str:
except Exception as e:
raise FrameError(500, f"Could not read the last frame: {e}") from e
return out_path
DEFAULT_LAST_CLIP_SECONDS = 1.0
def save_last_clip(video_path: str, out_path: str, seconds: float = DEFAULT_LAST_CLIP_SECONDS) -> None:
"""Decode the final ``seconds`` of ``video_path`` and re-encode them to ``out_path``.
Re-encoded rather than stream-copied: the source's keyframes rarely align
with an arbitrary cut ``seconds`` from the end, and a copy can only start on
one. Silent -- the trailing video is for motion continuity; voice continuity
already comes from each job's own audio reference.
"""
import av
if seconds <= 0:
raise ValueError(f"seconds must be positive, got {seconds}.")
tmp_path = f"{out_path}.tmp.mp4"
with av.open(video_path) as src:
video_stream = src.streams.video[0]
fps = float(video_stream.average_rate or video_stream.guessed_rate or 24)
frames = list(src.decode(video_stream))
if not frames:
raise ValueError("The video has no frames.")
keep = max(1, round(seconds * fps))
tail = frames[-keep:]
with av.open(tmp_path, mode="w") as out:
out_stream = out.add_stream("h264", rate=round(fps))
out_stream.width, out_stream.height = tail[0].width, tail[0].height
out_stream.pix_fmt = "yuv420p"
# Each frame needs its own increasing pts at a known time base, or PyAV
# stamps every frame identically (pts=0), which corrupts the muxed
# container's average-frame-rate metadata -- H3's reference pipeline
# trusts that metadata to resample the clip, so a wrong rate there
# silently collapses a short clip down to as little as one frame.
frame_time_base = Fraction(1, round(fps))
for i, frame in enumerate(tail):
frame.pts = i
frame.time_base = frame_time_base
for packet in out_stream.encode(frame):
out.mux(packet)
for packet in out_stream.encode():
out.mux(packet)
os.replace(tmp_path, out_path) # atomic, so a reader never sees a half-written file
def last_clip_for_job(job: Any, upload_dir: str, seconds: float = DEFAULT_LAST_CLIP_SECONDS) -> str:
"""Path of a short video of the end of ``job``'s video, extracting it if needed.
Raises :class:`FrameError` with the status the API should return.
"""
status = getattr(job.status, "value", job.status)
if status != "completed" or not job.output_path:
raise FrameError(404, "No output available for this job")
if not os.path.isfile(job.output_path):
raise FrameError(404, "Output file not found on disk")
if Path(job.output_path).suffix.lower() not in VIDEO_EXTENSIONS:
raise FrameError(400, "This job's output is not a video")
if not upload_dir:
raise FrameError(503, "Upload directory not configured")
out_dir = os.path.join(upload_dir, "last_clips")
os.makedirs(out_dir, exist_ok=True)
out_path = os.path.abspath(os.path.join(out_dir, f"last_clip_{job.id}.mp4"))
if not (os.path.isfile(out_path) and os.path.getmtime(out_path) >= os.path.getmtime(job.output_path)):
try:
save_last_clip(job.output_path, out_path, seconds)
except Exception as e:
raise FrameError(500, f"Could not read the end of the clip: {e}") from e
return out_path
+46 -23
View File
@@ -1,14 +1,17 @@
# SPDX-License-Identifier: Apache-2.0
"""Scheduling rules for queued jobs, and last-frame references between them.
"""Scheduling rules for queued jobs, and references to another job's ending.
Kept apart from ``job_runner.py`` because it is pure -- it works on plain
snapshots and needs neither FastVideo nor a GPU -- so it can be tested anywhere.
A job can start from the last frame of another job's video. That frame doesn't
exist until the other job finishes, so the reference is *deferred*: its source
is ``job-last-frame:<job id>`` and it is resolved to a real image only when the
job runs. That link is also the queue's dependency graph: a queued job waits
until every job it takes a frame from has completed.
A job can start from how another job's video ended -- either its last frame (a
still, for a hard "opens on this exact picture" continuation) or its last
second or so (a short video, so the model has actual motion to continue, not
just a pose to guess motion from). Neither exists until the other job
finishes, so the reference is *deferred*: its source is ``job-last-frame:<job
id>`` or ``job-last-clip:<job id>``, resolved to a real file only when the job
runs. That link is also the queue's dependency graph: a queued job waits until
every job it takes an ending from has completed.
"""
from __future__ import annotations
@@ -18,6 +21,8 @@ from dataclasses import dataclass, field
from typing import Any, Protocol
DEFERRED_LAST_FRAME_PREFIX = "job-last-frame:"
DEFERRED_LAST_CLIP_PREFIX = "job-last-clip:"
_DEFERRED_PREFIXES = (DEFERRED_LAST_FRAME_PREFIX, DEFERRED_LAST_CLIP_PREFIX)
QUEUED = "queued"
RUNNING = "running"
@@ -31,11 +36,18 @@ def deferred_last_frame_source(job_id: str) -> str:
return f"{DEFERRED_LAST_FRAME_PREFIX}{job_id}"
def deferred_last_clip_source(job_id: str) -> str:
return f"{DEFERRED_LAST_CLIP_PREFIX}{job_id}"
def dependency_of(reference: Mapping[str, Any] | None) -> str | None:
"""The job a deferred reference takes its frame from, or None for any other."""
"""The job a deferred reference takes its ending from, or None for any other."""
source = (reference or {}).get("source")
if isinstance(source, str) and source.startswith(DEFERRED_LAST_FRAME_PREFIX):
return source[len(DEFERRED_LAST_FRAME_PREFIX):] or None
if not isinstance(source, str):
return None
for prefix in _DEFERRED_PREFIXES:
if source.startswith(prefix):
return source[len(prefix):] or None
return None
@@ -127,10 +139,10 @@ def plan_dispatch(jobs: Mapping[str, JobView], max_concurrent: int) -> Plan:
for dep_id in dependencies(job.references):
dep = jobs.get(dep_id)
if dep is None:
plan.fail[job.id] = "Waiting on the last frame of a job that was deleted."
plan.fail[job.id] = "Waiting on the ending of a job that was deleted."
elif dep_id in plan.fail or status[dep_id] in (FAILED, STOPPED):
reason = "failed" if dep_id in plan.fail or status[dep_id] == FAILED else "was stopped"
plan.fail[job.id] = f"Needs the last frame of {_label(dep)}, which {reason}."
plan.fail[job.id] = f"Needs the ending of {_label(dep)}, which {reason}."
else:
continue
status[job.id] = FAILED
@@ -150,35 +162,46 @@ def plan_dispatch(jobs: Mapping[str, JobView], max_concurrent: int) -> Plan:
class UnresolvedReference(ValueError):
"""A deferred reference can't be turned into an image."""
"""A deferred reference can't be turned into a real file."""
def resolve_references(
references: list[dict[str, Any]] | None,
get_job: Callable[[str], Any],
last_frame: Callable[[Any], str],
last_clip: Callable[[Any], str] | None = None,
) -> list[dict[str, Any]]:
"""Copy of ``references`` with each deferred last-frame reference made real.
"""Copy of ``references`` with each deferred reference made real.
``last_frame(job)`` returns the path of an extracted frame and may raise
(see ``frames.FrameError``); its message is passed on. Other references are
returned unchanged, and the stored list is never modified, so a re-run
resolves again against the source job's current output.
``last_frame(job)`` and ``last_clip(job)`` each return the path of an
extracted file and may raise (see ``frames.FrameError``); the message is
passed on. ``last_clip`` is optional so callers that never attach a
trailing-clip reference (e.g. the mock server) don't need to supply one --
it is only required if a ``job-last-clip:`` reference is actually present.
Other references are returned unchanged, and the stored list is never
modified, so a re-run resolves again against the source job's current
output.
"""
resolved: list[dict[str, Any]] = []
for ref in references or []:
dep_id = dependency_of(ref)
if dep_id is None:
source = (ref or {}).get("source")
prefix = next((p for p in _DEFERRED_PREFIXES if isinstance(source, str) and source.startswith(p)), None)
if prefix is None:
resolved.append(ref)
continue
dep = get_job(dep_id)
dep_id = source[len(prefix):] or None
extract = last_frame if prefix == DEFERRED_LAST_FRAME_PREFIX else last_clip
what = "the last frame of" if prefix == DEFERRED_LAST_FRAME_PREFIX else "the ending of"
if extract is None:
raise UnresolvedReference("This server can't resolve a trailing-clip reference yet.")
dep = get_job(dep_id) if dep_id else None
if dep is None:
raise UnresolvedReference("A reference needs the last frame of a job that no longer exists.")
raise UnresolvedReference(f"A reference needs {what} a job that no longer exists.")
if _status(dep) != COMPLETED:
raise UnresolvedReference(f"A reference needs the last frame of {_label(dep)}, which hasn't finished "
raise UnresolvedReference(f"A reference needs {what} {_label(dep)}, which hasn't finished "
f"(it is {_status(dep)}).")
try:
path = last_frame(dep)
path = extract(dep)
except Exception as exc: # FrameError carries a readable .detail
raise UnresolvedReference(str(getattr(exc, "detail", None) or exc)) from exc
resolved.append({**ref, "source": path})
+64 -6
View File
@@ -30,7 +30,8 @@ import yaml
from fastvideo.utils import get_mp_context
from fastvideo_studio.database import Database
from fastvideo_studio.frames import last_frame_for_job
from fastvideo_studio.frames import last_clip_for_job, last_frame_for_job
from fastvideo_studio.trim import restore_original, trim_video
from fastvideo_studio.job_queue import (
UnresolvedReference,
circular_dependency,
@@ -858,10 +859,10 @@ class JobRunner:
for dep_id in dependencies(job.references):
dep = self.get_job(dep_id)
if dep is None:
raise ValueError("This job starts from the last frame of a job that no longer exists.")
raise ValueError("This job starts from the ending of a job that no longer exists.")
if dep.status != JobStatus.COMPLETED:
label = f"'{dep.name}'" if dep.name else dep.id
raise ValueError(f"This job starts from the last frame of {label}, which hasn't finished "
raise ValueError(f"This job starts from the ending of {label}, which hasn't finished "
f"(it is {dep.status.value}). Queue it to run after that job.")
# -- queue ---------------------------------------------------------------
@@ -978,6 +979,62 @@ class JobRunner:
logger.info("Stop requested for job %s", job.id)
return job
# -- trimming --------------------------------------------------------------
def _set_output(self, job: Job, output_path: str, num_frames: int) -> None:
"""Point a completed job at a different output file, e.g. after trimming.
Bypasses update_job_config()'s pending/failed/stopped gate on purpose:
a completed job's *generation* config stays fixed, but which cut of its
own video is showing is not a generation setting.
"""
job.output_path = output_path
job.num_frames = num_frames
self._save_job(job)
try:
self._db.update_job_config(job.id, {"num_frames": num_frames})
except Exception as exc:
logger.warning("Failed to persist trimmed frame count for job %s: %s", job.id, exc)
def trim_job(
self,
job_id: str,
start_seconds: float = 0.0,
end_seconds: float | None = None,
brightness: float = 0.0,
contrast: float = 1.0,
saturation: float = 1.0,
) -> Job:
"""Cut a completed job's video to [start_seconds, end_seconds) and adjust its color, in place.
The untouched original is kept, so this can be called again with a
different range and/or color -- it always renders from that original
with the full set of parameters given, never compounding quality loss
or discarding one edit by changing the other -- or undone with
restore_job_video().
"""
with self._jobs_lock:
job = self._jobs.get(job_id)
if job is None:
raise ValueError(f"Job {job_id} not found")
path, kept = trim_video(job, start_seconds, end_seconds, brightness, contrast, saturation)
self._set_output(job, path, kept)
logger.info("Edited job %s: %.2f-%s s, brightness=%.0f contrast=%.2f saturation=%.2f (%d frames)", job.id,
start_seconds, f"{end_seconds:.2f}" if end_seconds is not None else "end", brightness, contrast,
saturation, kept)
return job
def restore_job_video(self, job_id: str) -> Job:
"""Undo every trim on a job's video, back to what it originally generated."""
with self._jobs_lock:
job = self._jobs.get(job_id)
if job is None:
raise ValueError(f"Job {job_id} not found")
path, kept = restore_original(job)
self._set_output(job, path, kept)
logger.info("Restored job %s to its original video", job.id)
return job
def get_job_logs(self, job_id: str, after: int = 0) -> dict[str, Any]:
"""Return log lines for a job.
@@ -1300,12 +1357,13 @@ class JobRunner:
logger.warning("Job stopped before execution started")
return
# Frames borrowed from other clips are extracted now, when they exist.
# Done before loading the model so a missing frame fails fast.
# Frames and trailing clips borrowed from other jobs are extracted now,
# when they exist. Done before loading the model so a missing one fails fast.
resolved_references = resolve_references(
job.references,
self.get_job,
lambda source: last_frame_for_job(source, self.upload_dir),
last_frame=lambda source: last_frame_for_job(source, self.upload_dir),
last_clip=lambda source: last_clip_for_job(source, self.upload_dir),
)
buf.phase = "loading model"
+72 -4
View File
@@ -50,8 +50,9 @@ from fastapi.responses import FileResponse, PlainTextResponse
from fastvideo_studio.database import Database, default_settings_dict
from fastvideo_studio.job_queue import circular_dependency, dependencies, plan_dispatch
from fastvideo_studio.merge import MergeError, merge_jobs, merged_path
from fastvideo_studio.trim import BRIGHTNESS_RANGE, CONTRAST_RANGE, SATURATION_RANGE
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, MergeSceneRequest, QueueJobsRequest,
SettingsUpdate, UpdateCaptionRequest,
SettingsUpdate, TrimRequest, UpdateCaptionRequest,
model_label)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
@@ -311,7 +312,7 @@ 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 (internal-only keys stripped)."""
_advance_job(job)
return {k: v for k, v in job.items() if k != _FROM_SNAPSHOT_KEY}
return {k: v for k, v in job.items() if not k.startswith("_")}
_LOG_TAIL = [
@@ -741,12 +742,12 @@ def _check_dependencies_finished(job: dict[str, Any]) -> None:
dep = _jobs.get(dep_id)
if dep is None:
raise HTTPException(status_code=409,
detail="This job starts from the last frame of a job that no longer exists.")
detail="This job starts from the ending of a job that no longer exists.")
if dep["status"] != "completed":
label = f"'{dep['name']}'" if dep.get("name") else dep["id"]
raise HTTPException(
status_code=409,
detail=(f"This job starts from the last frame of {label}, which hasn't finished "
detail=(f"This job starts from the ending of {label}, which hasn't finished "
f"(it is {dep['status']}). Queue it to run after that job."),
)
@@ -791,6 +792,73 @@ def extract_last_frame(job_id: str) -> dict[str, str]:
return {"path": f"/mock/last_frames/last_frame_{job_id}.png", "media_type": "image"}
@app.post("/api/jobs/{job_id}/last-clip")
def extract_last_clip(job_id: str) -> dict[str, str]:
"""Stand-in for the real route: there is no video to read, so hand back a made-up path."""
with _state_lock:
_drain_queue()
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
_advance_job(job)
if job["status"] != "completed" or not job.get("output_path"):
raise HTTPException(status_code=404, detail="No output available for this job")
return {"path": f"/mock/last_clips/last_clip_{job_id}.mp4", "media_type": "video"}
#: Key holding a trimmed job's frame count before any trim, so it can be restored.
_ORIGINAL_NUM_FRAMES_KEY = "_original_num_frames"
def _trimmable_job(job_id: str) -> dict[str, Any]:
"""Look up a completed job with an output, or raise. Call with _state_lock held."""
_drain_queue()
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
_advance_job(job)
if job["status"] != "completed" or not job.get("output_path"):
raise HTTPException(status_code=404, detail="No output available for this job")
return job
def _check_grade_range(name: str, value: float, bounds: tuple[float, float]) -> None:
low, high = bounds
if not (low <= value <= high):
raise HTTPException(status_code=400, detail=f"{name} must be between {low:g} and {high:g}, got {value:g}.")
@app.post("/api/jobs/{job_id}/trim")
def trim_job(job_id: str, req: TrimRequest) -> dict[str, Any]:
"""Stand-in for the real route: no video to cut or grade, so just resize num_frames to match."""
with _state_lock:
job = _trimmable_job(job_id)
if req.start_seconds < 0:
raise HTTPException(status_code=400, detail="The start of the range can't be negative.")
_check_grade_range("brightness", req.brightness, BRIGHTNESS_RANGE)
_check_grade_range("contrast", req.contrast, CONTRAST_RANGE)
_check_grade_range("saturation", req.saturation, SATURATION_RANGE)
fps = job.get("fps") or 24
original_frames = job.setdefault(_ORIGINAL_NUM_FRAMES_KEY, job["num_frames"])
duration = original_frames / fps
end = duration if req.end_seconds is None else min(req.end_seconds, duration)
if end <= req.start_seconds:
raise HTTPException(status_code=400, detail="The end of the range must come after the start.")
job["num_frames"] = max(1, round((end - req.start_seconds) * fps))
return _public_job(job)
@app.post("/api/jobs/{job_id}/restore-video")
def restore_job_video(job_id: str) -> dict[str, Any]:
"""Stand-in for the real route: undo the simulated edit by restoring num_frames."""
with _state_lock:
job = _trimmable_job(job_id)
if _ORIGINAL_NUM_FRAMES_KEY not in job:
raise HTTPException(status_code=404, detail="This job has not been edited.")
job["num_frames"] = job.pop(_ORIGINAL_NUM_FRAMES_KEY)
return _public_job(job)
def _check_startable(job: dict[str, Any]) -> None:
if job["status"] == "running":
raise HTTPException(status_code=409, detail="Job is already running")
+2
View File
@@ -8,6 +8,7 @@ from fastvideo_studio.models.create_dataset_request import CreateDatasetRequest
from fastvideo_studio.models.update_caption_request import UpdateCaptionRequest
from fastvideo_studio.models.queue_jobs_request import QueueJobsRequest
from fastvideo_studio.models.merge_scene_request import MergeSceneRequest
from fastvideo_studio.models.trim_request import TrimRequest
def model_label(model_path: str) -> str:
@@ -22,5 +23,6 @@ __all__ = [
"UpdateCaptionRequest",
"QueueJobsRequest",
"MergeSceneRequest",
"TrimRequest",
"model_label",
]
@@ -0,0 +1,16 @@
# SPDX-License-Identifier: Apache-2.0
"""Request model for cutting and color-grading a job's video."""
from pydantic import BaseModel
class TrimRequest(BaseModel):
start_seconds: float = 0.0
#: None keeps to the end of the video.
end_seconds: float | None = None
#: Added directly to 0-255 pixel values. 0 = unchanged.
brightness: float = 0.0
#: 1.0 = unchanged, 0 = flat gray, >1 = more contrast.
contrast: float = 1.0
#: 1.0 = unchanged, 0 = grayscale, >1 = more saturated.
saturation: float = 1.0
+53 -2
View File
@@ -33,12 +33,12 @@ from fastapi.responses import FileResponse
from fastvideo.registry import (get_registered_model_paths, get_registered_models_with_workloads)
from fastvideo_studio.database import Database, _get_db_path
from fastvideo_studio.frames import FrameError, last_frame_for_job
from fastvideo_studio.frames import FrameError, last_clip_for_job, last_frame_for_job
from fastvideo_studio.gpu import get_gpu_snapshot
from fastvideo_studio.job_runner import JobRunner, JobStatus
from fastvideo_studio.merge import MergeError, merge_jobs, merged_path
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, MergeSceneRequest, QueueJobsRequest,
SettingsUpdate, UpdateCaptionRequest, model_label)
SettingsUpdate, TrimRequest, UpdateCaptionRequest, model_label)
logging.basicConfig(
level=logging.INFO,
@@ -721,6 +721,57 @@ def extract_last_frame(job_id: str) -> dict[str, str]:
return {"path": path, "media_type": "image"}
@app.post("/api/jobs/{job_id}/last-clip")
def extract_last_clip(job_id: str) -> dict[str, str]:
"""Save the trailing second of a completed job's video and return its path.
The path is meant to be attached to another job as a video reference, so
the next clip continues with real motion instead of guessing it from a
single still frame.
"""
job = job_runner.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
try:
path = last_clip_for_job(job, upload_dir)
except FrameError as e:
if e.status_code >= 500:
logger.warning("Last-clip extraction failed for job %s: %s", job_id, e.detail)
raise HTTPException(status_code=e.status_code, detail=e.detail) from e
return {"path": path, "media_type": "video"}
def _trim_error(e: Exception) -> HTTPException:
if isinstance(e, FrameError):
return HTTPException(status_code=e.status_code, detail=e.detail)
return HTTPException(status_code=404 if "not found" in str(e) else 400, detail=str(e))
@app.post("/api/jobs/{job_id}/trim")
def trim_job(job_id: str, req: TrimRequest) -> dict[str, Any]:
"""Cut a completed job's video to [start_seconds, end_seconds) and adjust its color, in place.
The untouched original is kept, so this can be called again with a
different range and/or color, or undone with POST .../restore-video.
"""
try:
job = job_runner.trim_job(job_id, req.start_seconds, req.end_seconds, req.brightness, req.contrast,
req.saturation)
except (ValueError, FrameError) as e:
raise _trim_error(e) from e
return job.to_dict()
@app.post("/api/jobs/{job_id}/restore-video")
def restore_job_video(job_id: str) -> dict[str, Any]:
"""Undo every trim on a job's video, back to what it originally generated."""
try:
job = job_runner.restore_job_video(job_id)
except (ValueError, FrameError) as e:
raise _trim_error(e) from e
return job.to_dict()
@app.post("/api/scenes/merge")
def merge_scene(req: MergeSceneRequest) -> dict[str, Any]:
"""Join the finished clips of a scene, in the order given, into one video.
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ScenesPage from './page';
import { toast } from 'sonner';
import { dequeueJob, downloadMergedVideo, getJobsList, mergeScene, queueJobs, updateJob } from '@/lib/api';
import { dequeueJob, downloadMergedVideo, getJobsList, mergeScene, queueJobs, restoreJobVideo, trimJob, updateJob } from '@/lib/api';
import { downloadBlob } from '@/lib/utils';
import { makeJob } from '@/test/factories';
@@ -19,6 +19,8 @@ vi.mock('@/lib/api', () => ({
getMergedVideoUrl: (name: string) => `http://test.local/api/merged/${name}`,
updateJob: vi.fn(),
getJobVideoUrl: (id: string) => `http://test.local/api/jobs/${id}/video`,
trimJob: vi.fn(),
restoreJobVideo: vi.fn(),
}));
vi.mock('@/lib/utils', async (importOriginal) => ({
@@ -924,3 +926,189 @@ describe('ScenesPage', () => {
});
});
});
describe('editing a clip\'s video', () => {
const finished = (n: number, over: Parameters<typeof wolf>[1] = {}) =>
wolf(n, { status: 'completed', output_path: `/o/${n}.mp4`, num_frames: 240, fps: 24, ...over });
const openEdit = async (user: ReturnType<typeof userEvent.setup>, id = 'wolf-1') => {
await user.click(within(document.getElementById(`clip-${id}`) as HTMLElement).getByRole('button', { name: 'Edit video' }));
return screen.findByRole('dialog');
};
const applyButton = (dialog: HTMLElement) => within(dialog).getByRole('button', { name: /Apply|Applying/ });
beforeEach(() => {
vi.mocked(trimJob).mockReset();
vi.mocked(restoreJobVideo).mockReset();
vi.mocked(toast.success).mockClear();
vi.mocked(toast.error).mockClear();
});
it('is only offered on a clip with a finished video', async () => {
vi.mocked(getJobsList).mockResolvedValue([finished(1), wolf(2, { status: 'running' })]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
expect(within(document.getElementById('clip-wolf-1') as HTMLElement).getByRole('button', { name: 'Edit video' })).toBeInTheDocument();
expect(within(document.getElementById('clip-wolf-2') as HTMLElement).queryByRole('button', { name: 'Edit video' })).not.toBeInTheDocument();
});
it('opens with the full current range and neutral color, and shows the current duration', async () => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockResolvedValue([finished(1)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const dialog = await openEdit(user);
expect(within(dialog).getByText(/Currently 0:10\./)).toBeInTheDocument();
expect(within(dialog).getByLabelText('Start (seconds)')).toHaveValue(0);
expect(within(dialog).getByLabelText('End (seconds, optional)')).toHaveValue(10);
expect(within(dialog).getByLabelText('Brightness')).toHaveValue(0);
expect(within(dialog).getByLabelText('Contrast')).toHaveValue(1);
expect(within(dialog).getByLabelText('Saturation')).toHaveValue(1);
});
it('applies the chosen range and reports it', async () => {
const user = userEvent.setup();
vi.mocked(trimJob).mockResolvedValue({ ...finished(1), output_path: '/o/edited.mp4', num_frames: 48 });
vi.mocked(getJobsList).mockResolvedValue([finished(1)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const dialog = await openEdit(user);
await user.clear(within(dialog).getByLabelText('Start (seconds)'));
await user.type(within(dialog).getByLabelText('Start (seconds)'), '1');
await user.clear(within(dialog).getByLabelText('End (seconds, optional)'));
await user.type(within(dialog).getByLabelText('End (seconds, optional)'), '3');
await user.click(applyButton(dialog));
await waitFor(() =>
expect(trimJob).toHaveBeenCalledWith('wolf-1', { startSeconds: 1, endSeconds: 3, brightness: 0, contrast: 1, saturation: 1 }),
);
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Applied: 0:01–0:03.'));
});
it('applies chosen color values alongside the range, in one call', async () => {
const user = userEvent.setup();
vi.mocked(trimJob).mockResolvedValue(finished(1));
vi.mocked(getJobsList).mockResolvedValue([finished(1)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const dialog = await openEdit(user);
await user.clear(within(dialog).getByLabelText('Brightness'));
await user.type(within(dialog).getByLabelText('Brightness'), '20');
await user.clear(within(dialog).getByLabelText('Contrast'));
await user.type(within(dialog).getByLabelText('Contrast'), '1.4');
await user.clear(within(dialog).getByLabelText('Saturation'));
await user.type(within(dialog).getByLabelText('Saturation'), '0.5');
await user.click(applyButton(dialog));
await waitFor(() =>
expect(trimJob).toHaveBeenCalledWith('wolf-1', { startSeconds: 0, endSeconds: 10, brightness: 20, contrast: 1.4, saturation: 0.5 }),
);
});
it('leaving the end blank keeps to the end of the clip', async () => {
const user = userEvent.setup();
vi.mocked(trimJob).mockResolvedValue(finished(1));
vi.mocked(getJobsList).mockResolvedValue([finished(1)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const dialog = await openEdit(user);
await user.clear(within(dialog).getByLabelText('Start (seconds)'));
await user.type(within(dialog).getByLabelText('Start (seconds)'), '2');
await user.clear(within(dialog).getByLabelText('End (seconds, optional)'));
await user.click(applyButton(dialog));
await waitFor(() =>
expect(trimJob).toHaveBeenCalledWith('wolf-1', { startSeconds: 2, endSeconds: undefined, brightness: 0, contrast: 1, saturation: 1 }),
);
});
it('disables Apply for an invalid range and explains why', async () => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockResolvedValue([finished(1)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const dialog = await openEdit(user);
await user.clear(within(dialog).getByLabelText('Start (seconds)'));
await user.type(within(dialog).getByLabelText('Start (seconds)'), '5');
await user.clear(within(dialog).getByLabelText('End (seconds, optional)'));
await user.type(within(dialog).getByLabelText('End (seconds, optional)'), '3');
expect(within(dialog).getByText('The end must come after the start.')).toBeInTheDocument();
expect(applyButton(dialog)).toBeDisabled();
expect(trimJob).not.toHaveBeenCalled();
});
it('reports a negative start instead of the generic message', async () => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockResolvedValue([finished(1)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const dialog = await openEdit(user);
await user.clear(within(dialog).getByLabelText('Start (seconds)'));
await user.type(within(dialog).getByLabelText('Start (seconds)'), '-1');
expect(within(dialog).getByText('The start must be 0 or later.')).toBeInTheDocument();
});
it.each([
['Brightness', '150', 'Brightness must be between -100 and 100.'],
['Contrast', '5', 'Contrast must be between 0 and 3.'],
['Saturation', '-1', 'Saturation must be between 0 and 3.'],
])('disables Apply for an out-of-range %s and explains why', async (label, value, message) => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockResolvedValue([finished(1)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const dialog = await openEdit(user);
await user.clear(within(dialog).getByLabelText(label));
await user.type(within(dialog).getByLabelText(label), value);
expect(within(dialog).getByText(message)).toBeInTheDocument();
expect(applyButton(dialog)).toBeDisabled();
});
it('restores the original video and reports it', async () => {
const user = userEvent.setup();
vi.mocked(restoreJobVideo).mockResolvedValue(finished(1));
vi.mocked(getJobsList).mockResolvedValue([finished(1)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const dialog = await openEdit(user);
await user.click(within(dialog).getByRole('button', { name: 'Restore original' }));
await waitFor(() => expect(restoreJobVideo).toHaveBeenCalledWith('wolf-1'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Restored the original video.'));
});
it('reports a failed edit and leaves the dialog open to retry', async () => {
const user = userEvent.setup();
vi.mocked(trimJob).mockRejectedValue(new Error('That range keeps no frames.'));
vi.mocked(getJobsList).mockResolvedValue([finished(1)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const dialog = await openEdit(user);
await user.click(applyButton(dialog));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('That range keeps no frames.'));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('closes, and refetches jobs after a change but not after just closing', async () => {
const user = userEvent.setup();
vi.mocked(getJobsList).mockResolvedValue([finished(1)]);
render(<ScenesPage />);
await screen.findByRole('heading', { name: 'wolf-lunch' });
const dialog = await openEdit(user);
await user.click(within(dialog).getAllByRole('button', { name: 'Close' })[0]);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(getJobsList).toHaveBeenCalledTimes(1); // just the initial load
});
});
@@ -7,6 +7,7 @@ import CreateJobModal from '@/components/jobs/CreateJobModal';
import { EDITABLE_STATUSES, SceneBoard } from '@/components/scenes/SceneBoard';
import { MergedSceneDialog } from '@/components/scenes/MergedSceneDialog';
import { SceneQueueDialog } from '@/components/scenes/SceneQueueDialog';
import { TrimClipDialog } from '@/components/scenes/TrimClipDialog';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { NativeSelect } from '@/components/ui/native-select';
@@ -30,6 +31,15 @@ export default function ScenesPage() {
const [choices, setChoices] = React.useState<Record<string, string>>({});
const [queueTarget, setQueueTarget] = React.useState<Scene | null>(null);
const [merged, setMerged] = React.useState<(MergedScene & { title: string }) | null>(null);
const [trimTarget, setTrimTarget] = React.useState<Job | null>(null);
// Bumped per job id whenever its video is edited in place, to cache-bust every
// <video> for that job (the dialog's own preview and the scene board's). Owned
// here, not by the dialog, so it survives the dialog re-rendering with a fresh
// job object after each edit -- a per-dialog counter got reset by that same
// re-render before the browser ever saw the new URL.
const [videoVersions, setVideoVersions] = React.useState<Record<string, number>>({});
const bumpVideoVersion = (jobId: string) =>
setVideoVersions((v) => ({ ...v, [jobId]: (v[jobId] ?? 0) + 1 }));
React.useEffect(() => {
let cancelled = false;
@@ -226,7 +236,9 @@ export default function ScenesPage() {
onMergeScene={mergeSceneClips}
onQueueClip={queueClip}
onDequeueClip={dequeueClip}
onTrimClip={(clip) => setTrimTarget(clip.job)}
onChooseTake={(takeKey, jobId) => setChoices((c) => ({ ...c, [takeKey]: jobId }))}
videoVersionFor={(jobId) => videoVersions[jobId] ?? 0}
/>
</div>
)}
@@ -234,6 +246,16 @@ export default function ScenesPage() {
<MergedSceneDialog merged={merged} onClose={() => setMerged(null)} />
<SceneQueueDialog scene={queueTarget} onConfirm={queueScene} onClose={() => setQueueTarget(null)} />
<TrimClipDialog
clip={trimTarget}
videoVersion={trimTarget ? (videoVersions[trimTarget.id] ?? 0) : 0}
onChanged={(job) => {
setTrimTarget(job);
bumpVideoVersion(job.id);
getJobsList('inference').then(setJobs).catch(() => {});
}}
onClose={() => setTrimTarget(null)}
/>
{openJob && (
<CreateJobModal
@@ -73,19 +73,28 @@ function Timeline({ scene }: { scene: Scene }) {
);
}
function ClipPreview({ job }: { job: Job }) {
function ClipPreview({ job, videoVersion }: { job: Job; videoVersion: number }) {
const isImage = job.output_path?.toLowerCase().endsWith('.png') ?? false;
const src = getJobVideoUrl(job.id);
const src = getJobVideoUrl(job.id, videoVersion);
// Sets the *initial* muted state only, imperatively, so it doesn't fight the
// user's own unmute via the native controls: `muted` as a plain JSX prop is
// controlled, and React re-applies it on every re-render of this component
// (e.g. the scene board's poll while a job is queued/running), silently
// re-muting a video the user just unmuted.
const videoRef = React.useRef<HTMLVideoElement | null>(null);
React.useEffect(() => {
if (videoRef.current) videoRef.current.muted = true;
}, []);
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
ref={videoRef}
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"
@@ -103,6 +112,8 @@ function ClipRow({
onDetachLastFrame,
onQueueClip,
onDequeueClip,
onTrimClip,
videoVersionFor,
}: {
clip: SceneClip;
/** the clip before this one in the scene, if any */
@@ -114,6 +125,8 @@ function ClipRow({
onDetachLastFrame: (clip: SceneClip) => Promise<void>;
onQueueClip: (clip: SceneClip) => Promise<void>;
onDequeueClip: (clip: SceneClip) => Promise<void>;
onTrimClip: (clip: SceneClip) => void;
videoVersionFor: (jobId: string) => number;
}) {
const [previewing, setPreviewing] = React.useState(false);
const [attaching, setAttaching] = React.useState(false);
@@ -232,6 +245,11 @@ function ClipRow({
{previewing ? 'Hide video' : 'Preview'}
</Button>
)}
{canPreview && (
<Button type="button" size="sm" variant="outline" onClick={() => onTrimClip(clip)}>
Edit video
</Button>
)}
{canQueue && (
<Button
type="button"
@@ -263,7 +281,7 @@ function ClipRow({
{previewing && canPreview && (
<div className="mb-2">
<ClipPreview job={job} />
<ClipPreview job={job} videoVersion={videoVersionFor(job.id)} />
</div>
)}
@@ -307,6 +325,10 @@ export interface SceneBoardProps {
/** queue / unqueue one clip; resolve when done (errors are reported by the caller) */
onQueueClip: (clip: SceneClip) => Promise<void>;
onDequeueClip: (clip: SceneClip) => Promise<void>;
/** open the trim dialog for one clip's video */
onTrimClip: (clip: SceneClip) => void;
/** cache-busting version for a job's video preview, bumped after each edit */
videoVersionFor: (jobId: string) => number;
}
/** One scene: a timeline of its clips, then every clip's shots in order, numbered straight through. */
@@ -320,6 +342,8 @@ export function SceneBoard({
onMergeScene,
onQueueClip,
onDequeueClip,
onTrimClip,
videoVersionFor,
}: SceneBoardProps) {
let shotNumber = 1;
const statuses = Object.entries(scene.statusCounts);
@@ -418,6 +442,8 @@ export function SceneBoard({
onDetachLastFrame={onDetachLastFrame}
onQueueClip={onQueueClip}
onDequeueClip={onDequeueClip}
onTrimClip={onTrimClip}
videoVersionFor={videoVersionFor}
/>
);
})}
@@ -0,0 +1,270 @@
'use client';
import * as React from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getJobVideoUrl, restoreJobVideo, trimJob } from '@/lib/api';
import { clipSeconds, formatClock } from '@/lib/scenes';
import type { Job } from '@/lib/types';
export interface TrimClipDialogProps {
/** the clip to edit, or null when closed */
clip: Job | null;
/**
* Cache-busts the preview after an edit (the video keeps the same URL, only
* the file changes). Owned by the caller, not this component -- a version
* counter kept here gets reset by the very re-render `onChanged` causes
* (a fresh `clip` object comes back from the edit), before the browser
* ever sees the new URL.
*/
videoVersion: number;
onClose: () => void;
/** called with the job as the edit/restore left it, so the caller can refresh */
onChanged: (job: Job) => void;
}
const NEUTRAL = { brightness: '0', contrast: '1', saturation: '1' };
// Mirrors trim.py's BRIGHTNESS_RANGE / CONTRAST_RANGE / SATURATION_RANGE.
const BRIGHTNESS_BOUNDS = [-100, 100] as const;
const CONTRAST_BOUNDS = [0, 3] as const;
const SATURATION_BOUNDS = [0, 3] as const;
function outOfRange(value: number, [low, high]: readonly [number, number]): boolean {
return !Number.isFinite(value) || value < low || value > high;
}
/**
* Cut a finished clip to a chosen in/out range and adjust its brightness,
* contrast and saturation, or undo every change back to what H3 originally
* generated. The server always keeps the untouched original and re-renders
* from it with the full set of values shown here, so range and color are
* applied together and neither is lost by changing the other -- as long as
* both are set the way you want them before you click Apply. The fields keep
* whatever you last applied while this dialog stays open for the same clip,
* so a second, smaller color tweak doesn't also reset the range. Opening it
* fresh for a clip (or reopening it later) shows the clip's current length as
* the range (so leaving color alone and applying keeps a previous trim), but
* color always starts at neutral, since the server doesn't remember which values
* produced the current video.
*/
export function TrimClipDialog({ clip, videoVersion, onClose, onChanged }: TrimClipDialogProps) {
const duration = clip ? clipSeconds(clip) : 0;
const [start, setStart] = React.useState('0');
const [end, setEnd] = React.useState('');
const [brightness, setBrightness] = React.useState(NEUTRAL.brightness);
const [contrast, setContrast] = React.useState(NEUTRAL.contrast);
const [saturation, setSaturation] = React.useState(NEUTRAL.saturation);
const [busy, setBusy] = React.useState<'apply' | 'restore' | null>(null);
// Reset the fields only when a *different* clip opens -- onChanged() re-renders
// this dialog with a fresh job object for the same edit, which must not reset
// what the user is about to apply next.
const openClipId = React.useRef<string | null>(null);
React.useEffect(() => {
if (clip && clip.id !== openClipId.current) {
openClipId.current = clip.id;
setStart('0');
setEnd(clipSeconds(clip).toFixed(2));
setBrightness(NEUTRAL.brightness);
setContrast(NEUTRAL.contrast);
setSaturation(NEUTRAL.saturation);
} else if (!clip) {
openClipId.current = null;
}
}, [clip]);
// Sets the *initial* muted state only, imperatively, so it doesn't fight the
// user's own unmute via the native controls: `muted` as a plain JSX prop is
// controlled, and React re-applies it on every re-render of this dialog
// (e.g. after Apply calls onChanged with a fresh job object), silently
// re-muting a video the user just unmuted.
const videoRef = React.useRef<HTMLVideoElement | null>(null);
React.useEffect(() => {
if (videoRef.current) videoRef.current.muted = true;
}, []);
if (!clip) {
return <Dialog open={false} onOpenChange={() => {}} />;
}
const startSeconds = Number(start);
const endSeconds = end.trim() === '' ? undefined : Number(end);
const brightnessValue = Number(brightness);
const contrastValue = Number(contrast);
const saturationValue = Number(saturation);
const rangeError =
!Number.isFinite(startSeconds) || startSeconds < 0
? 'The start must be 0 or later.'
: endSeconds !== undefined && (!Number.isFinite(endSeconds) || endSeconds <= startSeconds)
? 'The end must come after the start.'
: null;
const colorError = outOfRange(brightnessValue, BRIGHTNESS_BOUNDS)
? `Brightness must be between ${BRIGHTNESS_BOUNDS[0]} and ${BRIGHTNESS_BOUNDS[1]}.`
: outOfRange(contrastValue, CONTRAST_BOUNDS)
? `Contrast must be between ${CONTRAST_BOUNDS[0]} and ${CONTRAST_BOUNDS[1]}.`
: outOfRange(saturationValue, SATURATION_BOUNDS)
? `Saturation must be between ${SATURATION_BOUNDS[0]} and ${SATURATION_BOUNDS[1]}.`
: null;
const invalid = !!(rangeError || colorError);
async function apply() {
if (!clip || invalid) return;
setBusy('apply');
try {
const updated = await trimJob(clip.id, {
startSeconds,
endSeconds,
brightness: brightnessValue,
contrast: contrastValue,
saturation: saturationValue,
});
toast.success(`Applied: ${formatClock(startSeconds)}–${endSeconds === undefined ? 'end' : formatClock(endSeconds)}.`);
onChanged(updated);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Couldn't edit the clip.");
} finally {
setBusy(null);
}
}
async function restore() {
if (!clip) return;
setBusy('restore');
try {
const updated = await restoreJobVideo(clip.id);
toast.success('Restored the original video.');
onChanged(updated);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Couldn't restore the original video.");
} finally {
setBusy(null);
}
}
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit {clip.name?.trim() || clip.id.slice(0, 8)}</DialogTitle>
<DialogDescription>
Currently {formatClock(duration)}. Editing keeps the untouched original, so this can be adjusted or undone.
</DialogDescription>
</DialogHeader>
<video
ref={videoRef}
src={getJobVideoUrl(clip.id, videoVersion)}
aria-label={`Preview of ${clip.name ?? clip.id}`}
className="max-h-64 w-full rounded-md border border-border bg-black"
controls
playsInline
preload="metadata"
/>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Range</p>
<div className="flex items-end gap-3">
<div className="flex flex-1 flex-col gap-1 text-sm">
<Label htmlFor="trim-start">Start (seconds)</Label>
<Input
id="trim-start"
type="number"
min={0}
step={0.1}
value={start}
onChange={(e) => setStart(e.target.value)}
disabled={!!busy}
/>
</div>
<div className="flex flex-1 flex-col gap-1 text-sm">
<Label htmlFor="trim-end">End (seconds, optional)</Label>
<Input
id="trim-end"
type="number"
min={0}
step={0.1}
placeholder="end of clip"
value={end}
onChange={(e) => setEnd(e.target.value)}
disabled={!!busy}
/>
</div>
</div>
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
</div>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Color</p>
<div className="flex items-end gap-3">
<div className="flex flex-1 flex-col gap-1 text-sm">
<Label htmlFor="grade-brightness">Brightness</Label>
<Input
id="grade-brightness"
type="number"
min={BRIGHTNESS_BOUNDS[0]}
max={BRIGHTNESS_BOUNDS[1]}
step={5}
value={brightness}
onChange={(e) => setBrightness(e.target.value)}
disabled={!!busy}
/>
</div>
<div className="flex flex-1 flex-col gap-1 text-sm">
<Label htmlFor="grade-contrast">Contrast</Label>
<Input
id="grade-contrast"
type="number"
min={CONTRAST_BOUNDS[0]}
max={CONTRAST_BOUNDS[1]}
step={0.1}
value={contrast}
onChange={(e) => setContrast(e.target.value)}
disabled={!!busy}
/>
</div>
<div className="flex flex-1 flex-col gap-1 text-sm">
<Label htmlFor="grade-saturation">Saturation</Label>
<Input
id="grade-saturation"
type="number"
min={SATURATION_BOUNDS[0]}
max={SATURATION_BOUNDS[1]}
step={0.1}
value={saturation}
onChange={(e) => setSaturation(e.target.value)}
disabled={!!busy}
/>
</div>
</div>
{colorError && <p className="text-sm text-destructive">{colorError}</p>}
</div>
<DialogFooter className="justify-between sm:justify-between">
<Button type="button" variant="outline" onClick={restore} disabled={!!busy}>
{busy === 'restore' ? 'Restoring…' : 'Restore original'}
</Button>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onClose} disabled={!!busy}>
Close
</Button>
<Button type="button" onClick={apply} disabled={!!busy || invalid}>
{busy === 'apply' ? 'Applying…' : 'Apply'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+74 -2
View File
@@ -50,8 +50,15 @@ function stripTrailingSlash(url: string): string {
}
/** Full URL for streaming a job's video/image output (for &lt;video&gt; or &lt;img&gt; src). */
export function getJobVideoUrl(jobId: string): string {
return `${getApiBaseUrl()}/jobs/${jobId}/video`;
/**
* `version` busts the browser's cache after editing a job's video in place
* (Trim/Edit video): the URL is otherwise identical before and after an edit,
* even though the file on disk changed, so without this the same cached
* response keeps showing. Omit it where staleness doesn't matter.
*/
export function getJobVideoUrl(jobId: string, version?: number): string {
const base = `${getApiBaseUrl()}/jobs/${jobId}/video`;
return version ? `${base}?v=${version}` : base;
}
export interface CreateJobRequest {
@@ -208,6 +215,71 @@ export async function extractLastFrame(jobId: string): Promise<{ path: string; m
return response.json();
}
export interface EditVideoParams {
startSeconds: number;
/** omitted (or undefined) keeps to the end of the video */
endSeconds?: number;
/** added directly to 0-255 pixel values; 0 = unchanged */
brightness?: number;
/** 1 = unchanged, 0 = flat gray, >1 = more contrast */
contrast?: number;
/** 1 = unchanged, 0 = grayscale, >1 = more saturated */
saturation?: number;
}
/**
* Cut a completed job's video to a range and/or adjust its color, in place.
* The untouched original is kept server-side, so this always renders fresh
* from it with the *given* parameters -- pass every value you want kept, not
* just the one you changed, or the others reset to their neutral defaults.
* Can be undone with restoreJobVideo.
*/
export async function trimJob(jobId: string, params: EditVideoParams): Promise<Job> {
const baseApiUrl = getApiBaseUrl();
const response = await fetch(`${baseApiUrl}/jobs/${jobId}/trim`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
start_seconds: params.startSeconds,
end_seconds: params.endSeconds ?? null,
brightness: params.brightness ?? 0,
contrast: params.contrast ?? 1,
saturation: params.saturation ?? 1,
}),
});
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: "Could not edit the clip" }));
throw new Error(err.detail || "Could not edit the clip");
}
return response.json();
}
/** Undo every trim and color adjustment on a job's video, back to what it originally generated. */
export async function restoreJobVideo(jobId: string): Promise<Job> {
const baseApiUrl = getApiBaseUrl();
const response = await fetch(`${baseApiUrl}/jobs/${jobId}/restore-video`, { method: "POST" });
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: "Could not restore the original video" }));
throw new Error(err.detail || "Could not restore the original video");
}
return response.json();
}
/**
* Ask the server to save the last second or so of a finished clip and return its
* path, ready to attach to another job as a video reference -- so the next
* clip continues with real motion instead of guessing it from a single frame.
*/
export async function extractLastClip(jobId: string): Promise<{ path: string; media_type: MediaType }> {
const baseApiUrl = getApiBaseUrl();
const response = await fetch(`${baseApiUrl}/jobs/${jobId}/last-clip`, { method: "POST" });
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: "Could not get the end of the clip" }));
throw new Error(err.detail || "Could not get the end of the clip");
}
return response.json();
}
export async function uploadMedia(
file: File,
): Promise<{ path: string; media_type: MediaType }> {
@@ -1,11 +1,17 @@
import { describe, expect, it } from "vitest";
import {
attachLastClip,
attachLastFrame,
attachedLastClips,
attachedLastFrames,
deferredLastClipSource,
deferredLastFrameSource,
detachLastClip,
detachLastFrame,
lastClipSourceJobId,
lastFrameSourceJobId,
whyCannotAttach,
whyCannotAttachClip,
} from "@/lib/continuation";
import { parseH3Prompt } from "@/lib/h3Prompt";
import { readField } from "@/lib/h3PromptFields";
@@ -275,3 +281,198 @@ describe("detachLastFrame", () => {
expect(detachLastFrame(CLIP, REFS, FRAME)).toEqual({ ok: false, error: "That frame isn't attached to this clip." });
});
});
const CLIP_VIDEO = "/data/uploads/last_clips/last_clip_job-5.mp4";
function attachClip(prompt = CLIP, refs = REFS, clip = CLIP_VIDEO) {
const r = attachLastClip(prompt, refs, clip);
if (!r.ok) throw new Error(r.error);
return r;
}
describe("attachLastClip", () => {
it("adds the clip as the next video reference and names it by its video number", () => {
const r = attachClip();
expect(r.label).toBe("<Video 1>");
expect(r.references).toEqual([...REFS, { source: CLIP_VIDEO, media_type: "video" }]);
});
it("numbers a second video reference after the first", () => {
const withOne = attachClip();
const r = attachLastClip(withOne.prompt, withOne.references, "/refs/another.mp4");
if (!r.ok) throw new Error(r.error);
expect(r.label).toBe("<Video 2>");
});
it("declares the video standalone, not bound to a <Subject> the way pictures and audio are", () => {
const defs = readField(attachClip().prompt, "subject_definitions") as string;
expect(defs).toContain("<Video 1> is the source video for [Shot 1]");
expect(defs).not.toMatch(/<Subject \d+> is .*<Video 1>/);
});
it("adds a retention line with the fully_preserved status", () => {
const line = (readField(attachClip().prompt, "retention_analysis") as string).split("\n").at(-1);
expect(line).toBe(
"<Video 1> (continuation source): fully_preserved - the motion, scene, positions, and lighting of the previous clip's ending are continued exactly.",
);
});
it("prefixes the first shot with a continuation lead, keeping hand-written shots verbatim otherwise", () => {
const hand = CLIP.replace(
/\[Shot 1\][^\n]*\n\[Shot 2\][^\n]*/,
"[Shot 1] A handheld medium shot holds <Subject 1> at frame left.\n[Shot 2] At 00:03.800, the shot cuts to a low tracking shot.",
);
const shots = (readField(attachClip(hand).prompt, "detailed_description") as string).split("\n");
expect(shots).toEqual([
"[Shot 1] The shot continues from <Video 1>. A handheld medium shot holds <Subject 1> at frame left.",
"[Shot 2] At 00:03.800, the shot cuts to a low tracking shot.",
]);
});
describe("summary wording", () => {
const summaryOf = (summary: string) =>
readField(attachClip(CLIP.replace("[reference generation] The target video shows <Subject 1> and <Subject 2> at a lunch.", summary)).prompt, "summary") as string;
it("adds the tag when the summary has none", () => {
expect(summaryOf("Two men at a lunch.")).toBe("[video continuation] Two men at a lunch.");
});
it("replaces reference generation, not adds to it", () => {
expect(summaryOf("[reference generation] Two men.")).toBe("[video continuation] Two men.");
});
it("keeps a combined audio reference tag, per the guide's documented combination", () => {
expect(summaryOf("[reference generation + audio reference] Two men.")).toBe("[video continuation + audio reference] Two men.");
});
it("drops keyframe completion when switching from a picture-based continuation", () => {
expect(summaryOf("[reference generation + keyframe completion] Two men.")).toBe("[video continuation] Two men.");
});
});
describe("refusals", () => {
it("won't attach the same clip twice", () => {
const r = attachLastClip(CLIP, [...REFS, { source: CLIP_VIDEO, media_type: "video" }], CLIP_VIDEO);
expect(r).toEqual({ ok: false, error: "That clip is already attached to this job." });
});
it("refuses a prompt that is not in the six-section format", () => {
const r = attachLastClip("A plain prompt.", REFS, CLIP_VIDEO);
expect(r).toEqual({ ok: false, error: "This prompt isn't in the six-section format, so it can't be rewritten automatically. Attach the clip in Edit instead." });
});
it("refuses past H3's video reference limit", () => {
const many = Array.from({ length: 3 }, (_, i) => ({ source: `/v${i}.mp4`, media_type: "video" }));
const r = attachLastClip(CLIP, [...REFS, ...many], CLIP_VIDEO);
expect(r).toEqual({ ok: false, error: "A job can have at most 3 video references." });
});
it("refuses a prompt with no shots", () => {
const noShots = CLIP.replace(/detailed_description:\n[\s\S]*?\n\noverall_soundscape:/, "detailed_description:\n\noverall_soundscape:");
const r = attachLastClip(noShots, REFS, CLIP_VIDEO);
expect(r).toEqual({ ok: false, error: "This prompt has no shots to continue from the clip." });
});
});
});
describe("recognizing an attached last clip", () => {
it("reads the job id out of the saved path", () => {
expect(lastClipSourceJobId(CLIP_VIDEO)).toBe("job-5");
expect(lastClipSourceJobId("last_clip_abc-123.mp4")).toBe("abc-123");
expect(lastClipSourceJobId("/refs/veteran.png")).toBeNull();
});
it("reads the job id out of a reference the server resolves when the job runs", () => {
expect(deferredLastClipSource("job-5")).toBe("job-last-clip:job-5");
expect(lastClipSourceJobId(deferredLastClipSource("job-5"))).toBe("job-5");
});
it("finds last-clip references among a job's references, separately from last-frame ones", () => {
const frame = { source: "/data/uploads/last_frames/last_frame_job-2.png", media_type: "image" };
expect(attachedLastClips([...REFS, frame, { source: CLIP_VIDEO, media_type: "video" }])).toEqual([
{ source: CLIP_VIDEO, jobId: "job-5" },
]);
expect(attachedLastClips([frame])).toEqual([]);
expect(attachedLastClips(null)).toEqual([]);
});
});
describe("whyCannotAttachClip", () => {
it("blocks a prompt not in the six-section format", () => {
expect(whyCannotAttachClip("A plain prompt.", REFS)).toMatch(/six-section/);
});
it("allows a normal prompt under the video limit", () => {
expect(whyCannotAttachClip(CLIP, REFS)).toBeNull();
});
});
describe("detachLastClip", () => {
const roundTrip = (prompt: string, clip = CLIP_VIDEO) => {
const attached = attachClip(prompt, REFS, clip);
const r = detachLastClip(attached.prompt, attached.references, clip);
if (!r.ok) throw new Error(r.error);
return r;
};
it("takes back exactly what attaching added", () => {
const r = roundTrip(CLIP);
expect(r.references).toEqual(REFS);
expect(r.prompt).toBe(CLIP);
});
it("works for a clip the server resolves when the job runs", () => {
expect(roundTrip(CLIP, deferredLastClipSource("job-5")).prompt).toBe(CLIP);
});
it("round-trips a starting tag combined with audio reference", () => {
// A realistic starting point: audio references already attached (see continuation.test.ts's
// own audio-reference wiring), before any video continuation is attached.
const prompt = CLIP.replace(
"[reference generation] The target video shows <Subject 1> and <Subject 2> at a lunch.",
"[reference generation + audio reference] Two men at a lunch.",
);
expect(roundTrip(prompt).prompt).toBe(prompt);
});
// Unlike attachLastFrame's "keyframe completion" (only ever added alongside the existing base
// tag), "video continuation" *replaces* the base tag -- so once attached, nothing remembers
// what was there before, and detaching lands on the default "reference generation", not back
// on "no tag".
it("restores a summary with no tag to the default reference-generation tag, not back to no tag", () => {
const prompt = CLIP.replace("[reference generation] The target video shows <Subject 1> and <Subject 2> at a lunch.", "Two men at a lunch.");
expect(roundTrip(prompt).prompt).toBe(CLIP.replace("The target video shows <Subject 1> and <Subject 2> at a lunch.", "Two men at a lunch."));
});
it("restores hand-written shots and an N/A section", () => {
const hand = CLIP.replace(/\[Shot 1\][^\n]*\n\[Shot 2\][^\n]*/, "[Shot 1] A handheld medium shot.\n[Shot 2] At 00:03.800, the shot cuts to a low tracking shot.").replace(
/subject_definitions:\n[\s\S]*?\n\nsummary:/,
"subject_definitions:\nN/A\n\nsummary:",
);
expect(roundTrip(hand).prompt).toBe(hand);
});
it("only removes the reference from a prompt that was never rewritten", () => {
const r = detachLastClip("A plain prompt.", [...REFS, { source: CLIP_VIDEO, media_type: "video" }], CLIP_VIDEO);
expect(r).toEqual({ ok: true, prompt: "A plain prompt.", references: REFS });
});
it("refuses when other videos were added after the clip", () => {
const attached = attachClip();
const r = detachLastClip(attached.prompt, [...attached.references, { source: "/refs/extra.mp4", media_type: "video" }], CLIP_VIDEO);
expect(r.ok).toBe(false);
});
it("refuses a clip that isn't attached", () => {
expect(detachLastClip(CLIP, REFS, CLIP_VIDEO)).toEqual({ ok: false, error: "That clip isn't attached to this job." });
});
it("does not remove a last-frame attachment, and vice versa", () => {
const withFrame = attach(CLIP, REFS, FRAME);
const withBoth = attachClip(withFrame.prompt, withFrame.references, CLIP_VIDEO);
const r = detachLastClip(withBoth.prompt, withBoth.references, CLIP_VIDEO);
if (!r.ok) throw new Error(r.error);
expect(attachedLastFrames(r.references)).toEqual([{ source: FRAME, jobId: "job-5" }]);
expect(attachedLastClips(r.references)).toEqual([]);
});
});
+149 -6
View File
@@ -1,13 +1,18 @@
/**
* Start a clip from the last frame of the one before it.
* Start a clip from how the one before it ended -- either its last frame (a
* still) or its last second or so (a short video).
*
* A scene is generated as many short clips. To make one pick up where the
* previous ended, the previous clip's last frame is attached as an image
* reference, and the prompt is rewritten the way the prompt guide describes for
* a picture that anchors the opening frame: a `[keyframe completion]` task tag,
* a standalone `<Picture N>` definition, a retention line, and "the shot begins
* from <Picture N>" in the first shot. The wording is kept together here so it
* previous ended, the previous clip's ending is attached as a reference, and
* the prompt is rewritten the way the prompt guide describes for a picture
* that anchors the opening frame (`attachLastFrame`) or a video that the shot
* continues from (`attachLastClip`). The wording is kept together here so it
* is easy to change if the guide is read differently.
*
* The two approaches trade off differently: a still is a hard, exact opening
* frame, but the model has only a pose to guess the continuing motion from. A
* short video carries real motion, at the cost of one more reference slot and
* (per H3's own limits) at most 3 per job instead of 9.
*/
import { readField, writeField } from "@/lib/h3PromptFields";
import { H3_REFERENCE_LIMITS } from "@/lib/h3References";
@@ -28,11 +33,16 @@ export interface Reference {
* finished, and the server's queue knows this job must wait for it.
*/
export const DEFERRED_LAST_FRAME_PREFIX = "job-last-frame:";
/** As above, but for the previous job's trailing video instead of its last frame. */
export const DEFERRED_LAST_CLIP_PREFIX = "job-last-clip:";
export const deferredLastFrameSource = (jobId: string) => `${DEFERRED_LAST_FRAME_PREFIX}${jobId}`;
export const deferredLastClipSource = (jobId: string) => `${DEFERRED_LAST_CLIP_PREFIX}${jobId}`;
/** Saved by the server as `.../last_frames/last_frame_<job id>.png`. */
const LAST_FRAME_RE = /(?:^|\/)last_frame_([^/]+)\.png$/;
/** Saved by the server as `.../last_clips/last_clip_<job id>.mp4`. */
const LAST_CLIP_RE = /(?:^|\/)last_clip_([^/]+)\.mp4$/;
const REQUIRED_SECTIONS = [
"subject_definitions",
@@ -55,6 +65,20 @@ export function attachedLastFrames(references: Reference[] | null | undefined):
});
}
/** The job a last-clip reference came from, or null if `source` isn't one. */
export function lastClipSourceJobId(source: string): string | null {
if (source.startsWith(DEFERRED_LAST_CLIP_PREFIX)) return source.slice(DEFERRED_LAST_CLIP_PREFIX.length) || null;
return source.match(LAST_CLIP_RE)?.[1] ?? null;
}
/** Last-clip references already on a job, with the job each came from. */
export function attachedLastClips(references: Reference[] | null | undefined): { source: string; jobId: string }[] {
return (references ?? []).flatMap((r) => {
const jobId = lastClipSourceJobId(r.source);
return jobId ? [{ source: r.source, jobId }] : [];
});
}
/** Why a last frame can't be attached to this job, or null if it can. */
export function whyCannotAttach(prompt: string, references: Reference[] | null | undefined): string | null {
const missing = REQUIRED_SECTIONS.filter((s) => readField(prompt, s) === null);
@@ -189,3 +213,122 @@ export function detachLastFrame(prompt: string, references: Reference[], source:
}
return { ok: true, prompt: next, references: remaining };
}
/** Why a trailing clip can't be attached to this job, or null if it can. */
export function whyCannotAttachClip(prompt: string, references: Reference[] | null | undefined): string | null {
const missing = REQUIRED_SECTIONS.filter((s) => readField(prompt, s) === null);
if (missing.length > 0) {
return "This prompt isn't in the six-section format, so it can't be rewritten automatically. Attach the clip in Edit instead.";
}
const videos = (references ?? []).filter((r) => r.media_type === "video").length;
if (videos >= H3_REFERENCE_LIMITS.video) {
return `A job can have at most ${H3_REFERENCE_LIMITS.video} video references.`;
}
return null;
}
/** "[reference generation + audio reference]" -> "[video continuation + audio reference]"; drops "keyframe completion". */
function withVideoContinuation(summary: string): string {
const tag = summary.match(/^\[([^\]]*)\]\s*/);
const rest = tag ? summary.slice(tag[0].length) : summary;
const kept = tag
? tag[1].split("+").map((t) => t.trim()).filter((t) => t && t !== "reference generation" && t !== "keyframe completion")
: [];
if (!kept.includes("video continuation")) kept.unshift("video continuation");
return `[${kept.join(" + ")}] ${rest}`.trim();
}
/**
* Add `clipPath` as the next video reference and rewrite the prompt so Shot 1
* continues from it, per the guide's video-reference form: a standalone
* `<Video N>` declaration (videos aren't bound to a `<Subject>` the way
* pictures and audio are), a `[video continuation]` task tag, a retention
* line, and "the shot continues from <Video N>" in the first shot.
*/
export function attachLastClip(prompt: string, references: Reference[], clipPath: string): AttachResult {
if (references.some((r) => r.source === clipPath)) {
return { ok: false, error: "That clip is already attached to this job." };
}
const blocked = whyCannotAttachClip(prompt, references);
if (blocked) return { ok: false, error: blocked };
const label = `<Video ${references.filter((r) => r.media_type === "video").length + 1}>`;
const { preamble, shots } = parseDetailedDescription(readField(prompt, "detailed_description") ?? "", []);
if (shots.length === 0) return { ok: false, error: "This prompt has no shots to continue from the clip." };
const first = shots[0];
const lead = `The shot continues from ${label}.`;
const firstText = first.body.find((p) => p.kind === "text");
if (firstText && firstText.kind === "text") firstText.text = `${lead} ${firstText.text}`;
else first.body.unshift(newTextPart(lead));
let next = prompt;
next = writeField(
next,
"subject_definitions",
appendLine(
readField(next, "subject_definitions") ?? "",
`${label} is the source video for [Shot 1]: the last second of the previous clip, providing the continuation starting point and its motion.`,
),
);
next = writeField(next, "summary", withVideoContinuation(readField(next, "summary") ?? ""));
next = writeField(
next,
"retention_analysis",
appendLine(
readField(next, "retention_analysis") ?? "",
`${label} (continuation source): fully_preserved - the motion, scene, positions, and lighting of the previous clip's ending are continued exactly.`,
),
);
next = writeField(next, "detailed_description", detailedDescriptionFromShots(shots, preamble));
return { ok: true, prompt: next, references: [...references, { source: clipPath, media_type: "video" }], label };
}
/**
* Undo `attachLastClip`: remove the reference at `source` and take back what
* was added to the prompt for it. Only the most recently added video can be
* removed -- removing an earlier one would renumber the videos after it.
*/
export function detachLastClip(prompt: string, references: Reference[], source: string): DetachResult {
const videos = references.filter((r) => r.media_type === "video");
const index = videos.findIndex((r) => r.source === source);
if (index < 0) return { ok: false, error: "That clip isn't attached to this job." };
if (index !== videos.length - 1) {
return { ok: false, error: "Other videos were added after that clip. Remove it in Edit instead." };
}
const remaining = references.filter((r) => r.source !== source);
if (REQUIRED_SECTIONS.some((s) => readField(prompt, s) === null)) {
return { ok: true, prompt, references: remaining };
}
const label = `<Video ${index + 1}>`;
let next = prompt;
next = writeField(next, "subject_definitions", removeLine(readField(next, "subject_definitions") ?? "", `${label} is the source video for [Shot 1]`));
next = writeField(next, "retention_analysis", removeLine(readField(next, "retention_analysis") ?? "", `${label} (continuation source)`));
// "video continuation" replaced "reference generation" as the base tag (attachLastClip drops
// it outright, unlike "keyframe completion" which is only ever added alongside it) -- so
// undoing it means putting "reference generation" back, not just deleting the tag.
const summaryTag = (readField(next, "summary") ?? "").match(/^\[([^\]]*)\]\s*/);
if (summaryTag && summaryTag[1].split("+").map((t) => t.trim()).includes("video continuation")) {
const rest = (readField(next, "summary") ?? "").slice(summaryTag[0].length);
const types = summaryTag[1]
.split("+")
.map((t) => t.trim())
.map((t) => (t === "video continuation" ? "reference generation" : t));
next = writeField(next, "summary", `[${types.join(" + ")}] ${rest}`.trim());
}
const { preamble, shots } = parseDetailedDescription(readField(next, "detailed_description") ?? "", []);
const lead = `The shot continues from ${label}.`;
const first = shots[0];
const part = first?.body.find((p) => p.kind === "text" && p.text.startsWith(lead));
if (first && part && part.kind === "text") {
part.text = part.text.slice(lead.length).trimStart();
if (!part.text) first.body = first.body.filter((p) => p !== part);
next = writeField(next, "detailed_description", detailedDescriptionFromShots(shots, preamble));
}
return { ok: true, prompt: next, references: remaining };
}
@@ -9,6 +9,7 @@ import pytest
from fastvideo_studio.job_queue import (
circular_dependency,
deferred_last_clip_source,
deferred_last_frame_source,
dependencies,
dependency_of,
@@ -219,3 +220,59 @@ class TestResolveReferences:
with pytest.raises(UnresolvedReference, match="no frames"):
resolve_references(after("one"), self._jobs(one="completed").get, fail)
def after_clip(job_id: str) -> list[dict[str, str]]:
return [{"source": deferred_last_clip_source(job_id), "media_type": "video"}]
class TestResolveClipReferences:
"""The same resolution, but for a trailing-clip (job-last-clip:) reference."""
def _jobs(self, **statuses: str) -> dict[str, J]:
return {k: J(k, v, name=f"Clip {k}") for k, v in statuses.items()}
def test_uses_the_clip_resolver_not_the_frame_one(self):
jobs = self._jobs(one="completed")
stored = after_clip("one")
resolved = resolve_references(
stored, jobs.get,
last_frame=lambda job: f"/frames/{job.id}.png",
last_clip=lambda job: f"/clips/{job.id}.mp4",
)
assert resolved == [{"source": "/clips/one.mp4", "media_type": "video"}]
def test_a_mix_of_frame_and_clip_references_each_use_their_own_resolver(self):
jobs = self._jobs(a="completed", b="completed")
stored = [*after("a"), *after_clip("b"), {"source": "/x.png", "media_type": "image"}]
resolved = resolve_references(
stored, jobs.get,
last_frame=lambda job: f"/frames/{job.id}.png",
last_clip=lambda job: f"/clips/{job.id}.mp4",
)
assert resolved == [
{"source": "/frames/a.png", "media_type": "image"},
{"source": "/clips/b.mp4", "media_type": "video"},
{"source": "/x.png", "media_type": "image"},
]
def test_refuses_an_unfinished_source(self):
jobs = self._jobs(one="running")
with pytest.raises(UnresolvedReference, match="hasn't finished.*running"):
resolve_references(after_clip("one"), jobs.get, last_frame=lambda j: "", last_clip=lambda j: "/c.mp4")
def test_refuses_a_deleted_source(self):
with pytest.raises(UnresolvedReference, match="no longer exists"):
resolve_references(after_clip("gone"), {}.get, last_frame=lambda j: "", last_clip=lambda j: "/c.mp4")
def test_without_a_clip_resolver_a_clip_reference_is_refused_not_ignored(self):
jobs = self._jobs(one="completed")
with pytest.raises(UnresolvedReference, match="can't resolve"):
resolve_references(after_clip("one"), jobs.get, last_frame=lambda j: "/f.png")
def test_dependencies_and_circular_checks_see_clip_references_too(self):
jobs = self._jobs(a="pending")
jobs["a"].references = after_clip("a")
assert dependency_of(after_clip("x")[0]) == "x"
assert dependencies(after_clip("a") + after("a")) == ["a"]
assert circular_dependency(jobs, "a") is not None
@@ -18,6 +18,7 @@ from pathlib import Path
import pytest
from fastvideo_studio.database import Database
from fastvideo_studio.frames import FrameError
from fastvideo_studio.job_queue import deferred_last_frame_source
RUNNER_PATH = Path(__file__).resolve().parents[1] / "job_runner.py"
@@ -558,3 +559,126 @@ class TestRestart:
assert restored.references == frame_of(job)
assert restored.fps == 12
assert restored.negative_prompt == "neg"
class TestTrimming:
"""trim_job/restore_job_video against a real (small, synthetic) video file."""
FPS = 24
def _write_video(self, path, n=48):
import av
import numpy as np
Path(path).parent.mkdir(parents=True, exist_ok=True)
container = av.open(path, mode="w")
try:
stream = container.add_stream("h264", rate=self.FPS)
stream.width, stream.height, stream.pix_fmt = 64, 48, "yuv420p"
for i in range(n):
array = np.full((48, 64, 3), min(i * 5, 255), dtype=np.uint8)
for packet in stream.encode(av.VideoFrame.from_ndarray(array, format="rgb24")):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
finally:
container.close()
def _completed(self, h, r, n=48):
job = h.make(r, "clip")
r.start_job(job.id)
h.finish(job)
assert wait_for(lambda: r.get_job(job.id).status.value == "completed")
live = r.get_job(job.id)
path = str(h.tmp_path / "videos" / f"{job.id}.mp4")
self._write_video(path, n)
live.output_path = path
live.num_frames = n
return live
def test_trim_updates_output_path_and_frame_count(self, h):
r = h.runner()
job = self._completed(h, r) # 2s @ 24fps
original_path = job.output_path
trimmed = r.trim_job(job.id, start_seconds=0.5, end_seconds=1.5)
assert trimmed.output_path != original_path
assert trimmed.output_path.endswith("edited.mp4")
assert trimmed.num_frames == self.FPS # 1s kept
def test_grading_updates_output_path_without_changing_frame_count(self, h):
r = h.runner()
job = self._completed(h, r)
graded = r.trim_job(job.id, start_seconds=0.0, end_seconds=None, brightness=40.0)
assert graded.output_path.endswith("edited.mp4")
assert graded.num_frames == self.FPS * 2 # full length kept, only color changed
def test_grading_and_a_trim_requested_together_both_take_effect(self, h):
import av
r = h.runner()
job = self._completed(h, r)
edited = r.trim_job(job.id, start_seconds=0.5, end_seconds=1.5, brightness=80.0)
assert edited.num_frames == self.FPS # the range applied
with av.open(edited.output_path) as c:
frame = next(c.decode(c.streams.video[0]))
# Source frame at 0.5s (index 12) is level 60; +80 brightness should clearly lighten it.
assert int(frame.to_ndarray(format="rgb24")[0, 0, 0]) > 60 + 40
def test_trim_persists_across_a_restart(self, h):
first = h.runner()
job = self._completed(h, first)
trimmed = first.trim_job(job.id, start_seconds=0.5, end_seconds=1.5)
second = h.runner()
restored = second.get_job(job.id)
assert restored.output_path == trimmed.output_path
assert restored.num_frames == trimmed.num_frames
def test_trimming_again_is_relative_to_the_original_not_the_last_trim(self, h):
r = h.runner()
job = self._completed(h, r)
r.trim_job(job.id, start_seconds=0.5, end_seconds=1.5)
again = r.trim_job(job.id, start_seconds=0.0, end_seconds=2.0)
assert again.num_frames == self.FPS * 2
def test_restore_undoes_a_trim(self, h):
r = h.runner()
job = self._completed(h, r)
r.trim_job(job.id, start_seconds=0.5, end_seconds=1.5)
restored = r.restore_job_video(job.id)
# Points at the untouched backup, not literally the job's original path --
# trim_job() never overwrites or renames what H3 actually generated.
assert restored.output_path.endswith("original.mp4")
assert restored.num_frames == self.FPS * 2
def test_restore_refuses_a_job_never_edited(self, h):
r = h.runner()
job = self._completed(h, r)
with pytest.raises(FrameError, match="not been edited"):
r.restore_job_video(job.id)
def test_restore_undoes_a_grade_too(self, h):
import av
r = h.runner()
job = self._completed(h, r)
graded = r.trim_job(job.id, start_seconds=0.0, end_seconds=None, brightness=80.0)
with av.open(graded.output_path) as c:
graded_level = int(next(c.decode(c.streams.video[0])).to_ndarray(format="rgb24")[0, 0, 0])
assert graded_level > 60 # the grade visibly lightened the first frame (source level 0)
restored = r.restore_job_video(job.id)
with av.open(restored.output_path) as c:
restored_level = int(next(c.decode(c.streams.video[0])).to_ndarray(format="rgb24")[0, 0, 0])
assert restored_level == 0 # back to the source's actual first-frame level
def test_trim_refuses_an_unknown_job(self, h):
r = h.runner()
with pytest.raises(ValueError, match="not found"):
r.trim_job("nope", 0.0, 1.0)
def test_trim_refuses_a_pending_job(self, h):
r = h.runner()
job = h.make(r, "pending")
with pytest.raises(FrameError, match="No output"):
r.trim_job(job.id, 0.0, 1.0)
@@ -0,0 +1,209 @@
# SPDX-License-Identifier: Apache-2.0
"""A finished clip's trailing seconds are saved as a short video the next clip can continue from."""
from __future__ import annotations
import os
import time
from enum import Enum
from types import SimpleNamespace
import numpy as np
import pytest
from fastvideo_studio import frames
from fastvideo_studio.frames import FrameError, last_clip_for_job, save_last_clip
WIDTH, HEIGHT, FPS, FRAMES = 128, 96, 24, 48 # 2 seconds
class Status(Enum): # mirrors JobStatus without importing job_runner (which needs fastvideo)
PENDING = "pending"
COMPLETED = "completed"
FAILED = "failed"
def _write_video(path: str, n: int = FRAMES, fps: int = FPS) -> None:
"""A clip whose frames are numbered by brightness (frame i is roughly i*5 grey), so the tail is identifiable."""
import av
container = av.open(path, mode="w")
try:
stream = container.add_stream("h264", rate=fps)
stream.width, stream.height, stream.pix_fmt = WIDTH, HEIGHT, "yuv420p"
for i in range(n):
level = min(i * 5, 255)
array = np.full((HEIGHT, WIDTH, 3), level, dtype=np.uint8)
frame = av.VideoFrame.from_ndarray(array, format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
finally:
container.close()
def _read_levels(path: str) -> list[int]:
import av
with av.open(path) as container:
return [int(f.to_ndarray(format="rgb24")[0, 0, 0]) for f in container.decode(container.streams.video[0])]
def _job(job_id="job-1", status=Status.COMPLETED, output_path=None):
return SimpleNamespace(id=job_id, status=status, output_path=output_path)
@pytest.fixture
def video(tmp_path):
path = str(tmp_path / "clip.mp4")
_write_video(path)
return path
@pytest.fixture
def uploads(tmp_path):
return str(tmp_path / "uploads")
class TestSaveLastClip:
def test_keeps_roughly_the_requested_seconds(self, video, tmp_path):
out = str(tmp_path / "tail.mp4")
save_last_clip(video, out, seconds=1.0)
assert os.path.isfile(out)
levels = _read_levels(out)
assert FPS - 2 <= len(levels) <= FPS + 2 # ~1s at 24fps
def test_the_muxed_frame_rate_is_correct(self, video, tmp_path):
# Regression: every frame stamped with the same pts (e.g. all left at 0,
# or all set to `None` and left uncorrected) still decodes back the right
# *number* of frames, so a frame-count-only check doesn't catch it -- but
# it corrupts the container's average-frame-rate metadata, which is what
# H3's own reference pipeline reads to resample the clip. A wrong rate
# there silently collapses a short reference clip to as little as one
# usable frame.
out = str(tmp_path / "tail.mp4")
save_last_clip(video, out, seconds=1.0)
import av
with av.open(out) as container:
rate = float(container.streams.video[0].average_rate)
assert FPS - 1 <= rate <= FPS + 1
def test_frame_timestamps_are_distinct_and_increasing(self, video, tmp_path):
out = str(tmp_path / "tail.mp4")
save_last_clip(video, out, seconds=1.0)
import av
with av.open(out) as container:
times = [f.time for f in container.decode(container.streams.video[0])]
assert len(set(times)) == len(times)
assert times == sorted(times)
def test_keeps_the_end_of_the_source_not_the_start(self, video, tmp_path):
out = str(tmp_path / "tail.mp4")
save_last_clip(video, out, seconds=1.0)
levels = _read_levels(out)
full = _read_levels(video)
assert levels[-1] == full[-1] # the very last frame is preserved
assert levels[0] > full[0] # it does not start from the source's beginning
def test_asking_for_the_whole_clip_or_more_keeps_every_frame(self, video, tmp_path):
out = str(tmp_path / "tail.mp4")
save_last_clip(video, out, seconds=10.0)
assert len(_read_levels(out)) == FRAMES
def test_a_clip_with_no_frames(self, video, tmp_path, monkeypatch):
# A container that opens fine but yields zero frames (e.g. a truncated encode).
import av
class EmptyContainer:
class _Streams:
video = [SimpleNamespace(average_rate=24, guessed_rate=24)]
streams = _Streams()
def decode(self, *_a):
return iter(())
def __enter__(self):
return self
def __exit__(self, *_a):
return False
monkeypatch.setattr(av, "open", lambda *_a, **_k: EmptyContainer())
with pytest.raises(ValueError, match="no frames"):
save_last_clip(video, str(tmp_path / "out.mp4"))
def test_a_nonpositive_duration_is_refused(self, video, tmp_path):
with pytest.raises(ValueError, match="positive"):
save_last_clip(video, str(tmp_path / "out.mp4"), seconds=0)
def test_writes_no_temp_file_leftovers(self, video, tmp_path):
out = str(tmp_path / "tail.mp4")
save_last_clip(video, out, seconds=1.0)
assert os.listdir(tmp_path) == ["clip.mp4", "tail.mp4"]
class TestLastClipForJob:
def test_extracts_and_returns_a_path(self, video, uploads):
job = _job(output_path=video)
path = last_clip_for_job(job, uploads)
assert os.path.isfile(path)
assert path.endswith("last_clip_job-1.mp4")
assert "last_clips" in path
def test_reuses_an_existing_extraction(self, video, uploads, monkeypatch):
job = _job(output_path=video)
first = last_clip_for_job(job, uploads)
mtime = os.path.getmtime(first)
calls = []
monkeypatch.setattr(frames, "save_last_clip", lambda *a, **k: calls.append(a))
second = last_clip_for_job(job, uploads)
assert second == first
assert os.path.getmtime(second) == mtime
assert calls == []
def test_re_extracts_when_the_video_changed_since(self, video, uploads, monkeypatch):
job = _job(output_path=video)
last_clip_for_job(job, uploads)
_write_video(video, n=FRAMES + 24) # a longer re-render
# Force the source to read as newer, regardless of filesystem mtime resolution.
monkeypatch.setattr(os.path, "getmtime", lambda p: (time.time() + 1) if p == video else os.stat(p).st_mtime)
calls = []
real_save = frames.save_last_clip
monkeypatch.setattr(frames, "save_last_clip", lambda *a, **k: (calls.append(a), real_save(*a, **k))[1])
last_clip_for_job(job, uploads)
assert len(calls) == 1
@pytest.mark.parametrize(
("job", "match"),
[
(_job(status=Status.PENDING), "No output"),
(_job(status=Status.COMPLETED, output_path=None), "No output"),
(_job(status=Status.COMPLETED, output_path="/nope.mp4"), "not found"),
],
)
def test_refusals(self, job, match, uploads):
with pytest.raises(FrameError, match=match):
last_clip_for_job(job, uploads)
def test_refuses_a_non_video_output(self, uploads, tmp_path):
img = tmp_path / "out.png"
img.write_bytes(b"x")
job = _job(output_path=str(img))
with pytest.raises(FrameError, match="not a video"):
last_clip_for_job(job, uploads)
def test_refuses_without_an_upload_dir(self, video):
job = _job(output_path=video)
with pytest.raises(FrameError, match="Upload directory"):
last_clip_for_job(job, "")
def test_a_decode_failure_is_reported_as_a_500(self, uploads, tmp_path):
bad = tmp_path / "bad.mp4"
bad.write_bytes(b"not a video")
job = _job(output_path=str(bad))
with pytest.raises(FrameError, match="Could not read the end of the clip") as e:
last_clip_for_job(job, uploads)
assert e.value.status_code == 500
@@ -236,3 +236,109 @@ def test_merge_of_an_unknown_job_and_of_nothing(client):
@pytest.mark.parametrize("filename", ["nope.mp4", "..%2Fetc%2Fpasswd", "x.txt"])
def test_an_unknown_merged_file_is_not_found(client, filename):
assert client.get(f"/api/merged/{filename}").status_code == 404
# --- trimming ------------------------------------------------------------------
def test_trims_a_completed_jobs_video_by_resizing_its_frame_count(client):
job = _completed(client, "clip")
mock_server._jobs[job["id"]]["num_frames"] = 240 # 10s @ 24fps
mock_server._jobs[job["id"]]["fps"] = 24
res = client.post(f"/api/jobs/{job['id']}/trim", json={"start_seconds": 1.0, "end_seconds": 3.0})
assert res.status_code == 200
assert res.json()["num_frames"] == 48 # 2s @ 24fps
def test_an_open_end_keeps_to_the_original_end(client):
job = _completed(client, "clip")
mock_server._jobs[job["id"]]["num_frames"] = 240
mock_server._jobs[job["id"]]["fps"] = 24
res = client.post(f"/api/jobs/{job['id']}/trim", json={"start_seconds": 8.0})
assert res.json()["num_frames"] == 48 # 10s source, 8-10s kept = 2s remaining
def test_trimming_again_is_relative_to_the_original_length_not_the_last_trim(client):
job = _completed(client, "clip")
mock_server._jobs[job["id"]]["num_frames"] = 240
mock_server._jobs[job["id"]]["fps"] = 24
client.post(f"/api/jobs/{job['id']}/trim", json={"start_seconds": 1.0, "end_seconds": 3.0})
res = client.post(f"/api/jobs/{job['id']}/trim", json={"start_seconds": 0.0, "end_seconds": 10.0})
assert res.json()["num_frames"] == 240
def test_restore_undoes_a_trim(client):
job = _completed(client, "clip")
mock_server._jobs[job["id"]]["num_frames"] = 240
mock_server._jobs[job["id"]]["fps"] = 24
client.post(f"/api/jobs/{job['id']}/trim", json={"start_seconds": 1.0, "end_seconds": 3.0})
res = client.post(f"/api/jobs/{job['id']}/restore-video")
assert res.status_code == 200
assert res.json()["num_frames"] == 240
assert client.get(f"/api/jobs/{job['id']}").json()["num_frames"] == 240
def test_restore_refuses_a_job_that_was_never_edited(client):
job = _completed(client, "clip")
res = client.post(f"/api/jobs/{job['id']}/restore-video")
assert res.status_code == 404 and "not been edited" in res.json()["detail"]
def test_trim_refuses_an_invalid_range(client):
job = _completed(client, "clip")
mock_server._jobs[job["id"]]["num_frames"] = 240
mock_server._jobs[job["id"]]["fps"] = 24
assert client.post(f"/api/jobs/{job['id']}/trim", json={"start_seconds": -1.0}).status_code == 400
assert client.post(f"/api/jobs/{job['id']}/trim", json={"start_seconds": 5.0, "end_seconds": 5.0}).status_code == 400
def test_trim_accepts_color_parameters(client):
job = _completed(client, "clip")
mock_server._jobs[job["id"]]["num_frames"] = 240
mock_server._jobs[job["id"]]["fps"] = 24
res = client.post(
f"/api/jobs/{job['id']}/trim",
json={"start_seconds": 0.0, "end_seconds": 5.0, "brightness": 20.0, "contrast": 1.5, "saturation": 0.5},
)
assert res.status_code == 200
@pytest.mark.parametrize(
"body",
[
{"start_seconds": 0.0, "brightness": 200.0},
{"start_seconds": 0.0, "contrast": -1.0},
{"start_seconds": 0.0, "saturation": 10.0},
],
)
def test_trim_refuses_an_out_of_range_color_value(client, body):
job = _completed(client, "clip")
mock_server._jobs[job["id"]]["num_frames"] = 240
mock_server._jobs[job["id"]]["fps"] = 24
assert client.post(f"/api/jobs/{job['id']}/trim", json=body).status_code == 400
def test_trim_refuses_an_unfinished_job(client):
job = _create(client, name="pending")
res = client.post(f"/api/jobs/{job['id']}/trim", json={"start_seconds": 0.0, "end_seconds": 1.0})
assert res.status_code == 404
def test_trim_and_restore_of_an_unknown_job(client):
assert client.post("/api/jobs/nope/trim", json={"start_seconds": 0.0}).status_code == 404
assert client.post("/api/jobs/nope/restore-video").status_code == 404
def test_trimmed_frame_count_does_not_leak_the_internal_backup_key(client):
job = _completed(client, "clip")
mock_server._jobs[job["id"]]["num_frames"] = 240
mock_server._jobs[job["id"]]["fps"] = 24
res = client.post(f"/api/jobs/{job['id']}/trim", json={"start_seconds": 1.0, "end_seconds": 3.0})
assert not any(k.startswith("_") for k in res.json())
assert not any(k.startswith("_") for k in client.get(f"/api/jobs/{job['id']}").json())
+374
View File
@@ -0,0 +1,374 @@
# SPDX-License-Identifier: Apache-2.0
"""Cutting a finished clip to a chosen in/out range and adjusting its color, non-destructively."""
from __future__ import annotations
import os
from enum import Enum
from types import SimpleNamespace
import numpy as np
import pytest
from fastvideo_studio.frames import FrameError
from fastvideo_studio.trim import (
BRIGHTNESS_RANGE,
CONTRAST_RANGE,
EDITED_FILENAME,
ORIGINAL_FILENAME,
SATURATION_RANGE,
clip_duration_seconds,
restore_original,
trim_video,
)
WIDTH, HEIGHT, FPS, FRAMES = 128, 96, 24, 48 # 2 seconds, one frame per level 0..47
class Status(Enum): # mirrors JobStatus without importing job_runner (which needs fastvideo)
PENDING = "pending"
COMPLETED = "completed"
FAILED = "failed"
def _write_video(path: str, n: int = FRAMES, fps: int = FPS) -> None:
"""A clip whose frames are numbered by brightness (frame i is roughly i*5 grey), so a range is identifiable."""
import av
container = av.open(path, mode="w")
try:
stream = container.add_stream("h264", rate=fps)
stream.width, stream.height, stream.pix_fmt = WIDTH, HEIGHT, "yuv420p"
for i in range(n):
level = min(i * 5, 255)
frame = av.VideoFrame.from_ndarray(np.full((HEIGHT, WIDTH, 3), level, dtype=np.uint8), format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
finally:
container.close()
AUDIO_SAMPLE_RATE = 32000
AUDIO_SAMPLES_PER_FRAME = 1024
def _write_video_with_audio(path: str, n: int = FRAMES, fps: int = FPS) -> None:
"""Same as ``_write_video``, plus a silent stereo AAC track, to test that edits keep audio."""
import av
from fractions import Fraction
container = av.open(path, mode="w")
try:
vstream = container.add_stream("h264", rate=fps)
vstream.width, vstream.height, vstream.pix_fmt = WIDTH, HEIGHT, "yuv420p"
astream = container.add_stream("aac", rate=AUDIO_SAMPLE_RATE)
astream.layout = "stereo"
for i in range(n):
level = min(i * 5, 255)
frame = av.VideoFrame.from_ndarray(np.full((HEIGHT, WIDTH, 3), level, dtype=np.uint8), format="rgb24")
for packet in vstream.encode(frame):
container.mux(packet)
for packet in vstream.encode():
container.mux(packet)
audio_time_base = Fraction(1, AUDIO_SAMPLE_RATE)
n_audio_frames = int(n / fps * AUDIO_SAMPLE_RATE / AUDIO_SAMPLES_PER_FRAME) + 2
pts = 0
for _ in range(n_audio_frames):
arr = np.zeros((2, AUDIO_SAMPLES_PER_FRAME), dtype=np.float32)
aframe = av.AudioFrame.from_ndarray(arr, format="fltp", layout="stereo")
aframe.sample_rate = AUDIO_SAMPLE_RATE
aframe.pts = pts
aframe.time_base = audio_time_base
pts += AUDIO_SAMPLES_PER_FRAME
for packet in astream.encode(aframe):
container.mux(packet)
for packet in astream.encode(None):
container.mux(packet)
finally:
container.close()
def _write_colored_video(path: str, rgb: tuple[int, int, int], n: int = 12, fps: int = FPS) -> None:
"""A clip of one solid, non-gray color, so saturation changes are visible (unlike on a gray clip)."""
import av
container = av.open(path, mode="w")
try:
stream = container.add_stream("h264", rate=fps)
stream.width, stream.height, stream.pix_fmt = WIDTH, HEIGHT, "yuv420p"
for _ in range(n):
frame = av.VideoFrame.from_ndarray(np.full((HEIGHT, WIDTH, 3), rgb, dtype=np.uint8), format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
finally:
container.close()
def _read_levels(path: str) -> list[int]:
import av
with av.open(path) as container:
return [int(f.to_ndarray(format="rgb24")[0, 0, 0]) for f in container.decode(container.streams.video[0])]
def _read_mean_rgb(path: str, index: int = 0) -> np.ndarray:
import av
with av.open(path) as container:
frames = list(container.decode(container.streams.video[0]))
return frames[index].to_ndarray(format="rgb24").astype(float).mean(axis=(0, 1))
def _job(job_id="job-1", status=Status.COMPLETED, output_path=None):
return SimpleNamespace(id=job_id, status=status, output_path=output_path)
@pytest.fixture
def job_dir(tmp_path):
d = tmp_path / "job-1"
d.mkdir()
return d
@pytest.fixture
def video(job_dir):
path = str(job_dir / "wolf-lunch-clip-06.mp4")
_write_video(path)
return path
@pytest.fixture
def colored_video(job_dir):
path = str(job_dir / "wolf-lunch-clip-07.mp4")
_write_colored_video(path, (200, 100, 50)) # a warm orange, clearly non-gray
return path
@pytest.fixture
def video_with_audio(job_dir):
path = str(job_dir / "wolf-lunch-clip-08.mp4")
_write_video_with_audio(path)
return path
class TestTrimVideo:
def test_keeps_only_the_requested_range(self, video, job_dir):
path, kept = trim_video(_job(output_path=video), start_seconds=0.5, end_seconds=1.5)
assert path == str(job_dir / EDITED_FILENAME)
levels = _read_levels(path)
assert kept == len(levels) == FPS # 1 second kept
assert levels[0] == _read_levels(video)[12] # starts at 0.5s = frame 12
assert levels[-1] == _read_levels(video)[35] # ends just before 1.5s = frame 36
def test_an_open_end_keeps_to_the_end_of_the_source(self, video):
path, kept = trim_video(_job(output_path=video), start_seconds=1.0, end_seconds=None)
assert kept == FRAMES - FPS
assert _read_levels(path)[-1] == _read_levels(video)[-1]
def test_start_at_zero_keeps_from_the_beginning(self, video):
path, _ = trim_video(_job(output_path=video), start_seconds=0.0, end_seconds=1.0)
assert _read_levels(path)[0] == _read_levels(video)[0]
def test_the_original_is_preserved_byte_for_byte(self, video, job_dir):
before = open(video, "rb").read()
trim_video(_job(output_path=video), start_seconds=0.5, end_seconds=1.5)
original = job_dir / ORIGINAL_FILENAME
assert original.is_file()
assert original.read_bytes() == before
def test_trimming_again_re_cuts_from_the_original_not_the_edited_file(self, video, job_dir):
# If it re-cut the already-edited 0.5-1.5s file, asking for 0-2s again
# could not recover the parts outside that first window.
trim_video(_job(output_path=video), start_seconds=0.5, end_seconds=1.5)
path, kept = trim_video(_job(output_path=video), start_seconds=0.0, end_seconds=2.0)
assert kept == FRAMES
assert _read_levels(path) == _read_levels(video)
def test_narrowing_the_range_further_still_re_cuts_from_the_original(self, video):
trim_video(_job(output_path=video), start_seconds=0.0, end_seconds=2.0)
path, kept = trim_video(_job(output_path=video), start_seconds=1.0, end_seconds=1.5)
assert kept == FPS // 2
assert _read_levels(path)[0] == _read_levels(video)[24]
def test_muxed_frame_rate_stays_correct(self, video):
path, _ = trim_video(_job(output_path=video), start_seconds=0.0, end_seconds=1.0)
import av
with av.open(path) as c:
assert FPS - 1 <= float(c.streams.video[0].average_rate) <= FPS + 1
@pytest.mark.parametrize(
("start", "end", "match"),
[
(-1.0, None, "negative"),
(1.0, 1.0, "after the start"),
(1.0, 0.5, "after the start"),
(100.0, None, "no frames"),
],
)
def test_refuses_an_invalid_range(self, video, start, end, match):
with pytest.raises(FrameError, match=match) as e:
trim_video(_job(output_path=video), start_seconds=start, end_seconds=end)
assert e.value.status_code == 400
@pytest.mark.parametrize(
("job", "match"),
[
(_job(status=Status.PENDING), "No output"),
(_job(status=Status.COMPLETED, output_path=None), "No output"),
(_job(status=Status.COMPLETED, output_path="/nope.mp4"), "not found"),
],
)
def test_refusals(self, job, match):
with pytest.raises(FrameError, match=match):
trim_video(job, 0.0, 1.0)
def test_refuses_a_non_video_output(self, tmp_path):
img = tmp_path / "out.png"
img.write_bytes(b"x")
with pytest.raises(FrameError, match="not a video"):
trim_video(_job(output_path=str(img)), 0.0, 1.0)
class TestGrading:
def test_neutral_values_do_not_alter_the_pixels(self, colored_video):
path, _ = trim_video(_job(output_path=colored_video), 0.0, None, brightness=0.0, contrast=1.0, saturation=1.0)
got = _read_mean_rgb(path)
want = _read_mean_rgb(colored_video)
assert np.allclose(got, want, atol=2) # h264 is lossy even unchanged
def test_neutral_values_skip_the_per_frame_numpy_round_trip(self, colored_video, monkeypatch):
import fastvideo_studio.trim as trim_module
calls = []
monkeypatch.setattr(trim_module, "_apply_grade", lambda *a: calls.append(a) or a[0])
trim_video(_job(output_path=colored_video), 0.0, None)
assert calls == []
def test_positive_brightness_lightens_every_channel(self, colored_video):
path, _ = trim_video(_job(output_path=colored_video), 0.0, None, brightness=50.0)
got = _read_mean_rgb(path)
want = _read_mean_rgb(colored_video)
assert (got > want + 30).all()
def test_negative_brightness_darkens(self, colored_video):
path, _ = trim_video(_job(output_path=colored_video), 0.0, None, brightness=-50.0)
got = _read_mean_rgb(path)
want = _read_mean_rgb(colored_video)
assert (got < want - 30).all()
def test_zero_saturation_makes_every_channel_equal(self, colored_video):
path, _ = trim_video(_job(output_path=colored_video), 0.0, None, saturation=0.0)
r, g, b = _read_mean_rgb(path)
assert abs(r - g) < 2 and abs(g - b) < 2 # gray: channels converge on the frame's luma
def test_saturation_above_one_widens_the_channel_spread(self, colored_video):
base_r, base_g, base_b = _read_mean_rgb(colored_video)
path, _ = trim_video(_job(output_path=colored_video), 0.0, None, saturation=2.0)
r, g, b = _read_mean_rgb(path)
assert (r - b) > (base_r - base_b) # the warm/cool spread grows, not shrinks
def test_high_contrast_pushes_values_away_from_mid_gray(self, colored_video):
path, _ = trim_video(_job(output_path=colored_video), 0.0, None, contrast=2.0)
got = _read_mean_rgb(path)
want = _read_mean_rgb(colored_video)
# 128 = mid-gray. This clip's R (200) sits above it and should be pushed further up;
# its G and B (100, 50) sit below and should be pushed further down -- the spread
# around 128 widens either way.
assert got[0] > want[0] # R: 200 -> further above 128
assert got[1] < want[1] # G: 100 -> further below 128
assert got[2] < want[2] # B: 50 -> further below 128
def test_grading_and_trimming_the_same_call_compose(self, video, colored_video):
# A range trim and a color grade requested together both take effect --
# neither is silently dropped by the other.
path, kept = trim_video(_job(output_path=colored_video), start_seconds=0.2, end_seconds=0.3, brightness=40.0)
assert kept == pytest.approx(FPS * 0.1, abs=1)
base = _read_mean_rgb(colored_video)
assert (_read_mean_rgb(path) > base + 20).all()
@pytest.mark.parametrize(
("kwargs", "bounds"),
[
({"brightness": BRIGHTNESS_RANGE[1] + 1}, BRIGHTNESS_RANGE),
({"brightness": BRIGHTNESS_RANGE[0] - 1}, BRIGHTNESS_RANGE),
({"contrast": CONTRAST_RANGE[1] + 1}, CONTRAST_RANGE),
({"contrast": CONTRAST_RANGE[0] - 1}, CONTRAST_RANGE),
({"saturation": SATURATION_RANGE[1] + 1}, SATURATION_RANGE),
({"saturation": SATURATION_RANGE[0] - 1}, SATURATION_RANGE),
],
)
def test_refuses_an_out_of_range_value(self, colored_video, kwargs, bounds):
with pytest.raises(FrameError, match="between") as e:
trim_video(_job(output_path=colored_video), 0.0, None, **kwargs)
assert e.value.status_code == 400
@pytest.mark.parametrize("kwargs", [{"brightness": BRIGHTNESS_RANGE[1]}, {"contrast": CONTRAST_RANGE[0]},
{"saturation": SATURATION_RANGE[1]}])
def test_the_edges_of_each_range_are_allowed(self, colored_video, kwargs):
trim_video(_job(output_path=colored_video), 0.0, None, **kwargs) # does not raise
class TestAudio:
def test_a_source_with_no_audio_stays_video_only(self, video):
path, _ = trim_video(_job(output_path=video), start_seconds=0.5, end_seconds=1.5)
import av
with av.open(path) as c:
assert [s.type for s in c.streams] == ["video"]
def test_a_range_trim_keeps_the_audio_track(self, video_with_audio):
path, _ = trim_video(_job(output_path=video_with_audio), start_seconds=0.5, end_seconds=1.5)
import av
with av.open(path) as c:
assert "audio" in [s.type for s in c.streams]
a = c.streams.audio[0]
assert a.codec_context.sample_rate == AUDIO_SAMPLE_RATE
assert sum(1 for _ in c.decode(a)) > 0
def test_grading_alongside_a_trim_also_keeps_the_audio_track(self, video_with_audio):
path, _ = trim_video(_job(output_path=video_with_audio), start_seconds=0.0, end_seconds=1.0, brightness=40.0)
import av
with av.open(path) as c:
assert "audio" in [s.type for s in c.streams]
def test_restore_original_keeps_the_audio_track(self, video_with_audio, job_dir):
trim_video(_job(output_path=video_with_audio), start_seconds=0.5, end_seconds=1.5)
path, _ = restore_original(_job(output_path=str(job_dir / EDITED_FILENAME)))
import av
with av.open(path) as c:
assert "audio" in [s.type for s in c.streams]
class TestRestoreOriginal:
def test_restores_the_original_video_and_its_frame_count(self, video, job_dir):
trim_video(_job(output_path=video), start_seconds=0.5, end_seconds=1.5)
path, n = restore_original(_job(output_path=str(job_dir / EDITED_FILENAME)))
assert path == str(job_dir / ORIGINAL_FILENAME)
assert n == FRAMES
assert _read_levels(path) == _read_levels(video)
def test_restores_past_a_grade_too(self, colored_video, job_dir):
trim_video(_job(output_path=colored_video), 0.0, None, brightness=50.0, saturation=0.0)
path, _ = restore_original(_job(output_path=str(job_dir / EDITED_FILENAME)))
assert np.allclose(_read_mean_rgb(path), _read_mean_rgb(colored_video), atol=2)
def test_refuses_a_job_that_was_never_edited(self, video):
with pytest.raises(FrameError, match="not been edited"):
restore_original(_job(output_path=video))
def test_refuses_a_job_with_no_output(self):
with pytest.raises(FrameError, match="No output"):
restore_original(_job(output_path=None))
def test_clip_duration_seconds(video):
assert clip_duration_seconds(video) == pytest.approx(FRAMES / FPS, abs=0.05)
+234
View File
@@ -0,0 +1,234 @@
# SPDX-License-Identifier: Apache-2.0
"""Cut a finished clip to a chosen in/out range and adjust its color, non-destructively.
Kept apart from ``frames.py`` (which saves the *end* of a clip for another job
to continue from) because this edits a clip's own timeline, not a reference
for someone else. Needs only PyAV and numpy, so it can be exercised without a GPU.
Trimming and grading share one pipeline rather than being two independent
edits, on purpose: both always re-render from the untouched original in a
single pass with the *current* full set of parameters (range and color
together). Two separate "always start from the original" operations would
each undo the other's work the moment they ran -- grading, then trimming,
would silently discard the grade, because the trim would re-cut the original
as if the grade had never happened. Rendering everything from one function
each time is what lets adjusting either one leave the other in place.
"""
from __future__ import annotations
import os
import shutil
from fractions import Fraction
from pathlib import Path
from typing import Any
import numpy as np
from fastvideo_studio.frames import VIDEO_EXTENSIONS, FrameError
ORIGINAL_FILENAME = "original.mp4"
EDITED_FILENAME = "edited.mp4"
# Neutral (no-op) values for each color control, and the range each is clamped to.
BRIGHTNESS_RANGE = (-100.0, 100.0) # added directly to 0-255 pixel values
CONTRAST_RANGE = (0.0, 3.0) # 1.0 = unchanged
SATURATION_RANGE = (0.0, 3.0) # 1.0 = unchanged, 0 = grayscale
# Perceptual luma weights, matching the ones used elsewhere in Studio for brightness checks.
_LUMA_WEIGHTS = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
def clip_duration_seconds(video_path: str) -> float:
"""Decode ``video_path`` and return its length in seconds."""
import av
with av.open(video_path) as container:
stream = container.streams.video[0]
fps = float(stream.average_rate or stream.guessed_rate or 24)
n = sum(1 for _ in container.decode(stream))
if n == 0:
raise ValueError("The video has no frames.")
return n / fps
def _clamp_range(name: str, value: float, bounds: tuple[float, float]) -> float:
low, high = bounds
if not (low <= value <= high):
raise ValueError(f"{name} must be between {low:g} and {high:g}, got {value:g}.")
return value
def _apply_grade(array: np.ndarray, brightness: float, contrast: float, saturation: float) -> np.ndarray:
"""Adjust one RGB uint8 frame. Neutral values (0, 1, 1) are a no-op, skipped by the caller."""
pixels = array.astype(np.float32)
if saturation != 1.0:
luma = (pixels * _LUMA_WEIGHTS).sum(axis=-1, keepdims=True)
pixels = luma + (pixels - luma) * saturation
if contrast != 1.0:
pixels = (pixels - 128.0) * contrast + 128.0
if brightness != 0.0:
pixels = pixels + brightness
return np.clip(pixels, 0, 255).astype(np.uint8)
def _render_edit(
video_path: str,
out_path: str,
start_seconds: float,
end_seconds: float | None,
brightness: float,
contrast: float,
saturation: float,
) -> int:
"""Re-encode [``start_seconds``, ``end_seconds``) of ``video_path`` to ``out_path``, color-adjusted.
Re-encoded rather than stream-copied, for a frame-accurate cut regardless
of where the source's keyframes happen to fall. Returns the frame count kept.
"""
import av
if start_seconds < 0:
raise ValueError("The start of the range can't be negative.")
if end_seconds is not None and end_seconds <= start_seconds:
raise ValueError("The end of the range must come after the start.")
_clamp_range("brightness", brightness, BRIGHTNESS_RANGE)
_clamp_range("contrast", contrast, CONTRAST_RANGE)
_clamp_range("saturation", saturation, SATURATION_RANGE)
graded = brightness != 0.0 or contrast != 1.0 or saturation != 1.0
with av.open(video_path) as src:
video_stream = src.streams.video[0]
audio_stream = src.streams.audio[0] if src.streams.audio else None
fps = float(video_stream.average_rate or video_stream.guessed_rate or 24)
frames: list[Any] = []
audio_frames: list[Any] = []
# One decode pass covers both streams -- the container can't be
# decoded a second time once its packets are exhausted.
decode_streams = (video_stream, audio_stream) if audio_stream else (video_stream,)
for frame in src.decode(*decode_streams):
(audio_frames if isinstance(frame, av.AudioFrame) else frames).append(frame)
if not frames:
raise ValueError("The video has no frames.")
start_i = max(0, round(start_seconds * fps))
end_i = len(frames) if end_seconds is None else min(len(frames), round(end_seconds * fps))
kept = frames[start_i:end_i]
if not kept:
raise ValueError("That range keeps no frames.")
# Audio is cut to the same frame-granular window as the video, not the raw
# requested seconds, so the two tracks stay aligned.
kept_audio = [f for f in audio_frames if f.time is not None and start_i / fps <= f.time < end_i / fps]
tmp_path = f"{out_path}.tmp.mp4"
with av.open(tmp_path, mode="w") as out:
out_stream = out.add_stream("h264", rate=round(fps))
out_stream.width, out_stream.height = kept[0].width, kept[0].height
out_stream.pix_fmt = "yuv420p"
# Every output stream has to be added before the first packet is muxed
# (that's what writes the container header), so the audio stream and
# resampler are set up here even though they're only used after the
# video loop below.
out_audio_stream = None
resampler = None
if kept_audio:
out_audio_stream = out.add_stream("aac", rate=audio_stream.codec_context.sample_rate)
out_audio_stream.layout = audio_stream.codec_context.layout.name
resampler = av.AudioResampler(
format=out_audio_stream.format,
layout=out_audio_stream.layout,
rate=out_audio_stream.rate,
)
# Each frame needs its own increasing pts at a known time base, or the
# muxed container's frame-rate metadata comes out wrong (see frames.py's
# save_last_clip, which hit exactly this).
frame_time_base = Fraction(1, round(fps))
for i, frame in enumerate(kept):
if graded:
array = _apply_grade(frame.to_ndarray(format="rgb24"), brightness, contrast, saturation)
frame = av.VideoFrame.from_ndarray(array, format="rgb24")
frame.pts = i
frame.time_base = frame_time_base
for packet in out_stream.encode(frame):
out.mux(packet)
for packet in out_stream.encode():
out.mux(packet)
if out_audio_stream is not None:
for frame in kept_audio:
frame.pts = None # let the resampler retime relative to the trimmed start
for rframe in resampler.resample(frame):
for packet in out_audio_stream.encode(rframe):
out.mux(packet)
for packet in out_audio_stream.encode(None):
out.mux(packet)
os.replace(tmp_path, out_path) # atomic, so a reader never sees a half-written file
return len(kept)
def _require_completed_video(job: Any) -> None:
status = getattr(job.status, "value", job.status)
if status != "completed" or not job.output_path:
raise FrameError(404, "No output available for this job")
if not os.path.isfile(job.output_path):
raise FrameError(404, "Output file not found on disk")
if Path(job.output_path).suffix.lower() not in VIDEO_EXTENSIONS:
raise FrameError(400, "This job's output is not a video")
def trim_video(
job: Any,
start_seconds: float = 0.0,
end_seconds: float | None = None,
brightness: float = 0.0,
contrast: float = 1.0,
saturation: float = 1.0,
) -> tuple[str, int]:
"""Cut ``job``'s video to [``start_seconds``, ``end_seconds``) and adjust its color.
Always renders from the untouched original with this *full* set of
parameters, so trimming and grading compose -- adjusting one doesn't
discard the other, and neither ever re-encodes an already-edited file.
Returns ``(new_output_path, frames_kept)``. Raises :class:`FrameError` with
the status the API should report.
"""
_require_completed_video(job)
job_dir = os.path.dirname(job.output_path)
original_path = os.path.join(job_dir, ORIGINAL_FILENAME)
if not os.path.isfile(original_path):
# First edit: keep an untouched copy of whatever is there now before
# changing anything, so every later edit (and a restore) is relative
# to this, never to a file this function already produced.
shutil.copy2(job.output_path, original_path)
edited_path = os.path.join(job_dir, EDITED_FILENAME)
try:
kept = _render_edit(original_path, edited_path, start_seconds, end_seconds, brightness, contrast, saturation)
except FrameError:
raise
except Exception as e:
raise FrameError(400, str(e)) from e
return edited_path, kept
def restore_original(job: Any) -> tuple[str, int]:
"""Undo every edit: the path of ``job``'s untouched original video, and its frame count.
Undoes trimming and grading together, since they share one edit state.
Raises :class:`FrameError` if the job was never edited.
"""
if not job.output_path:
raise FrameError(404, "No output available for this job")
original_path = os.path.join(os.path.dirname(job.output_path), ORIGINAL_FILENAME)
if not os.path.isfile(original_path):
raise FrameError(404, "This job has not been edited.")
try:
import av
with av.open(original_path) as container:
stream = container.streams.video[0]
n = sum(1 for _ in container.decode(stream))
except Exception as e:
raise FrameError(500, f"Could not read the original video: {e}") from e
return original_path, n