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` вставляет оба шейдера в шаблон страницы. После правки шейдеров страницу
|
`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` —
|
||||||
старые вспомогательные скрипты.
|
старые вспомогательные скрипты.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
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