初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,5 @@
"""Persistent media analysis and conversion jobs."""
from .manager import MediaImportError, MediaImportManager
__all__ = ["MediaImportError", "MediaImportManager"]
@@ -0,0 +1,402 @@
from __future__ import annotations
import base64
import json
import math
import shutil
import subprocess
from bisect import bisect_right
from pathlib import Path
from typing import Any, Iterable, Iterator
from PIL import Image, ImageCms, ImageOps, UnidentifiedImageError
MAX_DURATION_SECONDS = 2 * 60 * 60
MAX_DIMENSION = 32768
MAX_PIXELS = 160_000_000
MAX_MERGED_FRAMES = 30_000
SAMPLE_MS = 50
OUTPUT_SIZE = 64
MIN_CENTER = -31.0
MAX_CENTER = 32.0
class MediaConversionError(ValueError):
pass
def rgb_scene(rgb: bytes) -> dict[str, Any]:
if len(rgb) != 64 * 64 * 3:
raise MediaConversionError("converted frame is not 64x64 RGB888")
return {
"version": 1, "width": 64, "height": 64,
"pixelRgb": base64.b64encode(rgb).decode("ascii"), "elements": [],
}
def _run(args: list[str], *, timeout: int = 120, stdout: Any = subprocess.PIPE) -> subprocess.CompletedProcess:
try:
result = subprocess.run(
args, stdin=subprocess.DEVNULL, stdout=stdout, stderr=subprocess.PIPE,
timeout=timeout, check=False,
)
except FileNotFoundError as exc:
raise MediaConversionError(f"required decoder is missing: {args[0]}") from exc
except subprocess.TimeoutExpired as exc:
raise MediaConversionError("media decoder made no progress before timeout") from exc
if result.returncode:
detail = result.stderr.decode("utf-8", "replace")[-1200:].strip()
raise MediaConversionError(detail or "media decoder failed")
return result
def _hex_color(value: Any, field: str) -> tuple[int, int, int]:
if not isinstance(value, str) or len(value) != 7 or value[0] != "#":
raise MediaConversionError(f"{field} must be #RRGGBB")
try:
return tuple(bytes.fromhex(value[1:])) # type: ignore[return-value]
except ValueError as exc:
raise MediaConversionError(f"{field} must be #RRGGBB") from exc
def validate_settings(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
raise MediaConversionError("settings must be an object")
fit_mode = value.get("fit_mode")
if fit_mode not in {"crop", "contain", "stretch"}:
raise MediaConversionError("fit_mode must be crop, contain, or stretch")
center_x, center_y, zoom = value.get("center_x"), value.get("center_y"), value.get("zoom")
for field, number in (("center_x", center_x), ("center_y", center_y)):
if (
isinstance(number, bool) or not isinstance(number, (int, float))
or not math.isfinite(number) or not MIN_CENTER <= number <= MAX_CENTER
):
raise MediaConversionError(f"{field} must be a finite free-framing coordinate")
if isinstance(zoom, bool) or not isinstance(zoom, (int, float)) or not 1 <= zoom <= 16:
raise MediaConversionError("zoom must be in 1..16")
_hex_color(value.get("transparency_color"), "transparency_color")
_hex_color(value.get("padding_color"), "padding_color")
return {
"fit_mode": fit_mode, "center_x": float(center_x), "center_y": float(center_y),
"zoom": float(zoom), "transparency_color": value["transparency_color"].upper(),
"padding_color": value["padding_color"].upper(),
}
def _orient_and_color(image: Image.Image) -> Image.Image:
image = ImageOps.exif_transpose(image)
rgba = image.convert("RGBA")
profile = image.info.get("icc_profile")
if profile:
alpha = rgba.getchannel("A")
try:
source = ImageCms.ImageCmsProfile(bytes(profile))
target = ImageCms.createProfile("sRGB")
rgb = ImageCms.profileToProfile(rgba.convert("RGB"), source, target, outputMode="RGB")
rgba = rgb.convert("RGBA")
rgba.putalpha(alpha)
except (ImageCms.PyCMSError, OSError, TypeError, ValueError) as exc:
raise MediaConversionError("image ICC profile cannot be converted to sRGB") from exc
return rgba
def _sample_aspect_ratio(value: Any) -> float:
if not isinstance(value, str) or ":" not in value:
return 1.0
numerator, denominator = value.split(":", 1)
try:
ratio = float(numerator) / float(denominator)
except (ValueError, ZeroDivisionError):
return 1.0
return ratio if math.isfinite(ratio) and ratio > 0 else 1.0
def _metadata_display_dimensions(metadata: dict[str, Any]) -> tuple[float, float]:
width, height = float(metadata["width"]), float(metadata["height"])
if metadata.get("decoder") == "ffmpeg":
width *= _sample_aspect_ratio(metadata.get("sample_aspect_ratio"))
return width, height
def _free_crop_geometry(
width: float, height: float, settings: dict[str, Any],
) -> tuple[int, int, int, int]:
scale = OUTPUT_SIZE * settings["zoom"] / max(width, height)
out_w = max(1, round(width * scale))
out_h = max(1, round(height * scale))
left = round(OUTPUT_SIZE / 2 - settings["center_x"] * out_w)
top = round(OUTPUT_SIZE / 2 - settings["center_y"] * out_h)
if left >= OUTPUT_SIZE or left + out_w <= 0 or top >= OUTPUT_SIZE or top + out_h <= 0:
raise MediaConversionError("free-framing position must leave at least one output pixel visible")
return out_w, out_h, left, top
def validate_settings_for_metadata(value: Any, metadata: dict[str, Any] | None) -> dict[str, Any]:
settings = validate_settings(value)
if settings["fit_mode"] == "crop" and metadata is not None:
_free_crop_geometry(*_metadata_display_dimensions(metadata), settings)
return settings
def _composite_free_crop(
image: Image.Image, settings: dict[str, Any], geometry: tuple[int, int, int, int],
) -> bytes:
out_w, out_h, left, top = geometry
transparency = _hex_color(settings["transparency_color"], "transparency_color")
padding = _hex_color(settings["padding_color"], "padding_color")
resized = image if image.size == (out_w, out_h) else image.resize((out_w, out_h), Image.Resampling.LANCZOS)
content = Image.alpha_composite(
Image.new("RGBA", resized.size, (*transparency, 255)), resized,
).convert("RGB")
output = Image.new("RGB", (OUTPUT_SIZE, OUTPUT_SIZE), padding)
output.paste(content, (left, top))
return output.tobytes()
def transform_frame(image: Image.Image, settings: dict[str, Any]) -> bytes:
settings = validate_settings(settings)
image = _orient_and_color(image)
width, height = image.size
if width < 1 or height < 1 or width > MAX_DIMENSION or height > MAX_DIMENSION or width * height > MAX_PIXELS:
raise MediaConversionError("media pixel dimensions exceed the safety limit")
transparency = _hex_color(settings["transparency_color"], "transparency_color")
padding = _hex_color(settings["padding_color"], "padding_color")
mode = settings["fit_mode"]
if mode == "crop":
return _composite_free_crop(image, settings, _free_crop_geometry(width, height, settings))
if mode == "stretch":
image = image.resize((64, 64), Image.Resampling.LANCZOS)
background = Image.new("RGBA", (64, 64), (*transparency, 255))
return Image.alpha_composite(background, image).convert("RGB").tobytes()
scale = min(64 / width, 64 / height)
size = (max(1, round(width * scale)), max(1, round(height * scale)))
image = image.resize(size, Image.Resampling.LANCZOS)
content = Image.alpha_composite(Image.new("RGBA", size, (*transparency, 255)), image).convert("RGB")
output = Image.new("RGB", (64, 64), padding)
output.paste(content, ((64 - size[0]) // 2, (64 - size[1]) // 2))
return output.tobytes()
def _check_dimensions(width: int, height: int) -> None:
if width < 1 or height < 1 or width > MAX_DIMENSION or height > MAX_DIMENSION or width * height > MAX_PIXELS:
raise MediaConversionError("media pixel dimensions exceed the safety limit")
def _pillow_probe(source: Path) -> tuple[dict[str, Any], list[Image.Image]]:
with Image.open(source) as image:
width, height = image.size
_check_dimensions(width, height)
count = int(getattr(image, "n_frames", 1))
durations, previews = [], []
preview_indexes = {
min(count - 1, round(i * (count - 1) / min(4, count - 1)))
for i in range(min(5, count))
} if count > 1 else {0}
for index in range(count):
image.seek(index)
duration = image.info.get("duration", 100 if count > 1 else 0)
if isinstance(duration, bool) or not isinstance(duration, (int, float)) or duration <= 0:
duration = 100
durations.append(int(round(duration)))
if index in preview_indexes:
previews.append(_orient_and_color(image.copy()))
total_ms = sum(durations) if count > 1 else 0
if total_ms > MAX_DURATION_SECONDS * 1000:
raise MediaConversionError("media duration exceeds 2 hours")
return {
"decoder": "pillow", "format": str(image.format or "image").lower(),
"width": previews[0].width, "height": previews[0].height, "duration_ms": total_ms,
"source_frame_count": count, "dynamic": count > 1,
"has_alpha": "A" in image.getbands() or "transparency" in image.info,
}, previews
def _ffprobe(source: Path) -> dict[str, Any]:
result = _run([
"ffprobe", "-v", "error", "-protocol_whitelist", "file,pipe",
"-show_streams", "-show_format", "-of", "json", str(source),
])
try:
value = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise MediaConversionError("ffprobe returned invalid metadata") from exc
streams = [
stream for stream in value.get("streams", [])
if stream.get("codec_type") == "video"
and not int((stream.get("disposition") or {}).get("attached_pic", 0))
]
if not streams:
raise MediaConversionError("input does not contain a supported video stream")
stream = streams[0]
width, height = int(stream.get("width") or 0), int(stream.get("height") or 0)
_check_dimensions(width, height)
duration = stream.get("duration") or (value.get("format") or {}).get("duration")
try:
duration_ms = round(float(duration) * 1000)
except (TypeError, ValueError):
raise MediaConversionError("video duration is unavailable")
if duration_ms <= 0 or duration_ms > MAX_DURATION_SECONDS * 1000:
raise MediaConversionError("media duration must be in 0..2 hours")
transfer = str(stream.get("color_transfer") or "").lower()
hdr = transfer in {"smpte2084", "arib-std-b67"}
if hdr:
filters = _run(["ffmpeg", "-hide_banner", "-filters"]).stdout.decode("utf-8", "replace")
if " zscale " not in filters or " tonemap " not in filters:
raise MediaConversionError("HDR input requires FFmpeg zscale and tonemap filters")
return {
"decoder": "ffmpeg", "format": str((value.get("format") or {}).get("format_name") or "video"),
"width": width, "height": height, "duration_ms": duration_ms,
"source_frame_count": int(stream.get("nb_frames") or 0), "dynamic": True,
"has_alpha": "a" in str(stream.get("pix_fmt") or ""),
"stream_index": int(stream.get("index") or 0), "hdr": hdr,
"sample_aspect_ratio": str(stream.get("sample_aspect_ratio") or "1:1"),
}
def _heif_probe(source: Path) -> tuple[dict[str, Any], list[Image.Image]]:
decoded = source.parent / "decoded-heif.png"
_run(["heif-convert", str(source), str(decoded)], timeout=120)
metadata, images = _pillow_probe(decoded)
metadata.update({"decoder": "heif", "format": "heif", "dynamic": False})
return metadata, images
def analyze(source: Path, preview_dir: Path) -> dict[str, Any]:
preview_dir.mkdir(parents=True, exist_ok=True)
try:
metadata, images = _pillow_probe(source)
for index, image in enumerate(images):
image.thumbnail((512, 512), Image.Resampling.LANCZOS)
image.save(preview_dir / f"{index}.png", "PNG")
except (UnidentifiedImageError, OSError):
try:
metadata, images = _heif_probe(source)
for index, image in enumerate(images):
image.thumbnail((512, 512), Image.Resampling.LANCZOS)
image.save(preview_dir / f"{index}.png", "PNG")
except MediaConversionError:
metadata = _ffprobe(source)
count = min(5, max(1, math.ceil(metadata["duration_ms"] / 1000)))
times = [metadata["duration_ms"] * i / max(1, count - 1) / 1000 for i in range(count)]
for index, when in enumerate(times):
_run([
"ffmpeg", "-nostdin", "-v", "error", "-protocol_whitelist", "file,pipe",
"-ss", f"{when:.3f}", "-i", str(source), "-map", f"0:{metadata['stream_index']}",
"-frames:v", "1", "-vf",
"scale=trunc(iw*sar+0.5):ih:flags=lanczos,setsar=1,"
"scale=512:512:force_original_aspect_ratio=decrease:flags=lanczos",
"-y", str(preview_dir / f"{index}.png"),
], timeout=120)
metadata["preview_count"] = len(list(preview_dir.glob("*.png")))
return metadata
def _pillow_frames(source: Path, settings: dict[str, Any]) -> Iterator[tuple[bytes, int]]:
with Image.open(source) as image:
count = int(getattr(image, "n_frames", 1))
if count == 1:
yield transform_frame(image.copy(), settings), 0
return
frames, ends, elapsed = [], [], 0
for index in range(count):
image.seek(index)
duration = image.info.get("duration", 100)
if not isinstance(duration, (int, float)) or isinstance(duration, bool) or duration <= 0:
duration = 100
elapsed += int(round(duration))
ends.append(elapsed)
frames.append(image.copy())
if elapsed > MAX_DURATION_SECONDS * 1000:
raise MediaConversionError("media duration exceeds 2 hours")
sample_times = range(0, elapsed, SAMPLE_MS)
for time_ms in sample_times:
index = min(len(frames) - 1, bisect_right(ends, time_ms))
duration = min(SAMPLE_MS, elapsed - time_ms)
yield transform_frame(frames[index], settings), duration
def _ffmpeg_frames(source: Path, metadata: dict[str, Any], settings: dict[str, Any]) -> Iterator[tuple[bytes, int]]:
width, height = _metadata_display_dimensions(metadata)
mode = settings["fit_mode"]
if mode == "contain":
scale = min(64 / width, 64 / height)
out_w, out_h = max(1, round(width * scale)), max(1, round(height * scale))
geometry = f"scale={out_w}:{out_h}:flags=lanczos"
elif mode == "stretch":
out_w = out_h = 64
geometry = "scale=64:64:flags=lanczos"
else:
out_w, out_h, left, top = _free_crop_geometry(width, height, settings)
geometry = f"scale={out_w}:{out_h}:flags=lanczos"
color_filter = ""
if metadata.get("hdr"):
color_filter = "zscale=t=linear:npl=100,format=gbrpf32le,tonemap=hable,zscale=p=bt709:t=bt709:m=bt709,"
filters = f"fps=20,{color_filter}{geometry},setsar=1,format=rgba"
try:
process = subprocess.Popen([
"ffmpeg", "-nostdin", "-v", "error", "-protocol_whitelist", "file,pipe",
"-i", str(source), "-map", f"0:{metadata['stream_index']}", "-an", "-sn", "-dn",
"-vf", filters, "-f", "rawvideo", "-pix_fmt", "rgba", "pipe:1",
], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except FileNotFoundError as exc:
raise MediaConversionError("required decoder is missing: ffmpeg") from exc
assert process.stdout is not None
frame_bytes = out_w * out_h * 4
count = 0
while True:
raw = process.stdout.read(frame_bytes)
if not raw:
break
if len(raw) != frame_bytes:
process.kill()
raise MediaConversionError("FFmpeg returned a truncated video frame")
rgba = Image.frombytes("RGBA", (out_w, out_h), raw)
if mode == "contain":
transparency = _hex_color(settings["transparency_color"], "transparency_color")
padding = _hex_color(settings["padding_color"], "padding_color")
content = Image.alpha_composite(Image.new("RGBA", rgba.size, (*transparency, 255)), rgba).convert("RGB")
output = Image.new("RGB", (64, 64), padding)
output.paste(content, ((64 - out_w) // 2, (64 - out_h) // 2))
rgb = output.tobytes()
elif mode == "crop":
rgb = _composite_free_crop(rgba, settings, (out_w, out_h, left, top))
else:
background = Image.new("RGBA", (64, 64), (*_hex_color(settings["transparency_color"], "transparency_color"), 255))
rgb = Image.alpha_composite(background, rgba).convert("RGB").tobytes()
count += 1
yield rgb, SAMPLE_MS
stderr = process.stderr.read().decode("utf-8", "replace") if process.stderr else ""
if process.wait(timeout=30):
raise MediaConversionError(stderr[-1200:].strip() or "FFmpeg conversion failed")
if count == 0:
raise MediaConversionError("video decoder produced no frames")
def converted_frames(source: Path, metadata: dict[str, Any], settings: dict[str, Any]) -> Iterator[tuple[bytes, int]]:
settings = validate_settings_for_metadata(settings, metadata)
raw_frames: Iterable[tuple[bytes, int]]
if metadata["decoder"] in {"pillow", "heif"}:
actual_source = source if metadata["decoder"] == "pillow" else source.parent / "decoded-heif.png"
raw_frames = _pillow_frames(actual_source, settings)
else:
raw_frames = _ffmpeg_frames(source, metadata, settings)
previous: bytes | None = None
duration = 0
merged = 0
for rgb, frame_duration in raw_frames:
if previous is None:
previous, duration = rgb, frame_duration
elif rgb == previous:
duration += frame_duration
else:
merged += 1
if merged > MAX_MERGED_FRAMES:
raise MediaConversionError("converted animation exceeds 30,000 merged frames")
yield previous, max(SAMPLE_MS, duration)
previous, duration = rgb, frame_duration
if previous is None:
raise MediaConversionError("decoder produced no frames")
merged += 1
if merged > MAX_MERGED_FRAMES:
raise MediaConversionError("converted animation exceeds 30,000 merged frames")
yield previous, max(SAMPLE_MS, duration) if metadata["dynamic"] else 0
@@ -0,0 +1,524 @@
from __future__ import annotations
import json
import os
import queue
import shutil
import subprocess
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
from app.animations.store import AnimationStore
from app.persistence import atomic_write_bytes
from app.templates.store import TemplateConflictError, TemplateStore
from .converter import (
MAX_MERGED_FRAMES, MediaConversionError, analyze, converted_frames,
rgb_scene, validate_settings_for_metadata,
)
JOB_SCHEMA_VERSION = 1
MAX_SOURCE_BYTES = 4 * 1024 * 1024 * 1024
MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024
RETENTION_HOURS = 24
STATES = {
"uploading", "analyzing", "awaiting_settings", "queued", "converting", "failed",
}
JOB_FIELDS = {
"id", "filename", "name", "source_size", "state", "action", "created_at",
"updated_at", "expires_at", "progress", "metadata", "settings",
"preview_count", "error", "queue_sequence",
}
class MediaImportError(ValueError):
def __init__(self, message: str, *, status_code: int = 400) -> None:
super().__init__(message)
self.status_code = status_code
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat()
def _job_id(value: Any) -> str:
try:
return str(UUID(str(value)))
except (ValueError, TypeError, AttributeError) as exc:
raise MediaImportError("media import job not found", status_code=404) from exc
def _output_name(value: Any) -> str:
if not isinstance(value, str):
raise MediaImportError("output name must be a string")
checked = value.strip()
if not checked or len(checked) > 80:
raise MediaImportError("output name must contain 1..80 characters")
return checked
def _default_output_name(filename: str) -> str:
leaf = filename.replace("\\", "/").rsplit("/", 1)[-1].strip()
stem = leaf.rsplit(".", 1)[0].strip() if "." in leaf else leaf
return (stem[:80].strip() or "未命名媒体")
class MediaImportManager:
"""Persistent single-worker media queue.
The record is authoritative, so analyzing/queued/converting work can be
restored after either service or device restart.
"""
def __init__(self, data_dir: Path, templates: TemplateStore, animations: AnimationStore) -> None:
self.root = Path(data_dir) / "media-import" / "jobs"
self.root.mkdir(parents=True, exist_ok=True)
self.templates = templates
self.animations = animations
self._lock = threading.RLock()
self._queue: queue.PriorityQueue[tuple[int, str, str]] = queue.PriorityQueue()
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self._canceled: set[str] = set()
self._external_worker = os.environ.get("MATRIX_MEDIA_WORKER_MODE") == "systemd"
self._sequence = 0
with self._lock:
for child in self.root.iterdir():
if not child.is_dir():
continue
record = self._read(child.name)
self._sequence = max(self._sequence, record["queue_sequence"])
if record["state"] == "uploading":
record.update({
"state": "failed", "action": "analyze",
"error": "upload was interrupted", "progress": 0,
"updated_at": _iso(_now()),
"expires_at": _iso(_now() + timedelta(hours=RETENTION_HOURS)),
})
self._write(record)
elif record["state"] in {"analyzing", "queued", "converting"}:
action = "analyze" if record["action"] == "analyze" else "convert"
record["state"] = "analyzing" if action == "analyze" else "queued"
record["progress"] = 0
self._write(record)
self._queue.put((record["queue_sequence"], record["id"], action))
def _dir(self, job_id: str) -> Path:
return self.root / job_id
def _path(self, job_id: str) -> Path:
return self._dir(job_id) / "job.json"
def source_path(self, job_id: Any) -> Path:
return self._dir(_job_id(job_id)) / "source.bin"
def preview_path(self, job_id: Any, index: int) -> Path:
normalized = _job_id(job_id)
record = self._read(normalized)
if index < 0 or index >= record["preview_count"]:
raise MediaImportError("media preview not found", status_code=404)
path = self._dir(normalized) / "previews" / f"{index}.png"
if not path.is_file():
raise MediaImportError("media preview not found", status_code=404)
return path
def _write(self, record: dict[str, Any]) -> None:
payload = json.dumps(
{"schema_version": JOB_SCHEMA_VERSION, **record},
ensure_ascii=False, sort_keys=True, indent=2,
).encode("utf-8") + b"\n"
atomic_write_bytes(self._path(record["id"]), payload)
def _validate(self, value: Any, expected_id: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise MediaImportError("media job record is invalid", status_code=500)
document = dict(value)
if document.pop("schema_version", None) != 1 or set(document) != JOB_FIELDS:
raise MediaImportError("media job schema is unsupported", status_code=500)
if document.get("id") != expected_id or document.get("state") not in STATES:
raise MediaImportError("media job record is invalid", status_code=500)
if document.get("action") not in {"analyze", "convert"}:
raise MediaImportError("media job action is invalid", status_code=500)
if not isinstance(document.get("settings"), dict):
raise MediaImportError("media job settings are invalid", status_code=500)
return document
def _read(self, job_id: Any) -> dict[str, Any]:
normalized = _job_id(job_id)
path = self._path(normalized)
if not path.is_file():
raise MediaImportError("media import job not found", status_code=404)
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise MediaImportError("media job record is unreadable", status_code=500) from exc
return self._validate(value, normalized)
def _public(self, record: dict[str, Any]) -> dict[str, Any]:
queued = []
for _, queued_id, action in list(self._queue.queue):
if action == "convert":
queued.append(queued_id)
queue_position = queued.index(record["id"]) + 1 if record["id"] in queued else None
return {
key: record[key] for key in (
"id", "filename", "name", "source_size", "state", "created_at",
"updated_at", "expires_at", "progress", "metadata", "settings",
"error",
)
} | {
"queue_position": queue_position,
"previews": [
f"/api/media-imports/{record['id']}/previews/{index}"
for index in range(record["preview_count"])
],
}
def _reconcile_external(self, record: dict[str, Any]) -> dict[str, Any]:
if not self._external_worker or record["state"] not in {"analyzing", "queued", "converting"}:
return record
result = subprocess.run(
["systemctl", "show", f"matrix-screen-converter@{record['id']}.service",
"-p", "ActiveState", "-p", "Result", "--value"],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
check=False, timeout=5,
)
values = set(result.stdout.decode("utf-8", "replace").split())
if result.returncode == 0 and "failed" in values:
now = _now()
record.update({
"state": "failed", "progress": 0,
"error": "isolated converter process exited unexpectedly",
"updated_at": _iso(now),
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
})
self._write(record)
return record
def _ensure_space(self, incoming: int = 0) -> None:
free = shutil.disk_usage(self.root).free
if free - incoming < MIN_FREE_BYTES:
raise MediaImportError("device must retain at least 2 GiB free space", status_code=507)
def begin_upload(self, filename: Any, name: Any | None, size: int) -> tuple[dict[str, Any], Path]:
if not isinstance(filename, str) or not filename.strip() or len(filename) > 255:
raise MediaImportError("filename must contain 1..255 characters")
checked_name = _default_output_name(filename) if name is None else _output_name(name)
if isinstance(size, bool) or not isinstance(size, int) or size <= 0:
raise MediaImportError("Content-Length must be a positive integer", status_code=411)
if size > MAX_SOURCE_BYTES:
raise MediaImportError("source file exceeds 4 GiB", status_code=413)
with self._lock:
self._ensure_space(size)
job_id, now = str(uuid4()), _now()
self._sequence += 1
directory = self._dir(job_id)
directory.mkdir(parents=True, exist_ok=False)
record = {
"id": job_id, "filename": filename.strip(), "name": checked_name,
"source_size": size, "state": "uploading", "action": "analyze",
"created_at": _iso(now), "updated_at": _iso(now),
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
"progress": 0, "metadata": None,
"settings": {
"fit_mode": "crop", "center_x": 0.5, "center_y": 0.5,
"zoom": 1.0, "transparency_color": "#000000",
"padding_color": "#000000",
},
"preview_count": 0, "error": None, "queue_sequence": self._sequence,
}
self._write(record)
return self._public(record), directory / "source.bin"
def finish_upload(self, job_id: Any, received: int) -> dict[str, Any]:
with self._lock:
record = self._read(job_id)
if record["state"] != "uploading":
raise MediaImportError("media upload is not active", status_code=409)
if received != record["source_size"]:
shutil.rmtree(self._dir(record["id"]), ignore_errors=True)
raise MediaImportError("uploaded byte count does not match Content-Length")
record.update({
"state": "analyzing", "action": "analyze", "progress": 0,
"updated_at": _iso(_now()), "error": None,
})
self._write(record)
self._schedule(record["id"], "analyze", record["queue_sequence"])
return self._public(record)
def fail_upload(self, job_id: Any) -> None:
with self._lock:
shutil.rmtree(self._dir(_job_id(job_id)), ignore_errors=True)
def start(self) -> None:
with self._lock:
if self._external_worker:
pending = []
while not self._queue.empty():
pending.append(self._queue.get_nowait())
for sequence, job_id, action in pending:
self._schedule(job_id, action, sequence)
return
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, name="media-import-queue", daemon=True)
self._thread.start()
def stop(self) -> None:
if self._external_worker:
return
self._stop.set()
self._queue.put((2**63 - 1, "", "stop"))
if self._thread:
self._thread.join(timeout=10)
def _schedule(self, job_id: str, action: str, sequence: int) -> None:
if not self._external_worker:
self._queue.put((sequence, job_id, action))
return
result = subprocess.run(
["systemctl", "start", "--no-block", f"matrix-screen-converter@{job_id}.service"],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
check=False, timeout=15,
)
if result.returncode:
raise MediaImportError(
"cannot start isolated converter: "
+ result.stderr.decode("utf-8", "replace")[-800:].strip(),
status_code=503,
)
def _run(self) -> None:
while not self._stop.is_set():
_, job_id, action = self._queue.get()
if not job_id or self._stop.is_set():
return
with self._lock:
if job_id in self._canceled or not self._path(job_id).is_file():
continue
try:
if action == "analyze":
self._analyze(job_id)
else:
self._convert(job_id)
except Exception as exc:
with self._lock:
if job_id in self._canceled or not self._path(job_id).is_file():
continue
record = self._read(job_id)
now = _now()
record.update({
"state": "failed", "progress": 0,
"error": str(exc)[:2000], "updated_at": _iso(now),
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
})
self._write(record)
def run_job(self, job_id: Any) -> None:
"""Run one persisted action; used by the isolated systemd instance."""
normalized = _job_id(job_id)
record = self._read(normalized)
try:
if record["action"] == "analyze":
self._analyze(normalized)
else:
self._convert(normalized)
except Exception as exc:
if not self._path(normalized).is_file():
return
record = self._read(normalized)
now = _now()
record.update({
"state": "failed", "progress": 0, "error": str(exc)[:2000],
"updated_at": _iso(now),
"expires_at": _iso(now + timedelta(hours=RETENTION_HOURS)),
})
self._write(record)
raise
def _analyze(self, job_id: str) -> None:
metadata = analyze(self.source_path(job_id), self._dir(job_id) / "previews")
with self._lock:
if job_id in self._canceled:
return
record = self._read(job_id)
record.update({
"state": "awaiting_settings", "metadata": metadata,
"preview_count": metadata["preview_count"], "progress": 100,
"updated_at": _iso(_now()), "error": None,
})
self._write(record)
def _library_name_conflict(self, name: str) -> bool:
folded = name.casefold()
if any(item["name"].casefold() == folded for item in self.templates.list()["templates"]):
return True
return not self.animations.name_available(name)
def _reserved_name_conflict(self, name: str, *, exclude_job_id: str) -> bool:
folded = name.casefold()
for child in self.root.iterdir():
if not child.is_dir() or child.name == exclude_job_id:
continue
other = self._read(child.name)
if other["state"] in {"queued", "converting"} and other["name"].casefold() == folded:
return True
return False
def _name_conflict(self, record: dict[str, Any], *, include_reservations: bool) -> bool:
if self._library_name_conflict(record["name"]):
return True
return include_reservations and self._reserved_name_conflict(
record["name"], exclude_job_id=record["id"],
)
def _convert(self, job_id: str) -> None:
with self._lock:
self._ensure_space()
record = self._read(job_id)
if self._name_conflict(record, include_reservations=False):
raise TemplateConflictError("output name already exists")
record.update({
"state": "converting", "action": "convert", "progress": 1,
"updated_at": _iso(_now()), "error": None,
})
self._write(record)
frames = converted_frames(
self.source_path(job_id), record["metadata"], record["settings"],
)
if record["metadata"]["dynamic"]:
def scenes():
for index, (rgb, duration) in enumerate(frames):
with self._lock:
if job_id in self._canceled:
raise MediaConversionError("media conversion was canceled")
current = self._read(job_id)
current["progress"] = min(99, 1 + index * 98 // MAX_MERGED_FRAMES)
self._write(current)
yield {
"scene": rgb_scene(rgb), "duration_ms": duration,
"name": None,
}
self.animations.create_from_frames(record["name"], scenes())
else:
rgb, _ = next(iter(frames))
with self._lock:
if job_id in self._canceled:
raise MediaConversionError("media conversion was canceled")
self.templates.create(record["name"], rgb_scene(rgb))
with self._lock:
if job_id not in self._canceled:
shutil.rmtree(self._dir(job_id), ignore_errors=True)
def list(self) -> dict[str, Any]:
self.cleanup_expired()
with self._lock:
records = sorted(
(self._reconcile_external(self._read(child.name)) for child in self.root.iterdir() if child.is_dir()),
key=lambda record: record["created_at"],
)
return {"jobs": [self._public(record) for record in records]}
def get(self, job_id: Any) -> dict[str, Any]:
self.cleanup_expired()
with self._lock:
return self._public(self._reconcile_external(self._read(job_id)))
def update_settings(self, job_id: Any, value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
raise MediaImportError("settings must be an object")
allowed = {
"name", "fit_mode", "center_x", "center_y", "zoom",
"transparency_color", "padding_color",
}
if set(value) - allowed:
raise MediaImportError("unknown media settings field")
with self._lock:
record = self._read(job_id)
if record["state"] not in {"awaiting_settings", "failed"}:
raise MediaImportError("media settings cannot be changed in the current state", status_code=409)
settings = {**record["settings"], **{k: v for k, v in value.items() if k != "name"}}
try:
record["settings"] = validate_settings_for_metadata(settings, record["metadata"])
except MediaConversionError as exc:
raise MediaImportError(str(exc)) from exc
if "name" in value:
proposed_name = _output_name(value["name"])
proposed = {**record, "name": proposed_name}
if self._name_conflict(proposed, include_reservations=True):
raise MediaImportError("output name already exists", status_code=409)
record["name"] = proposed_name
record.update({"updated_at": _iso(_now()), "error": None})
if record["state"] == "failed" and record["metadata"] is not None:
record["state"] = "awaiting_settings"
self._write(record)
return self._public(record)
def enqueue_conversion(self, job_id: Any) -> dict[str, Any]:
with self._lock:
record = self._read(job_id)
if record["state"] != "awaiting_settings":
raise MediaImportError("media job is not ready for conversion", status_code=409)
if self._name_conflict(record, include_reservations=True):
raise MediaImportError("output name already exists", status_code=409)
self._sequence += 1
record.update({
"state": "queued", "action": "convert", "progress": 0,
"queue_sequence": self._sequence, "updated_at": _iso(_now()),
"error": None,
})
self._write(record)
self._schedule(record["id"], "convert", record["queue_sequence"])
return self._public(record)
def retry(self, job_id: Any) -> dict[str, Any]:
with self._lock:
record = self._read(job_id)
if record["state"] != "failed":
raise MediaImportError("only failed jobs can be retried", status_code=409)
action = record["action"]
if action == "convert" and record["metadata"] is not None and self._name_conflict(
record, include_reservations=True,
):
raise MediaImportError("output name already exists", status_code=409)
self._sequence += 1
record.update({
"state": "analyzing" if action == "analyze" else "queued",
"queue_sequence": self._sequence, "progress": 0,
"updated_at": _iso(_now()), "error": None,
})
self._write(record)
self._schedule(record["id"], action, record["queue_sequence"])
return self._public(record)
def cancel(self, job_id: Any) -> None:
normalized = _job_id(job_id)
with self._lock:
self._read(normalized)
self._canceled.add(normalized)
shutil.rmtree(self._dir(normalized), ignore_errors=True)
def cleanup_expired(self) -> None:
with self._lock:
now = _now()
for child in list(self.root.iterdir()):
if not child.is_dir():
continue
record = self._read(child.name)
if record["state"] in {"failed", "awaiting_settings"}:
try:
expires = datetime.fromisoformat(record["expires_at"])
except (TypeError, ValueError):
continue
if expires <= now:
self._canceled.add(record["id"])
shutil.rmtree(child, ignore_errors=True)
@@ -0,0 +1,24 @@
from __future__ import annotations
import argparse
from app.animations.store import AnimationStore
from app.config.store import ConfigStore
from app.media.manager import MediaImportManager
from app.templates.store import TemplateStore
def main() -> int:
parser = argparse.ArgumentParser(description="Run one isolated media import action")
parser.add_argument("job_id")
args = parser.parse_args()
config = ConfigStore()
templates = TemplateStore(config.data_dir)
animations = AnimationStore(config.data_dir, cleanup_playback=False)
manager = MediaImportManager(config.data_dir, templates, animations)
manager.run_job(args.job_id)
return 0
if __name__ == "__main__":
raise SystemExit(main())