diff --git a/README.md b/README.md
index 9fe8650..5cae90d 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,40 @@ python3 build_plasma.py 10_ice_fish.frag 11_ice_over_fish.frag ice_fish.html
`build_plasma.py` вставляет оба шейдера в шаблон страницы. После правки шейдеров страницу
надо пересобрать — HTML сам по себе не обновляется.
+## Обои
+
+Готовые рендеры с рыбами — по файлу на монитор (в репозиторий не входят, они тяжёлые):
+
+| файл | экран | размер |
+| --- | --- | --- |
+| `~/Videos/wallpapers/ice_fish_2560x1080.mp4` | DP-2, ультравайд | 110 МБ |
+| `~/Videos/wallpapers/ice_fish_2560x1440.mp4` | HDMI-A-1 | 112 МБ |
+
+30 секунд, 30 fps, петля бесшовная: кадр 900 совпадает с кадром 0, потому что цикл
+плазмы ровно 30 с. Профиль H.264 — **Baseline**: Qt на Fedora декодирует через openh264,
+который отвергает High profile и молча оставляет замерший кадр.
+
+Пересобрать:
+
+```sh
+python3 render_wallpaper.py ice_fish.html ~/Videos/wallpapers/ice_fish_2560x1440.mp4 2560 1440 30 30
+```
+
+Скрипт гонит кадры в headless Firefox по четыре за раз, кладёт их в `*.frames/`
+(можно удалить) и кодирует через `h264_nvenc`. Повторный запуск продолжает с места
+обрыва.
+
+Обоями видео ставит плагин `dev.emil.video` (исходники — `plasma-plugin/dev.emil.video/`,
+установленная копия — `~/.local/share/plasma/wallpapers/dev.emil.video/`). Он проигрывает
+файл из своего конфига, заполняя экран, и зацикливает его:
+
+```sh
+kpackagetool6 --type Wallpaper -i plasma-plugin/dev.emil.video
+```
+
+Дальше — правый клик по рабочему столу → «Настроить рабочий стол…» → тип обоев
+Video Wallpaper → путь к файлу. Настройки у каждого экрана свои.
+
## Проверка
```sh
@@ -73,5 +107,7 @@ python3 -m unittest test_ice
и преломления льда надо переносить во все три файла.
- `debug_fish.frag` + `debug_fish.html` — плоский рендер силуэтов рыб для отладки формы.
- `renders/*.jpg` — картинки для этого README, к сборке отношения не имеют.
+- `render_wallpaper.py` — кадры + ffmpeg, длинное видео для обоев.
+- `plasma-plugin/dev.emil.video` — минимальный видео-обои плагин для Plasma 6.
- `build.py` собирает `playground.html` со всеми `.frag` подряд, `render.py`/`shot.py` —
старые вспомогательные скрипты.
diff --git a/build_plasma.py b/build_plasma.py
index 8fa8123..be62e43 100644
--- a/build_plasma.py
+++ b/build_plasma.py
@@ -79,6 +79,7 @@ const shot = location.hash.startsWith('#shot');
const shotParams = new URLSearchParams(location.hash.slice(1));
let mouseX = Number(shotParams.get('mx') ?? 0.5), mouseY = Number(shotParams.get('my') ?? 0.5);
let speed = 1, time = Number(shotParams.get('t') ?? 0), previous;
+if (shotParams.get('info') === '0') document.getElementById('info').style.display = 'none';
addEventListener('pointermove', e => { mouseX = e.clientX / innerWidth; mouseY = 1 - e.clientY / innerHeight; });
addEventListener('keydown', e => {
if (e.key === '-' || e.key === '_') speed = Math.max(0.125, speed / 2);
diff --git a/ice_fish.html b/ice_fish.html
index d5074bb..2d68e08 100644
--- a/ice_fish.html
+++ b/ice_fish.html
@@ -65,6 +65,7 @@ const shot = location.hash.startsWith('#shot');
const shotParams = new URLSearchParams(location.hash.slice(1));
let mouseX = Number(shotParams.get('mx') ?? 0.5), mouseY = Number(shotParams.get('my') ?? 0.5);
let speed = 1, time = Number(shotParams.get('t') ?? 0), previous;
+if (shotParams.get('info') === '0') document.getElementById('info').style.display = 'none';
addEventListener('pointermove', e => { mouseX = e.clientX / innerWidth; mouseY = 1 - e.clientY / innerHeight; });
addEventListener('keydown', e => {
if (e.key === '-' || e.key === '_') speed = Math.max(0.125, speed / 2);
diff --git a/plasma-plugin/dev.emil.video/contents/config/main.xml b/plasma-plugin/dev.emil.video/contents/config/main.xml
new file mode 100644
index 0000000..8a9cb0b
--- /dev/null
+++ b/plasma-plugin/dev.emil.video/contents/config/main.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ 1.0
+
+
+
diff --git a/plasma-plugin/dev.emil.video/contents/ui/main.qml b/plasma-plugin/dev.emil.video/contents/ui/main.qml
new file mode 100644
index 0000000..2bde6ec
--- /dev/null
+++ b/plasma-plugin/dev.emil.video/contents/ui/main.qml
@@ -0,0 +1,58 @@
+import QtQuick
+import QtMultimedia
+import org.kde.plasma.plasmoid
+
+// Plays one video file, filling the screen and looping for ever. Plasma keeps a
+// separate configuration per screen, so every monitor gets its own video.
+WallpaperItem {
+ id: root
+
+ readonly property var activeConfiguration: {
+ if (typeof wallpaper !== "undefined" && wallpaper && wallpaper.configuration)
+ return wallpaper.configuration
+ return typeof configuration !== "undefined" ? configuration : null
+ }
+
+ function option(name, fallback) {
+ if (!activeConfiguration)
+ return fallback
+ const value = activeConfiguration[name]
+ return value === undefined ? fallback : value
+ }
+
+ readonly property string videoPath: String(option("videoPath", ""))
+ // shortcut: keeps decoding behind fullscreen windows too, add window tracking
+ // (as the shader wallpaper plugin does) if the cost ever shows up in a game
+ readonly property bool shouldPlay: videoPath.length > 0 && root.visible
+
+ Rectangle {
+ anchors.fill: parent
+ color: "#03111d"
+ }
+
+ VideoOutput {
+ id: output
+ anchors.fill: parent
+ fillMode: VideoOutput.PreserveAspectCrop
+ }
+
+ MediaPlayer {
+ id: player
+ videoOutput: output
+ source: root.videoPath.length > 0 ? "file://" + root.videoPath : ""
+ loops: MediaPlayer.Infinite
+ playbackRate: Number(root.option("playbackRate", 1.0))
+
+ // Some containers do not restart on their own when they run out of frames.
+ onMediaStatusChanged: {
+ if (mediaStatus === MediaPlayer.EndOfMedia) {
+ position = 0
+ play()
+ }
+ }
+
+ onErrorOccurred: console.warn("video wallpaper:", error, errorString)
+ }
+
+ Component.onCompleted: player.play()
+}
diff --git a/plasma-plugin/dev.emil.video/metadata.json b/plasma-plugin/dev.emil.video/metadata.json
new file mode 100644
index 0000000..c812d08
--- /dev/null
+++ b/plasma-plugin/dev.emil.video/metadata.json
@@ -0,0 +1,17 @@
+{
+ "KPackageStructure": "Plasma/Wallpaper",
+ "KPlugin": {
+ "Authors": [
+ {
+ "Name": "Emil"
+ }
+ ],
+ "Id": "dev.emil.video",
+ "License": "GPL-3.0-or-later",
+ "Name": "Video Wallpaper",
+ "Version": "0.1.0",
+ "Description": "Plays a video file as the wallpaper, scaled to fill the screen"
+ },
+ "X-KDE-ParentApp": "org.kde.plasmashell",
+ "X-Plasma-API-Minimum-Version": "6.0"
+}
diff --git a/render_wallpaper.py b/render_wallpaper.py
new file mode 100755
index 0000000..03beae9
--- /dev/null
+++ b/render_wallpaper.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""Render a seamless video of one of the pages: frames first, then ffmpeg.
+
+ render_wallpaper.py [seconds] [fps]
+
+Frames land in .frames/ as PNG and are kept so a re-run resumes.
+The page's loop is exactly 30 s, so keep the duration a multiple of that to
+make the video loop without a seam."""
+import os
+import subprocess
+import sys
+import time
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+page, out = Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve()
+width, height = int(sys.argv[3]), int(sys.argv[4])
+seconds = float(sys.argv[5]) if len(sys.argv) > 5 else 30.0
+fps = int(sys.argv[6]) if len(sys.argv) > 6 else 30
+workers = min(4, os.cpu_count() or 2) # 8 headless Firefox at once segfaults the GPU driver
+
+frames = out.with_suffix('.frames')
+frames.mkdir(parents=True, exist_ok=True)
+profiles = [Path(f'/tmp/ff_wall_{i}') for i in range(workers)]
+for profile in profiles:
+ profile.mkdir(exist_ok=True)
+
+total = int(round(seconds * fps))
+
+
+def render(index):
+ target = frames / f'{index:04d}.png'
+ if target.exists() and target.stat().st_size > 20000:
+ return 0.0
+ url = f'{page.as_uri()}#shot&t={index / fps:.6f}&info=0'
+ profile = profiles[index % workers]
+ for attempt in range(4):
+ started = time.time()
+ result = subprocess.run(['firefox', '--headless', '--new-instance',
+ '--profile', str(profile),
+ f'--window-size={width},{height}',
+ f'--screenshot={target}', url],
+ timeout=300,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ if result.returncode == 0 and target.exists() and target.stat().st_size > 20000:
+ return time.time() - started
+ target.unlink(missing_ok=True)
+ time.sleep(1 + attempt)
+ raise RuntimeError(f'frame {index} failed')
+
+
+began = time.time()
+with ThreadPoolExecutor(workers) as pool:
+ for done, _ in enumerate(pool.map(render, range(total)), 1):
+ if done % 30 == 0 or done == total:
+ elapsed = time.time() - began
+ print(f'{done}/{total} frames, {elapsed:.0f}s elapsed, '
+ f'~{elapsed / done * (total - done):.0f}s left', flush=True)
+
+out.parent.mkdir(parents=True, exist_ok=True)
+subprocess.run(['ffmpeg', '-y', '-hide_banner', '-loglevel', 'warning',
+ '-framerate', str(fps), '-i', str(frames / '%04d.png'),
+ # Baseline on purpose: Fedora's Qt Multimedia decodes H.264 through
+ # openh264, which rejects High profile ("DecodeFrame failed" in the
+ # journal) and then Plasma shows a frozen frame.
+ '-c:v', 'h264_nvenc', '-profile:v', 'baseline', '-preset', 'p7',
+ '-rc', 'constqp', '-qp', '18', '-pix_fmt', 'yuv420p',
+ '-movflags', '+faststart', str(out)], check=True)
+print(f'{out} written ({out.stat().st_size / 1e6:.1f} MB)')