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)')