feat: ffmpeg frame extraction + optional dominant_color
Per sub-project-2 plan Task 5 / spec §5.2. ffmpeg-only dominant color (no image library), opt-in by design; extract_frame for review previews. Runners injectable so the unit suite never shells out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
from tools.mediatools import (
|
||||
compute_dominant_color,
|
||||
dominant_color_from_rgb,
|
||||
extract_frame,
|
||||
)
|
||||
|
||||
|
||||
def test_dominant_color_from_rgb_pure_red():
|
||||
assert dominant_color_from_rgb(b"\xff\x00\x00") == "#ff0000"
|
||||
|
||||
|
||||
def test_dominant_color_from_rgb_arbitrary():
|
||||
assert dominant_color_from_rgb(b"\x12\xab\x0f") == "#12ab0f"
|
||||
|
||||
|
||||
def test_compute_dominant_color_uses_runner():
|
||||
captured = {}
|
||||
|
||||
def fake_runner(args):
|
||||
captured["args"] = args
|
||||
return b"\x00\x80\xff"
|
||||
|
||||
color = compute_dominant_color("clip.mp4", midpoint_s=5.0, runner=fake_runner)
|
||||
assert color == "#0080ff"
|
||||
assert "ffmpeg" in captured["args"]
|
||||
assert "5.0" in captured["args"]
|
||||
|
||||
|
||||
def test_extract_frame_invokes_runner_and_returns_dest(tmp_path):
|
||||
dest = tmp_path / "frame.png"
|
||||
calls = []
|
||||
|
||||
def fake_runner(args):
|
||||
calls.append(args)
|
||||
|
||||
out = extract_frame("clip.mp4", dest, midpoint_s=2.0, runner=fake_runner)
|
||||
assert out == dest
|
||||
assert calls and "ffmpeg" in calls[0] and str(dest) in calls[0]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""ffmpeg helpers: representative frame; OPTIONAL dominant color. Runner injectable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def _run_bytes(args) -> bytes:
|
||||
return subprocess.run(args, capture_output=True, check=True).stdout
|
||||
|
||||
|
||||
def dominant_color_from_rgb(rgb: bytes) -> str:
|
||||
r, g, b = rgb[0], rgb[1], rgb[2]
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def compute_dominant_color(path, *, midpoint_s=0.0, runner=_run_bytes) -> str:
|
||||
"""ffmpeg-only single-color palette of a mid-segment frame -> #rrggbb."""
|
||||
args = [
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-ss",
|
||||
str(midpoint_s),
|
||||
"-i",
|
||||
str(path),
|
||||
"-vf",
|
||||
"thumbnail,palettegen=max_colors=1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgb24",
|
||||
"-",
|
||||
]
|
||||
return dominant_color_from_rgb(runner(args))
|
||||
|
||||
|
||||
def extract_frame(path, dest, *, midpoint_s=0.0, runner=None):
|
||||
"""Write one representative frame to dest (PNG) for the review preview."""
|
||||
runner = runner or (lambda a: subprocess.run(a, check=True))
|
||||
runner(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-y",
|
||||
"-ss",
|
||||
str(midpoint_s),
|
||||
"-i",
|
||||
str(path),
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(dest),
|
||||
]
|
||||
)
|
||||
return dest
|
||||
Reference in New Issue
Block a user