From fab9b3159cfd06bd0db5c12c5efe8176ddf40c62 Mon Sep 17 00:00:00 2001 From: kevin314 Date: Mon, 28 Sep 2026 11:47:24 +0000 Subject: [PATCH] Add last-clip, cutting, color grading --- apps/fastvideo_studio/README.md | 52 ++- apps/fastvideo_studio/frames.py | 83 +++- apps/fastvideo_studio/job_queue.py | 69 ++-- apps/fastvideo_studio/job_runner.py | 70 +++- apps/fastvideo_studio/mock_server.py | 76 +++- apps/fastvideo_studio/models/__init__.py | 2 + apps/fastvideo_studio/models/trim_request.py | 16 + apps/fastvideo_studio/server.py | 55 ++- .../src/app/scenes/page.test.tsx | 190 ++++++++- apps/fastvideo_studio/src/app/scenes/page.tsx | 22 ++ .../src/components/scenes/SceneBoard.tsx | 34 +- .../src/components/scenes/TrimClipDialog.tsx | 270 +++++++++++++ apps/fastvideo_studio/src/lib/api.ts | 76 +++- .../src/lib/continuation.test.ts | 201 ++++++++++ apps/fastvideo_studio/src/lib/continuation.ts | 155 +++++++- apps/fastvideo_studio/tests/test_job_queue.py | 57 +++ .../tests/test_job_queue_runner.py | 124 ++++++ apps/fastvideo_studio/tests/test_last_clip.py | 209 ++++++++++ .../tests/test_mock_server_jobs.py | 106 +++++ apps/fastvideo_studio/tests/test_trim.py | 374 ++++++++++++++++++ apps/fastvideo_studio/trim.py | 234 +++++++++++ 21 files changed, 2417 insertions(+), 58 deletions(-) create mode 100644 apps/fastvideo_studio/models/trim_request.py create mode 100644 apps/fastvideo_studio/src/components/scenes/TrimClipDialog.tsx create mode 100644 apps/fastvideo_studio/tests/test_last_clip.py create mode 100644 apps/fastvideo_studio/tests/test_trim.py create mode 100644 apps/fastvideo_studio/trim.py diff --git a/apps/fastvideo_studio/README.md b/apps/fastvideo_studio/README.md index 3e40fa379..26d374524 100644 --- a/apps/fastvideo_studio/README.md +++ b/apps/fastvideo_studio/README.md @@ -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:`. 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:`. 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:`, 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 | diff --git a/apps/fastvideo_studio/frames.py b/apps/fastvideo_studio/frames.py index c6dd017b9..7d6ee8da6 100644 --- a/apps/fastvideo_studio/frames.py +++ b/apps/fastvideo_studio/frames.py @@ -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 diff --git a/apps/fastvideo_studio/job_queue.py b/apps/fastvideo_studio/job_queue.py index 2b752bd51..2c4d6d2ac 100644 --- a/apps/fastvideo_studio/job_queue.py +++ b/apps/fastvideo_studio/job_queue.py @@ -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:`` 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:`` or ``job-last-clip:``, 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}) diff --git a/apps/fastvideo_studio/job_runner.py b/apps/fastvideo_studio/job_runner.py index c039d4040..e352f5ad0 100644 --- a/apps/fastvideo_studio/job_runner.py +++ b/apps/fastvideo_studio/job_runner.py @@ -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" diff --git a/apps/fastvideo_studio/mock_server.py b/apps/fastvideo_studio/mock_server.py index 584781ad0..2a8af68d6 100644 --- a/apps/fastvideo_studio/mock_server.py +++ b/apps/fastvideo_studio/mock_server.py @@ -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") diff --git a/apps/fastvideo_studio/models/__init__.py b/apps/fastvideo_studio/models/__init__.py index 43dba21ff..7e867f0a9 100644 --- a/apps/fastvideo_studio/models/__init__.py +++ b/apps/fastvideo_studio/models/__init__.py @@ -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", ] diff --git a/apps/fastvideo_studio/models/trim_request.py b/apps/fastvideo_studio/models/trim_request.py new file mode 100644 index 000000000..ec3faf7b2 --- /dev/null +++ b/apps/fastvideo_studio/models/trim_request.py @@ -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 diff --git a/apps/fastvideo_studio/server.py b/apps/fastvideo_studio/server.py index c162559dd..513d3e315 100644 --- a/apps/fastvideo_studio/server.py +++ b/apps/fastvideo_studio/server.py @@ -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. diff --git a/apps/fastvideo_studio/src/app/scenes/page.test.tsx b/apps/fastvideo_studio/src/app/scenes/page.test.tsx index 66ff7cbd8..d1050f0e0 100644 --- a/apps/fastvideo_studio/src/app/scenes/page.test.tsx +++ b/apps/fastvideo_studio/src/app/scenes/page.test.tsx @@ -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[1] = {}) => + wolf(n, { status: 'completed', output_path: `/o/${n}.mp4`, num_frames: 240, fps: 24, ...over }); + const openEdit = async (user: ReturnType, 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 + }); +}); diff --git a/apps/fastvideo_studio/src/app/scenes/page.tsx b/apps/fastvideo_studio/src/app/scenes/page.tsx index 9a3395106..91d6b6ad2 100644 --- a/apps/fastvideo_studio/src/app/scenes/page.tsx +++ b/apps/fastvideo_studio/src/app/scenes/page.tsx @@ -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>({}); const [queueTarget, setQueueTarget] = React.useState(null); const [merged, setMerged] = React.useState<(MergedScene & { title: string }) | null>(null); + const [trimTarget, setTrimTarget] = React.useState(null); + // Bumped per job id whenever its video is edited in place, to cache-bust every + //