feat: initial video wallpaper implementation
This commit is contained in:
@@ -5,6 +5,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -31,6 +32,23 @@ from zshell.utils.schemepalettes import (
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
VIDEO_EXTENSIONS = {
|
||||
".mp4",
|
||||
".mkv",
|
||||
".webm",
|
||||
".avi",
|
||||
".mov",
|
||||
".wmv",
|
||||
".flv",
|
||||
".m4v",
|
||||
".mpg",
|
||||
".mpeg",
|
||||
".3gp",
|
||||
".ogv",
|
||||
".ts",
|
||||
".vob",
|
||||
}
|
||||
|
||||
|
||||
def _complete_scheme_name(incomplete):
|
||||
schemes = [
|
||||
@@ -351,6 +369,109 @@ def generate(
|
||||
safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key)
|
||||
return thumb_dir / f"{safe_key}_thumbnail.jpg"
|
||||
|
||||
def is_video(path: Path) -> bool:
|
||||
return path.suffix.lower() in VIDEO_EXTENSIONS
|
||||
|
||||
def video_duration(path: Path) -> float:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=nw=1:nk=1",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return float(out.stdout.strip())
|
||||
except (ValueError, OSError):
|
||||
return 0.0
|
||||
|
||||
def video_frame_chroma(path: Path) -> tuple[float, float]:
|
||||
"""Return (mean chroma, mean luminance) of a frame.
|
||||
|
||||
Mean chroma (mean of max-min across RGB channels) is ~0 for black,
|
||||
white and grey frames, and rises with saturation - so it identifies a
|
||||
"good", colorful frame and rejects black or fade frames.
|
||||
"""
|
||||
with Image.open(path) as im:
|
||||
im = im.convert("RGB")
|
||||
pixels = list(im.getdata())
|
||||
|
||||
chroma = 0
|
||||
luma = 0
|
||||
for r, g, b in pixels:
|
||||
chroma += max(r, g, b) - min(r, g, b)
|
||||
luma += r + g + b
|
||||
|
||||
n = max(1, len(pixels))
|
||||
return chroma / n, luma / (3 * n)
|
||||
|
||||
def generate_video_thumbnail(
|
||||
video_path: Path, cache_path: Path, size=(128, 128)
|
||||
) -> None:
|
||||
w, h = size
|
||||
duration = video_duration(video_path)
|
||||
|
||||
samples = 8 if duration <= 0 else min(10, max(4, int(duration)))
|
||||
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="zshell-vthumb-"))
|
||||
try:
|
||||
best: Path | None = None
|
||||
best_key: tuple[float, float] = (-1.0, -1.0)
|
||||
|
||||
for i in range(samples):
|
||||
frac = (i + 0.5) / samples if duration > 0 else 0.0
|
||||
seek = f"{frac * duration:.3f}" if duration > 0 else "0"
|
||||
frame = tmp_dir / f"frame_{i}.jpg"
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-ss",
|
||||
seek,
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
f"scale={w}:{h}",
|
||||
"-pix_fmt",
|
||||
"yuvj420p",
|
||||
"-q:v",
|
||||
"2",
|
||||
"-y",
|
||||
str(frame),
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
if not frame.exists():
|
||||
continue
|
||||
|
||||
key = video_frame_chroma(frame)
|
||||
if key > best_key:
|
||||
best_key = key
|
||||
best = frame
|
||||
|
||||
if best is None:
|
||||
raise RuntimeError(
|
||||
f"Could not extract a frame from {video_path}"
|
||||
)
|
||||
|
||||
shutil.copy(best, cache_path)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
def generate_thumbnail(
|
||||
image_path: Path, thumb_dir: Path, size=(128, 128)
|
||||
) -> Path:
|
||||
@@ -360,6 +481,10 @@ def generate(
|
||||
if cache_path.exists():
|
||||
return cache_path
|
||||
|
||||
if is_video(image_path):
|
||||
generate_video_thumbnail(image_path, cache_path, size)
|
||||
return cache_path
|
||||
|
||||
image = Image.open(image_path)
|
||||
image.draft("RGB", size)
|
||||
image = image.convert("RGB")
|
||||
|
||||
Reference in New Issue
Block a user