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:
@@ -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` —
|
||||
старые вспомогательные скрипты.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+69
@@ -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)')
|
||||
Reference in New Issue
Block a user