- The cached-model listing tagged any repo with a model_index.json as text-to-image, so a community pipeline the image loader's trust rule refuses still got a row in the Images picker, and a detected-but-untrusted video repo fell through to that same tag. Gate the image tag on the load path's rule and hide an untrusted video repo outright. - A routed diffusion pick only carries a GGUF filename, which is all the chat picker has, so a curated single-file artifact arrived with no quant and was loaded as a pipeline: from_pretrained on a repo with no model_index.json. Pass the page's own catalog spec into the route pick, so a routed pick resolves to exactly what a direct pick on that page resolves to. - The dataset mutation endpoints checked is_active() and only then handed their filesystem work to a thread, so a start reserving in that gap changed captions or removed images underneath the preflight or the running trainer. The interlock is now registered for the whole request under the lock reserve() uses, and a start refuses while a mutation is open rather than waiting on it. - GIF export held every kept frame as a paletted image before encoding; a clip may be 2048x2048 for 1024 frames, and at the 12 fps target the step is 1, so one export click could allocate over 4 GB and take the backend down. Downscale past 720 px and widen the step to keep at most 300 frames. - seed accepted any Python int, so an out-of-range one passed every preflight, evicted the resident models, spawned the trainer and only then died in torch.manual_seed. Bound it to torch's 64-bit range in the request and config.
340 lines
13 KiB
Python
340 lines
13 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Disk-backed persistence for generated videos.
|
|
|
|
Each video is a pair under ``studio_root()/videos``: ``{id}.mp4`` holds the bytes, ``{id}.json``
|
|
holds the recipe (an MP4 has no portable text-chunk like a PNG). The pair travels together; a lone
|
|
file is not a valid record. Dumb storage: the route owns the schema; this only reads/writes/sorts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from loggers import get_logger
|
|
from utils.paths import ensure_dir, studio_root
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Video ids are file stems; restrict to safe chars so a crafted id can't escape the directory.
|
|
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
|
|
|
|
def gallery_dir() -> Path:
|
|
return ensure_dir(studio_root() / "videos")
|
|
|
|
|
|
def save(mp4_bytes: bytes, meta: dict[str, Any]) -> dict[str, Any]:
|
|
"""Persist encoded MP4 bytes plus their recipe sidecar; return the record."""
|
|
video_id = uuid.uuid4().hex
|
|
directory = gallery_dir()
|
|
mp4_path = directory / f"{video_id}.mp4"
|
|
mp4_tmp = directory / f".{video_id}.mp4.tmp"
|
|
sidecar = directory / f"{video_id}.json"
|
|
sidecar_tmp = directory / f".{video_id}.json.tmp"
|
|
# Stage both files, rename the MP4 in, then the sidecar (the pair's commit marker: list_videos
|
|
# skips an mp4 without a readable sidecar). On any failure remove every artifact, else a sidecar
|
|
# failure would leave an invisible, undeletable orphan MP4.
|
|
try:
|
|
mp4_tmp.write_bytes(mp4_bytes)
|
|
sidecar_tmp.write_text(json.dumps(meta), encoding = "utf-8")
|
|
os.replace(mp4_tmp, mp4_path)
|
|
os.replace(sidecar_tmp, sidecar)
|
|
except BaseException:
|
|
for path in (mp4_tmp, sidecar_tmp, mp4_path, sidecar):
|
|
try:
|
|
path.unlink(missing_ok = True)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
return _record(video_id, meta)
|
|
|
|
|
|
def _record(video_id: str, meta: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
**meta,
|
|
"id": video_id,
|
|
"url": f"/api/inference/video/gallery/{video_id}/file",
|
|
}
|
|
|
|
|
|
def video_path(video_id: str) -> Optional[Path]:
|
|
"""Resolve an id to its on-disk MP4, or None if missing / unsafe."""
|
|
if not _ID_RE.match(video_id):
|
|
return None
|
|
path = gallery_dir() / f"{video_id}.mp4"
|
|
# Defence in depth: confirm the resolved path is still inside the gallery.
|
|
try:
|
|
path.resolve().relative_to(gallery_dir().resolve())
|
|
except ValueError:
|
|
return None
|
|
return path if path.is_file() else None
|
|
|
|
|
|
def transcode(video_id: str, fmt: str) -> Optional[bytes]:
|
|
"""Re-encode a stored MP4 for the Download menu: "webm" (VP9) or "gif". Returns the bytes, or
|
|
None when the id doesn't resolve. Raises RuntimeError on missing codec/deps (route 501s). MP4
|
|
downloads stream the original via /file, not here."""
|
|
# Ownership-gate like /file: only transcode a Studio-owned clip (readable sidecar), so a guessed
|
|
# stem for a foreign/orphan MP4 the gallery hides can't be re-encoded out either.
|
|
path = owned_video_path(video_id)
|
|
if path is None:
|
|
return None
|
|
normalized = fmt.strip().lower()
|
|
if normalized == "webm":
|
|
return _transcode_webm(path)
|
|
if normalized == "gif":
|
|
return _transcode_gif(path)
|
|
raise ValueError(f"Unsupported export format '{fmt}'. Use webm or gif.")
|
|
|
|
|
|
def _transcode_webm(path: Path) -> bytes:
|
|
import io
|
|
|
|
try:
|
|
import av
|
|
except Exception as exc: # noqa: BLE001 -- no PyAV -> no transcode
|
|
raise RuntimeError("WebM export needs the 'av' package (PyAV).") from exc
|
|
buf = io.BytesIO()
|
|
try:
|
|
with av.open(str(path)) as src, av.open(buf, "w", format = "webm") as dst:
|
|
if not src.streams.video:
|
|
raise RuntimeError("WebM export failed: the clip has no video stream.")
|
|
in_v = src.streams.video[0]
|
|
rate = in_v.average_rate or 24
|
|
out_v = dst.add_stream("libvpx-vp9", rate = rate)
|
|
out_v.width = in_v.codec_context.width
|
|
out_v.height = in_v.codec_context.height
|
|
out_v.pix_fmt = "yuv420p"
|
|
# Realtime settings: VP9's default "good" profile is slow; cpu-used 8 + row-mt is much faster at
|
|
# a small quality cost, right for a download button.
|
|
out_v.options = {"deadline": "realtime", "cpu-used": "8", "row-mt": "1"}
|
|
for frame in src.decode(in_v):
|
|
for packet in out_v.encode(frame.reformat(format = "yuv420p")):
|
|
dst.mux(packet)
|
|
for packet in out_v.encode():
|
|
dst.mux(packet)
|
|
except RuntimeError:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 -- surface as "encoder unavailable"
|
|
raise RuntimeError(f"WebM export failed (libvpx-vp9 unavailable?): {exc}") from exc
|
|
# Audio dropped: Opus muxing needs a 48 kHz resample chain and most clips are silent (the
|
|
# original MP4 keeps the audio).
|
|
return buf.getvalue()
|
|
|
|
|
|
# Ceilings for a GIF export, which must hold every kept frame in memory before encoding. 720 px
|
|
# and 300 frames (25s at the 12 fps target) bound that at roughly 150 MB for the widest clip the
|
|
# generate request allows, and cover the share/preview case a GIF is for.
|
|
_GIF_MAX_EDGE = 720
|
|
_GIF_MAX_FRAMES = 300
|
|
|
|
|
|
def _transcode_gif(path: Path) -> bytes:
|
|
import io
|
|
|
|
try:
|
|
import av
|
|
from PIL import Image
|
|
except Exception as exc: # noqa: BLE001 -- missing deps -> no transcode
|
|
raise RuntimeError("GIF export needs the 'av' and 'Pillow' packages.") from exc
|
|
frames: list[Any] = []
|
|
try:
|
|
with av.open(str(path)) as src:
|
|
if not src.streams.video:
|
|
raise RuntimeError("GIF export failed: the clip has no video stream.")
|
|
in_v = src.streams.video[0]
|
|
rate = float(in_v.average_rate or 24)
|
|
# Full-rate GIFs are huge and stutter; ~12 fps (skipping source frames) is the sweet spot.
|
|
step = max(1, round(rate / 12))
|
|
# Every kept frame is held as a paletted image until the encoder runs, so an unbounded
|
|
# walk is a memory bomb: a clip may be 2048x2048 for 1024 frames, and at 12 fps or
|
|
# below step is 1, which is >4 GB of frames plus the GIF buffer -- enough to take the
|
|
# whole backend down from one export click. Bound both axes instead: downscale past
|
|
# _GIF_MAX_EDGE and widen the step so at most _GIF_MAX_FRAMES are retained (GIF is the
|
|
# share/preview format; MP4 keeps the full clip).
|
|
total = in_v.frames or 0
|
|
kept = (total + step - 1) // step if total else 0
|
|
if kept > _GIF_MAX_FRAMES:
|
|
step = -(-total // _GIF_MAX_FRAMES)
|
|
for i, frame in enumerate(src.decode(in_v)):
|
|
if i % step:
|
|
continue
|
|
if len(frames) >= _GIF_MAX_FRAMES:
|
|
# Frame count unknown up front (no stream metadata): stop at the cap.
|
|
break
|
|
image = frame.to_image()
|
|
if max(image.size) > _GIF_MAX_EDGE:
|
|
scale = _GIF_MAX_EDGE / max(image.size)
|
|
image = image.resize(
|
|
(max(1, round(image.width * scale)), max(1, round(image.height * scale))),
|
|
Image.Resampling.LANCZOS,
|
|
)
|
|
frames.append(image.convert("P", palette = Image.Palette.ADAPTIVE))
|
|
except RuntimeError:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 -- surface as "decoder unavailable"
|
|
raise RuntimeError(f"GIF export failed to decode the clip: {exc}") from exc
|
|
if not frames:
|
|
raise RuntimeError("GIF export decoded no frames.")
|
|
duration_ms = max(20, int(1000 * step / rate))
|
|
buf = io.BytesIO()
|
|
frames[0].save(
|
|
buf,
|
|
format = "GIF",
|
|
save_all = True,
|
|
append_images = frames[1:],
|
|
duration = duration_ms,
|
|
loop = 0,
|
|
)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _sidecar_path(video_id: str) -> Path:
|
|
return gallery_dir() / f"{video_id}.json"
|
|
|
|
|
|
# Sidecar keys every genuine Studio record carries. delete()/clear() own a pair only when its
|
|
# sidecar has all of these, so a hand-dropped MP4 with an empty or partial sidecar is neither
|
|
# counted as ours nor destroyed. Key-presence only (the route owns schema validation).
|
|
_REQUIRED_META = (
|
|
"prompt",
|
|
"width",
|
|
"height",
|
|
"num_frames",
|
|
"fps",
|
|
"duration_s",
|
|
"steps",
|
|
"guidance",
|
|
"seed",
|
|
"created_at",
|
|
)
|
|
|
|
|
|
def _read_meta(sidecar: Path) -> Optional[dict[str, Any]]:
|
|
try:
|
|
raw = sidecar.read_text(encoding = "utf-8")
|
|
except (OSError, UnicodeError):
|
|
# Invalid UTF-8 is a corrupt sidecar, not a listing failure.
|
|
return None
|
|
try:
|
|
meta = json.loads(raw)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
# A parseable dict is not enough: a foreign ("{}") or different-schema sidecar lacks these keys.
|
|
# Require them so delete()/clear() never destroy a clip the gallery never surfaced.
|
|
if not isinstance(meta, dict) or any(k not in meta for k in _REQUIRED_META):
|
|
return None
|
|
return meta
|
|
|
|
|
|
def owned_video_path(video_id: str) -> Optional[Path]:
|
|
"""Resolve an id to its MP4 only when it is a Studio-owned clip (a readable sidecar), else
|
|
None. The serve and export routes use this instead of video_path() so a guessed stem for a
|
|
hand-dropped/orphan MP4 -- which list_videos/delete/clear already treat as not ours -- can't
|
|
be streamed or transcoded out. Mirrors the delete/clear ownership guard."""
|
|
path = video_path(video_id)
|
|
if path is None or _read_meta(_sidecar_path(video_id)) is None:
|
|
return None
|
|
return path
|
|
|
|
|
|
def _mtime(path: Path) -> float:
|
|
try:
|
|
return path.stat().st_mtime
|
|
except OSError:
|
|
return 0.0
|
|
|
|
|
|
def list_videos(
|
|
limit: Optional[int] = None,
|
|
offset: int = 0,
|
|
*,
|
|
valid: Optional[Callable[[dict[str, Any]], bool]] = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""A newest-first window of videos for infinite scroll.
|
|
|
|
Ordered by MP4 mtime (a cheap stat ~= generation order); only the window's sidecars are read.
|
|
limit=None returns everything from ``offset`` on. A file without its pair is skipped.
|
|
|
|
``valid`` (optional) filters records BEFORE pagination, so ``offset`` / ``limit`` and has_more
|
|
all count over the accepted-record domain. Pass the route's schema validator: a sidecar that
|
|
parses as JSON but fails the response schema would otherwise be counted here yet dropped after
|
|
slicing, stalling infinite scroll."""
|
|
try:
|
|
paths = list(gallery_dir().glob("*.mp4"))
|
|
except OSError:
|
|
return []
|
|
paths.sort(key = _mtime, reverse = True)
|
|
# Page over READABLE records, not raw files: filtering an orphan MP4 out of an already-sliced
|
|
# window would drop valid videos and make has_more wrong. Read only as far as needed.
|
|
want = None if limit is None else offset + limit
|
|
records = []
|
|
for path in paths:
|
|
meta = _read_meta(_sidecar_path(path.stem))
|
|
if meta is None: # orphan mp4 (no readable sidecar)
|
|
continue
|
|
record = _record(path.stem, meta)
|
|
if valid is not None and not valid(record): # parses but schema-invalid
|
|
continue
|
|
records.append(record)
|
|
if want is not None and len(records) >= want:
|
|
break
|
|
return records[offset:] if limit is None else records[offset : offset + limit]
|
|
|
|
|
|
def delete(video_id: str) -> bool:
|
|
"""Remove both files of an owned pair; True if the MP4 existed and was ours."""
|
|
path = video_path(video_id)
|
|
if path is None:
|
|
return False
|
|
# Only delete a pair we own (a readable sidecar); a foreign/orphan MP4 is invisible to
|
|
# list_videos, so a guessed id must not destroy it.
|
|
if _read_meta(_sidecar_path(video_id)) is None:
|
|
return False
|
|
# Delete the MP4 FIRST: dropping the sidecar first and failing to unlink the mp4 would leave a
|
|
# clip that vanished from the gallery with no retry. mp4-first leaves at worst an orphan sidecar,
|
|
# which list_videos ignores.
|
|
try:
|
|
path.unlink()
|
|
except OSError as exc:
|
|
logger.warning("video_gallery.delete_failed: %s", exc)
|
|
return False
|
|
# Best-effort sidecar unlink: a leftover json is skipped by list_videos anyway.
|
|
try:
|
|
_sidecar_path(video_id).unlink()
|
|
except OSError:
|
|
pass
|
|
return True
|
|
|
|
|
|
def clear() -> int:
|
|
"""Delete every Studio-owned gallery pair (readable sidecar); return how many were removed.
|
|
|
|
Foreign/orphan MP4s are preserved: list_videos already hides them, so clear must not destroy them."""
|
|
removed = 0
|
|
try:
|
|
paths = list(gallery_dir().glob("*.mp4"))
|
|
except OSError:
|
|
return 0
|
|
for path in paths:
|
|
if _read_meta(_sidecar_path(path.stem)) is None: # orphan / not ours
|
|
continue
|
|
# mp4 first; if it can't be unlinked, leave the sidecar so the video stays listable.
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
continue
|
|
removed += 1
|
|
try:
|
|
_sidecar_path(path.stem).unlink()
|
|
except OSError:
|
|
pass
|
|
return removed
|