Render the ice as long videos and play them as wallpapers

render_wallpaper.py drives headless Firefox frame by frame (four at a time,
resuming where it stopped), then ffmpeg. Thirty seconds at 30 fps is exactly
one plasma cycle, so frame 900 meets frame 0 and the loop has no seam.

plasma-plugin/dev.emil.video is a minimal Plasma 6 video wallpaper: one file
per screen, filling the screen, looping. H.264 is encoded Baseline because
Qt here decodes through openh264, which rejects High profile and leaves a
frozen frame; a new source also has to be played explicitly.

The #shot hash learned info=0 to hide the on-screen hint for renders.
This commit is contained in:
emil28092005
2026-10-31 01:22:16 +03:00
parent 3f638a286a
commit 92e8b287a3
7 changed files with 193 additions and 0 deletions
+36
View File
@@ -54,6 +54,40 @@ python3 build_plasma.py 10_ice_fish.frag 11_ice_over_fish.frag ice_fish.html
`build_plasma.py` вставляет оба шейдера в шаблон страницы. После правки шейдеров страницу `build_plasma.py` вставляет оба шейдера в шаблон страницы. После правки шейдеров страницу
надо пересобрать — HTML сам по себе не обновляется. надо пересобрать — 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 ```sh
@@ -73,5 +107,7 @@ python3 -m unittest test_ice
и преломления льда надо переносить во все три файла. и преломления льда надо переносить во все три файла.
- `debug_fish.frag` + `debug_fish.html` — плоский рендер силуэтов рыб для отладки формы. - `debug_fish.frag` + `debug_fish.html` — плоский рендер силуэтов рыб для отладки формы.
- `renders/*.jpg` — картинки для этого README, к сборке отношения не имеют. - `renders/*.jpg` — картинки для этого README, к сборке отношения не имеют.
- `render_wallpaper.py` — кадры + ffmpeg, длинное видео для обоев.
- `plasma-plugin/dev.emil.video` — минимальный видео-обои плагин для Plasma 6.
- `build.py` собирает `playground.html` со всеми `.frag` подряд, `render.py`/`shot.py` — - `build.py` собирает `playground.html` со всеми `.frag` подряд, `render.py`/`shot.py` —
старые вспомогательные скрипты. старые вспомогательные скрипты.
+1
View File
@@ -79,6 +79,7 @@ const shot = location.hash.startsWith('#shot');
const shotParams = new URLSearchParams(location.hash.slice(1)); const shotParams = new URLSearchParams(location.hash.slice(1));
let mouseX = Number(shotParams.get('mx') ?? 0.5), mouseY = Number(shotParams.get('my') ?? 0.5); 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; 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('pointermove', e => { mouseX = e.clientX / innerWidth; mouseY = 1 - e.clientY / innerHeight; });
addEventListener('keydown', e => { addEventListener('keydown', e => {
if (e.key === '-' || e.key === '_') speed = Math.max(0.125, speed / 2); if (e.key === '-' || e.key === '_') speed = Math.max(0.125, speed / 2);
+1
View File
@@ -65,6 +65,7 @@ const shot = location.hash.startsWith('#shot');
const shotParams = new URLSearchParams(location.hash.slice(1)); const shotParams = new URLSearchParams(location.hash.slice(1));
let mouseX = Number(shotParams.get('mx') ?? 0.5), mouseY = Number(shotParams.get('my') ?? 0.5); 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; 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('pointermove', e => { mouseX = e.clientX / innerWidth; mouseY = 1 - e.clientY / innerHeight; });
addEventListener('keydown', e => { addEventListener('keydown', e => {
if (e.key === '-' || e.key === '_') speed = Math.max(0.125, speed / 2); if (e.key === '-' || e.key === '_') speed = Math.max(0.125, speed / 2);
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg>
<group name="General">
<entry name="videoPath" type="String">
<default></default>
</entry>
<entry name="playbackRate" type="Double">
<default>1.0</default>
</entry>
</group>
</kcfg>
@@ -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()
}
@@ -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"
}
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Render a seamless video of one of the pages: frames first, then ffmpeg.
render_wallpaper.py <page.html> <out.mp4> <width> <height> [seconds] [fps]
Frames land in <out.mp4>.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)')