feat: initial video wallpaper implementation

This commit is contained in:
2026-10-25 02:10:50 +02:00
parent 200a6a5d54
commit 0d0faecb1c
14 changed files with 1280 additions and 88 deletions
+125
View File
@@ -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")