Add last-clip, cutting, color grading
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -50,8 +50,15 @@ function stripTrailingSlash(url: string): string {
|
||||
}
|
||||
|
||||
/** Full URL for streaming a job's video/image output (for <video> or <img> 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user