WIP: feat: video wallpapers #163
+26
-1
@@ -18,6 +18,8 @@ Searcher {
|
||||
property string actualCurrent: WallpaperPath.currentWallpaperPath
|
||||
property alias crops: adapter.monitorCrops
|
||||
readonly property string current: showPreview ? previewPath : actualCurrent
|
||||
readonly property string currentThumbnail: thumbnailFor(current)
|
||||
readonly property bool currentIsVideo: isVideoPath(current)
|
||||
property alias monitorCrops: monitorCrops
|
||||
property bool pendingPreviewClear
|
||||
property bool previewColorLock
|
||||
@@ -32,6 +34,29 @@ Searcher {
|
||||
return category;
|
||||
}
|
||||
|
||||
readonly property var videoExts: ["mp4", "mkv", "webm", "avi", "mov", "wmv", "flv", "m4v", "mpg", "mpeg", "3gp", "ogv", "ts", "vob"]
|
||||
|
||||
function isVideoPath(path: string): bool {
|
||||
if (!path)
|
||||
return false;
|
||||
const dot = path.lastIndexOf(".");
|
||||
if (dot < 0)
|
||||
return false;
|
||||
return videoExts.includes(path.slice(dot + 1).toLowerCase());
|
||||
}
|
||||
|
||||
function thumbnailFor(path: string): string {
|
||||
if (!isVideoPath(path))
|
||||
return path;
|
||||
|
||||
for (const entry of root.list) {
|
||||
if (entry.path === path && entry.thumbnailPath)
|
||||
return entry.thumbnailPath;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
function getCrop(screen: string): var {
|
||||
return root.crops[screen];
|
||||
}
|
||||
@@ -125,7 +150,7 @@ Searcher {
|
||||
FileSystemModel {
|
||||
id: wallpapers
|
||||
|
||||
filter: FileSystemModel.Images
|
||||
filter: FileSystemModel.Media
|
||||
path: Config.general.wallpaperPath
|
||||
recursive: true
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
@@ -9,7 +11,7 @@ Item {
|
||||
id: root
|
||||
|
||||
required property bool isCurrent
|
||||
required property var modelData // FileSystemEntry-like {path, relativePath}
|
||||
required property var modelData
|
||||
required property real tileHeight
|
||||
required property real tileWidth
|
||||
required property ScreenState screenState
|
||||
@@ -29,25 +31,75 @@ Item {
|
||||
radius: Tokens.rounding.small
|
||||
y: Tokens.padding.large
|
||||
|
||||
MaterialIcon {
|
||||
Loader {
|
||||
active: opacity > 0
|
||||
anchors.centerIn: parent
|
||||
color: Colors.tPalette.m3outline
|
||||
fontStyle: Tokens.font.icon.builders.extraLarge.scale(2).weight(Font.DemiBold).build()
|
||||
text: "image"
|
||||
opacity: img.status === Image.Ready ? 0 : 1
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
sourceComponent: CustomRect {
|
||||
color: Colors.palette.m3primaryContainer
|
||||
implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2
|
||||
implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2
|
||||
radius: Tokens.rounding.full
|
||||
|
||||
LoadingIndicator {
|
||||
id: loadingIndicator
|
||||
|
||||
anchors.centerIn: parent
|
||||
containsIcon: true
|
||||
implicitSize: Math.min(image.width, image.height) * 0.3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CachingImage {
|
||||
id: img
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
cache: true
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
height: root.tileHeight
|
||||
path: root.modelData ? root.modelData.path : ""
|
||||
path: root.modelData ? root.modelData.thumbnailPath : ""
|
||||
smooth: root.isCurrent
|
||||
sourceSize.height: root.tileHeight
|
||||
sourceSize.width: root.tileHeight * (16 / 9)
|
||||
width: root.tileHeight * (16 / 9)
|
||||
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0
|
||||
active: opacity > 0
|
||||
anchors.centerIn: parent
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
|
||||
sourceComponent: CustomRect {
|
||||
implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2
|
||||
implicitHeight: implicitWidth
|
||||
radius: Tokens.rounding.full
|
||||
color: Colors.tPalette.m3surface
|
||||
|
||||
MaterialIcon {
|
||||
id: videoIcon
|
||||
anchors.centerIn: parent
|
||||
text: "video_library"
|
||||
fontStyle: Tokens.font.icon.extraLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
|
||||
@@ -39,8 +39,7 @@ Item {
|
||||
opacity: img.status === Image.Ready ? 0 : 1
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
sourceComponent: CustomRect {
|
||||
color: Colors.palette.m3primaryContainer
|
||||
@@ -64,18 +63,43 @@ Item {
|
||||
anchors.fill: parent
|
||||
asynchronous: true
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
retainWhileLoading: true
|
||||
sourceSize: {
|
||||
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
|
||||
return Qt.size(width * dpr, height * dpr);
|
||||
}
|
||||
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
opacity: root.modelData.isVideo && img.status === Image.Ready ? 1 : 0
|
||||
active: opacity > 0
|
||||
anchors.centerIn: parent
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
|
||||
sourceComponent: CustomRect {
|
||||
implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2
|
||||
implicitHeight: implicitWidth
|
||||
radius: Tokens.rounding.full
|
||||
color: Colors.tPalette.m3surface
|
||||
|
||||
MaterialIcon {
|
||||
id: videoIcon
|
||||
anchors.centerIn: parent
|
||||
text: "video_library"
|
||||
fontStyle: Tokens.font.icon.extraLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import qs.Services
|
||||
Item {
|
||||
id: wrapper
|
||||
|
||||
property int buttonRowHeight: Tokens.padding.large * 3
|
||||
readonly property bool isVideo: Wallpapers.currentIsVideo
|
||||
readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null
|
||||
readonly property real maxZoom: 5.0
|
||||
readonly property bool changesMade: {
|
||||
cropRevision;
|
||||
|
||||
@@ -25,9 +27,9 @@ Item {
|
||||
|
||||
return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001;
|
||||
}
|
||||
|
||||
property int buttonRowHeight: Tokens.padding.large * 3
|
||||
property int cropRevision: 0
|
||||
readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null
|
||||
readonly property real maxZoom: 5.0
|
||||
property var screens: []
|
||||
property int selectedScreenIndex: 0
|
||||
property bool shouldBeActive: true
|
||||
@@ -127,7 +129,7 @@ Item {
|
||||
anchors.rightMargin: Tokens.padding.extraLarge * 2
|
||||
anchors.top: parent.top
|
||||
|
||||
active: wrapper.screens.length > 1
|
||||
active: wrapper.screens.length > 1 && !wrapper.isVideo
|
||||
|
||||
sourceComponent: ButtonRow {
|
||||
id: screenSelector
|
||||
@@ -163,6 +165,7 @@ Item {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
implicitHeight: 30
|
||||
enabled: !wrapper.isVideo
|
||||
|
||||
CustomSlider {
|
||||
id: zoomSlider
|
||||
@@ -274,7 +277,7 @@ Item {
|
||||
opacity: status === Image.Ready ? 1 : 0
|
||||
retainWhileLoading: true
|
||||
smooth: true
|
||||
source: Wallpapers.current
|
||||
source: Wallpapers.currentThumbnail
|
||||
sourceSize.height: {
|
||||
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
|
||||
return imageSlot.height * dpr;
|
||||
@@ -285,12 +288,37 @@ Item {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
opacity: wrapper.isVideo && scaledImg.status === Image.Ready ? 1 : 0
|
||||
active: opacity > 0
|
||||
anchors.centerIn: parent
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
|
||||
sourceComponent: CustomRect {
|
||||
implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2
|
||||
implicitHeight: implicitWidth
|
||||
radius: Tokens.rounding.full
|
||||
color: Colors.tPalette.m3surface
|
||||
|
||||
MaterialIcon {
|
||||
id: videoIcon
|
||||
anchors.centerIn: parent
|
||||
text: "video_library"
|
||||
fontStyle: Tokens.font.icon.extraLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: cropRectLoader
|
||||
|
||||
active: scaledImg.status === Image.Ready
|
||||
active: scaledImg.status === Image.Ready && !wrapper.isVideo
|
||||
|
||||
sourceComponent: Component {
|
||||
CustomRect {
|
||||
@@ -333,7 +361,6 @@ Item {
|
||||
y = t.y;
|
||||
}
|
||||
|
||||
// Where the rect should be according to saved data (same rules as a restore).
|
||||
function savedTarget() {
|
||||
const data = Wallpapers.getCrop(wrapper.currentScreen.name);
|
||||
const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001);
|
||||
@@ -436,9 +463,10 @@ Item {
|
||||
}
|
||||
|
||||
anchors.fill: parent
|
||||
cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor
|
||||
cursorShape: wrapper.isVideo ? Qt.ArrowCursor : pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
enabled: !wrapper.isVideo
|
||||
|
||||
onPositionChanged: mouse => {
|
||||
if (pressed) {
|
||||
|
||||
@@ -50,11 +50,12 @@ PageBase {
|
||||
}
|
||||
|
||||
WallItem {
|
||||
id: wallItem
|
||||
required property FileSystemEntry modelData
|
||||
|
||||
enabled: modelData
|
||||
opacity: modelData ? 1 : 0
|
||||
source: String(modelData?.path ?? "")
|
||||
source: String(modelData?.thumbnailPath ?? "")
|
||||
|
||||
onClicked: {
|
||||
Wallpapers.setWallpaper(modelData.path);
|
||||
|
||||
@@ -20,6 +20,7 @@ pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
|
||||
pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)
|
||||
pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl)
|
||||
pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl)
|
||||
pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv)
|
||||
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
|
||||
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
|
||||
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
|
||||
|
||||
@@ -39,4 +39,5 @@ qml_module(ZShell-internal
|
||||
PkgConfig::EGL
|
||||
PkgConfig::WAYLAND_EGL
|
||||
PkgConfig::GL
|
||||
PkgConfig::MPV
|
||||
)
|
||||
|
||||
@@ -7,15 +7,26 @@
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glext.h>
|
||||
|
||||
#include <mpv/client.h>
|
||||
#include <mpv/render_gl.h>
|
||||
|
||||
#include <clocale>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <QDebug>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QImageReader>
|
||||
#include <QScreen>
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QProcess>
|
||||
|
||||
#include <qguiapplication_platform.h>
|
||||
|
||||
#include <private/qwaylandscreen_p.h>
|
||||
@@ -29,7 +40,6 @@ namespace {
|
||||
const char* kVertexShader = R"(
|
||||
#version 300 es
|
||||
void main() {
|
||||
// The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer.
|
||||
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
||||
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@@ -41,16 +51,36 @@ precision highp float;
|
||||
uniform sampler2D source;
|
||||
uniform vec2 size;
|
||||
uniform vec4 crop;
|
||||
uniform float alpha;
|
||||
out vec4 outColor;
|
||||
void main() {
|
||||
// The pixels are stored top-down, so the fragment coordinate is flipped.
|
||||
vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size;
|
||||
outColor = texture(source, crop.xy + p * crop.zw);
|
||||
vec4 c = texture(source, crop.xy + p * crop.zw);
|
||||
outColor = vec4(c.rgb, c.a * alpha);
|
||||
}
|
||||
)";
|
||||
|
||||
constexpr int kWaitTimeoutMs = 20;
|
||||
|
||||
constexpr int kFadeDurationMs = 400;
|
||||
|
||||
const QStringList kVideoExtensions = {
|
||||
"mp4",
|
||||
"mkv",
|
||||
"webm",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"m4v",
|
||||
"mpg",
|
||||
"mpeg",
|
||||
"3gp",
|
||||
"ogv",
|
||||
"ts",
|
||||
"vob",
|
||||
};
|
||||
|
||||
void logShader(uint32_t object, bool program) {
|
||||
int ok = 0;
|
||||
if (program)
|
||||
@@ -94,6 +124,16 @@ WallpaperService::~WallpaperService() {
|
||||
|
||||
if (m_thread.joinable()) m_thread.join();
|
||||
|
||||
if (m_mpvRender) {
|
||||
mpv_render_context_free(m_mpvRender);
|
||||
m_mpvRender = nullptr;
|
||||
}
|
||||
|
||||
if (m_mpv) {
|
||||
mpv_terminate_destroy(m_mpv);
|
||||
m_mpv = nullptr;
|
||||
}
|
||||
|
||||
for (auto* surface : m_surfaces)
|
||||
delete surface;
|
||||
m_surfaces.clear();
|
||||
@@ -102,6 +142,8 @@ WallpaperService::~WallpaperService() {
|
||||
if (eglMakeCurrent(
|
||||
m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) {
|
||||
if (m_texture) glDeleteTextures(1, &m_texture);
|
||||
if (m_previousTexture) glDeleteTextures(1, &m_previousTexture);
|
||||
if (m_blackTex) glDeleteTextures(1, &m_blackTex);
|
||||
if (m_program) glDeleteProgram(m_program);
|
||||
}
|
||||
|
||||
@@ -127,22 +169,210 @@ QString WallpaperService::path() const {
|
||||
}
|
||||
|
||||
void WallpaperService::setPath(const QString& path) {
|
||||
if (m_path == path) return;
|
||||
if (path.isEmpty() || path == this->path()) return;
|
||||
|
||||
if (isVideoPath(path) && needsTranscode(path)) {
|
||||
const QString dst = transcodeTarget(path);
|
||||
|
||||
if (QFile::exists(dst)) {
|
||||
applyPath(dst);
|
||||
return;
|
||||
}
|
||||
|
||||
applyPath(path);
|
||||
transcodeVideo(path, dst);
|
||||
return;
|
||||
}
|
||||
|
||||
applyPath(path);
|
||||
}
|
||||
|
||||
void WallpaperService::applyPath(const QString& path) {
|
||||
const bool video = isVideoPath(path);
|
||||
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(m_mutex);
|
||||
m_path = path;
|
||||
|
||||
const bool wasVideo = m_isVideo && m_mpv != nullptr;
|
||||
const bool wasImage = !m_image.isNull() && m_texture != 0;
|
||||
|
||||
m_previousVideo = wasVideo && !video;
|
||||
|
||||
if (wasImage) {
|
||||
m_previousImage = m_image;
|
||||
m_previousCrops = m_crops;
|
||||
m_previousTexture = 0;
|
||||
} else {
|
||||
m_previousImage = QImage();
|
||||
}
|
||||
|
||||
m_isVideo = video;
|
||||
|
||||
m_fading = true;
|
||||
m_fadeClockStarted = false;
|
||||
|
||||
m_forceDecode = true;
|
||||
m_imageChanged = true;
|
||||
m_videoReload = true;
|
||||
}
|
||||
|
||||
emit pathChanged();
|
||||
|
||||
start();
|
||||
|
||||
queueAll();
|
||||
}
|
||||
|
||||
bool WallpaperService::isHardwareCodec(const QString& codec) {
|
||||
static const QStringList hardware{
|
||||
"av1",
|
||||
"hevc",
|
||||
"h264",
|
||||
"vp9",
|
||||
"vp8",
|
||||
"vc1",
|
||||
"mpeg2",
|
||||
"mpeg2video",
|
||||
"mpeg4",
|
||||
"mjpeg",
|
||||
};
|
||||
|
||||
return hardware.contains(codec);
|
||||
}
|
||||
|
||||
QString WallpaperService::codecName(const QString& path) const {
|
||||
QProcess probe;
|
||||
probe.start(
|
||||
"ffprobe",
|
||||
{
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
"-of",
|
||||
"default=nw=1:nk=1",
|
||||
path,
|
||||
});
|
||||
|
||||
if (!probe.waitForFinished(2000) || probe.exitCode() != 0) {
|
||||
qWarning() << "WallpaperService: ffprobe failed for" << path << "(exit"
|
||||
<< probe.exitCode() << ")";
|
||||
return {};
|
||||
}
|
||||
|
||||
const QString codec =
|
||||
QString::fromUtf8(probe.readAllStandardOutput()).trimmed().toLower();
|
||||
qWarning() << "WallpaperService: codec of" << path << "is"
|
||||
<< (codec.isEmpty() ? "<empty>" : codec);
|
||||
return codec;
|
||||
}
|
||||
|
||||
QString WallpaperService::cacheDir() const {
|
||||
return QDir::homePath() + "/.cache/zshell/wallpaper-videos";
|
||||
}
|
||||
|
||||
QString WallpaperService::transcodeTarget(const QString& path) const {
|
||||
const QFileInfo info(path);
|
||||
|
||||
const QByteArray key =
|
||||
path.toUtf8() + '|' + QByteArray::number(info.size()) + '|' +
|
||||
QByteArray::number(info.lastModified().toMSecsSinceEpoch());
|
||||
|
||||
const QString hash = QString::fromUtf8(
|
||||
QCryptographicHash::hash(key, QCryptographicHash::Sha1).toHex());
|
||||
|
||||
return cacheDir() + '/' + hash + ".av1.mkv";
|
||||
}
|
||||
|
||||
bool WallpaperService::needsTranscode(const QString& path) const {
|
||||
if (path.startsWith(cacheDir())) return false;
|
||||
|
||||
const QString codec = codecName(path);
|
||||
if (codec.isEmpty()) return false;
|
||||
|
||||
return !isHardwareCodec(codec);
|
||||
}
|
||||
|
||||
void WallpaperService::transcodeVideo(const QString& src, const QString& dst) {
|
||||
QDir().mkpath(cacheDir());
|
||||
|
||||
if (m_transcode) {
|
||||
m_transcode->kill();
|
||||
m_transcode->deleteLater();
|
||||
m_transcode = nullptr;
|
||||
}
|
||||
|
||||
m_transcode = new QProcess(this);
|
||||
auto* proc = m_transcode;
|
||||
|
||||
connect(proc, &QProcess::readyReadStandardError, proc, [proc]() {
|
||||
qWarning().noquote()
|
||||
<< "ffmpeg:" << QString::fromUtf8(proc->readAllStandardError());
|
||||
});
|
||||
|
||||
connect(
|
||||
proc,
|
||||
&QProcess::finished,
|
||||
this,
|
||||
[this, proc, dst](int code, QProcess::ExitStatus status) {
|
||||
const bool ok = code == 0 && status == QProcess::NormalExit &&
|
||||
QFile::exists(dst);
|
||||
|
||||
qWarning() << "WallpaperService: transcode finished, exit" << code
|
||||
<< "status" << int(status) << "exists"
|
||||
<< QFile::exists(dst) << "->" << dst;
|
||||
|
||||
if (proc == m_transcode) m_transcode = nullptr;
|
||||
proc->deleteLater();
|
||||
|
||||
if (ok) applyPath(dst);
|
||||
});
|
||||
|
||||
qWarning() << "WallpaperService: transcoding" << src << "->" << dst;
|
||||
|
||||
proc->setProgram("nice");
|
||||
proc->setArguments({
|
||||
"-n",
|
||||
"19",
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-threads",
|
||||
"2",
|
||||
"-i",
|
||||
src,
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-vf",
|
||||
"scale=in_range=pc:out_range=tv:out_color_matrix=bt709",
|
||||
"-colorspace",
|
||||
"bt709",
|
||||
"-color_primaries",
|
||||
"bt709",
|
||||
"-color_trc",
|
||||
"bt709",
|
||||
"-color_range",
|
||||
"tv",
|
||||
"-c:v",
|
||||
"libsvtav1",
|
||||
"-preset",
|
||||
"6",
|
||||
"-crf",
|
||||
"20",
|
||||
"-pix_fmt",
|
||||
"yuv420p10le",
|
||||
"-an",
|
||||
dst,
|
||||
});
|
||||
|
||||
proc->start();
|
||||
}
|
||||
|
||||
void WallpaperService::addScreen(const QString& screen) {
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(m_mutex);
|
||||
@@ -212,6 +442,178 @@ void WallpaperService::redraw() {
|
||||
queueAll();
|
||||
}
|
||||
|
||||
bool WallpaperService::isVideoPath(const QString& path) {
|
||||
return kVideoExtensions.contains(QFileInfo(path).suffix().toLower());
|
||||
}
|
||||
|
||||
void WallpaperService::startVideo(const QString& path) {
|
||||
if (path.isEmpty()) return;
|
||||
|
||||
m_videoReady.store(false, std::memory_order_relaxed);
|
||||
m_videoLoaded.store(false, std::memory_order_relaxed);
|
||||
|
||||
if (!m_mpv) {
|
||||
std::setlocale(LC_NUMERIC, "C");
|
||||
|
||||
m_mpv = mpv_create();
|
||||
if (!m_mpv) {
|
||||
qWarning() << "WallpaperService: could not create mpv handle";
|
||||
return;
|
||||
}
|
||||
|
||||
mpv_set_option_string(m_mpv, "config", "no");
|
||||
mpv_set_option_string(m_mpv, "vo", "libmpv");
|
||||
mpv_set_option_string(m_mpv, "audio", "no");
|
||||
mpv_set_option_string(m_mpv, "loop", "inf");
|
||||
mpv_set_option_string(m_mpv, "hwdec", "auto-copy");
|
||||
mpv_set_option_string(m_mpv, "video-sync", "display");
|
||||
mpv_set_option_string(m_mpv, "framedrop", "decoder");
|
||||
mpv_set_option_string(m_mpv, "background-color", "#00000000");
|
||||
|
||||
if (mpv_initialize(m_mpv) < 0) {
|
||||
qWarning() << "WallpaperService: mpv_initialize failed";
|
||||
mpv_terminate_destroy(m_mpv);
|
||||
m_mpv = nullptr;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const QByteArray local = path.toUtf8();
|
||||
|
||||
initMpvRender();
|
||||
|
||||
const char* loadCmd[]{"loadfile", local.constData(), nullptr};
|
||||
if (mpv_command(m_mpv, loadCmd) < 0)
|
||||
qWarning() << "WallpaperService: mpv could not load" << path;
|
||||
|
||||
m_mpvFrameReady.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void WallpaperService::pumpMpvEvents() {
|
||||
if (!m_mpv) return;
|
||||
|
||||
mpv_event* event = nullptr;
|
||||
while ((event = mpv_wait_event(m_mpv, 0)) != nullptr &&
|
||||
event->event_id != MPV_EVENT_NONE) {
|
||||
if (event->event_id == MPV_EVENT_LOG_MESSAGE) {
|
||||
auto* data = static_cast<mpv_event_log_message*>(event->data);
|
||||
if (data && data->text)
|
||||
qWarning().noquote() << "mpv:" << data->prefix << data->text;
|
||||
}
|
||||
|
||||
if (event->event_id == MPV_EVENT_FILE_LOADED) {
|
||||
m_videoLoaded.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
if (event->event_id == MPV_EVENT_END_FILE) {
|
||||
m_videoLoaded.store(false, std::memory_order_relaxed);
|
||||
m_videoReady.store(false, std::memory_order_relaxed);
|
||||
|
||||
auto* data = static_cast<mpv_event_end_file*>(event->data);
|
||||
if (data && data->error != MPV_END_FILE_REASON_EOF)
|
||||
qWarning() << "WallpaperService: mpv ended the file, error"
|
||||
<< data->error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WallpaperService::stopVideo() {
|
||||
teardownMpv();
|
||||
}
|
||||
|
||||
void WallpaperService::teardownMpv() {
|
||||
if (m_mpvRender) {
|
||||
mpv_render_context_free(m_mpvRender);
|
||||
m_mpvRender = nullptr;
|
||||
}
|
||||
|
||||
if (m_mpv) {
|
||||
mpv_terminate_destroy(m_mpv);
|
||||
m_mpv = nullptr;
|
||||
}
|
||||
|
||||
m_mpvFrameReady.store(false, std::memory_order_relaxed);
|
||||
m_videoReady.store(false, std::memory_order_relaxed);
|
||||
m_videoLoaded.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void* WallpaperService::getMpvProcAddress(void* ctx, const char* name) {
|
||||
(void)ctx;
|
||||
|
||||
if (strcmp(name, "glFenceSync") == 0)
|
||||
return reinterpret_cast<void*>(
|
||||
+[](GLenum, GLbitfield) -> GLsync { return nullptr; });
|
||||
|
||||
return reinterpret_cast<void*>(eglGetProcAddress(name));
|
||||
}
|
||||
|
||||
void WallpaperService::mpvUpdate(void* ctx) {
|
||||
auto* self = static_cast<WallpaperService*>(ctx);
|
||||
|
||||
self->m_mpvFrameReady.store(true, std::memory_order_relaxed);
|
||||
|
||||
if (self->m_videoLoaded.load(std::memory_order_relaxed))
|
||||
self->m_videoReady.store(true, std::memory_order_relaxed);
|
||||
|
||||
self->wake();
|
||||
}
|
||||
|
||||
void WallpaperService::initMpvRender() {
|
||||
if (m_mpvRender || !m_mpv) return;
|
||||
|
||||
mpv_opengl_init_params gl_init{getMpvProcAddress, nullptr};
|
||||
|
||||
mpv_render_param params[]{
|
||||
{MPV_RENDER_PARAM_API_TYPE,
|
||||
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
|
||||
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &gl_init},
|
||||
{MPV_RENDER_PARAM_INVALID, nullptr},
|
||||
};
|
||||
|
||||
if (mpv_render_context_create(&m_mpvRender, m_mpv, params) < 0) {
|
||||
qWarning() << "WallpaperService: could not create mpv render context";
|
||||
m_mpvRender = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
mpv_render_context_set_update_callback(m_mpvRender, mpvUpdate, this);
|
||||
}
|
||||
|
||||
void WallpaperService::renderMpvToSurface(const QSize& size) {
|
||||
if (!m_mpvRender) initMpvRender();
|
||||
if (!m_mpvRender) return;
|
||||
|
||||
glViewport(0, 0, size.width(), size.height());
|
||||
|
||||
mpv_opengl_fbo fbo{0, size.width(), size.height(), GL_RGBA};
|
||||
|
||||
int flip = 1;
|
||||
int block = 0;
|
||||
mpv_render_param params[]{
|
||||
{MPV_RENDER_PARAM_OPENGL_FBO, &fbo},
|
||||
{MPV_RENDER_PARAM_FLIP_Y, &flip},
|
||||
{MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block},
|
||||
{MPV_RENDER_PARAM_INVALID, nullptr},
|
||||
};
|
||||
|
||||
mpv_render_context_render(m_mpvRender, params);
|
||||
}
|
||||
|
||||
void WallpaperService::createBlackTexture(uint32_t& texture) {
|
||||
if (texture != 0) return;
|
||||
|
||||
const unsigned char pixel[4] = {0, 0, 0, 255};
|
||||
|
||||
glGenTextures(1, &texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexImage2D(
|
||||
GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixel);
|
||||
}
|
||||
|
||||
void WallpaperService::start() {
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(m_mutex);
|
||||
@@ -260,11 +662,7 @@ void WallpaperService::wake() {
|
||||
}
|
||||
|
||||
void WallpaperService::queue(WallpaperSurface* surface) {
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(m_mutex);
|
||||
m_pending.push_back(surface);
|
||||
}
|
||||
|
||||
surface->markDirty();
|
||||
wake();
|
||||
}
|
||||
|
||||
@@ -273,7 +671,7 @@ void WallpaperService::queueAll() {
|
||||
std::lock_guard<std::recursive_mutex> lock(m_mutex);
|
||||
|
||||
for (auto* surface : m_surfaces)
|
||||
m_pending.push_back(surface);
|
||||
surface->markDirty();
|
||||
}
|
||||
|
||||
wake();
|
||||
@@ -323,13 +721,50 @@ void WallpaperService::present() {
|
||||
|
||||
ensureDecodeSize();
|
||||
|
||||
std::vector<WallpaperSurface*> pending;
|
||||
QList<WallpaperSurface*> surfaces;
|
||||
bool fading = false;
|
||||
bool videoReload = false;
|
||||
QString path;
|
||||
bool isVideo = false;
|
||||
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(m_mutex);
|
||||
pending.swap(m_pending);
|
||||
|
||||
if (m_videoReload) {
|
||||
m_videoReload = false;
|
||||
videoReload = true;
|
||||
path = m_path;
|
||||
isVideo = m_isVideo;
|
||||
}
|
||||
|
||||
fading = m_fading;
|
||||
const bool newVideoFrame =
|
||||
m_isVideo &&
|
||||
m_mpvFrameReady.exchange(false, std::memory_order_relaxed);
|
||||
|
||||
if (fading || newVideoFrame)
|
||||
for (auto* surface : m_surfaces)
|
||||
surface->markDirty();
|
||||
|
||||
surfaces = m_surfaces;
|
||||
}
|
||||
|
||||
for (auto* surface : pending)
|
||||
if (m_context == EGL_NO_CONTEXT) return;
|
||||
|
||||
eglMakeCurrent(m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context);
|
||||
|
||||
if (videoReload) {
|
||||
if (isVideo)
|
||||
startVideo(path);
|
||||
else if (m_mpv && !m_previousVideo)
|
||||
teardownMpv();
|
||||
}
|
||||
|
||||
const bool videoActive = (m_isVideo || m_previousVideo) && m_mpv != nullptr;
|
||||
|
||||
if (videoActive) pumpMpvEvents();
|
||||
|
||||
for (auto* surface : surfaces)
|
||||
drawSurface(surface);
|
||||
}
|
||||
|
||||
@@ -494,7 +929,8 @@ uint32_t WallpaperService::createProgram() {
|
||||
logShader(program, true);
|
||||
|
||||
if (glGetUniformLocation(program, "size") < 0 ||
|
||||
glGetUniformLocation(program, "crop") < 0) {
|
||||
glGetUniformLocation(program, "crop") < 0 ||
|
||||
glGetUniformLocation(program, "alpha") < 0) {
|
||||
qWarning() << "WallpaperService: the program does not have the "
|
||||
"uniforms it needs";
|
||||
glDeleteProgram(program);
|
||||
@@ -503,22 +939,23 @@ uint32_t WallpaperService::createProgram() {
|
||||
|
||||
m_sizeUniform = glGetUniformLocation(program, "size");
|
||||
m_cropUniform = glGetUniformLocation(program, "crop");
|
||||
m_alphaUniform = glGetUniformLocation(program, "alpha");
|
||||
|
||||
glDeleteShader(vertex);
|
||||
glDeleteShader(fragment);
|
||||
return program;
|
||||
}
|
||||
|
||||
void WallpaperService::uploadImage() {
|
||||
if (m_image.isNull()) return;
|
||||
void WallpaperService::uploadTexture(uint32_t& texture, const QImage& image) {
|
||||
if (image.isNull()) return;
|
||||
|
||||
if (m_texture == 0) {
|
||||
glGenTextures(1, &m_texture);
|
||||
glBindTexture(GL_TEXTURE_2D, m_texture);
|
||||
if (texture == 0) {
|
||||
glGenTextures(1, &texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
} else {
|
||||
glBindTexture(GL_TEXTURE_2D, m_texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
}
|
||||
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
@@ -526,12 +963,35 @@ void WallpaperService::uploadImage() {
|
||||
GL_TEXTURE_2D,
|
||||
0,
|
||||
GL_RGBA8,
|
||||
m_image.width(),
|
||||
m_image.height(),
|
||||
image.width(),
|
||||
image.height(),
|
||||
0,
|
||||
GL_RGBA,
|
||||
GL_UNSIGNED_BYTE,
|
||||
m_image.constBits());
|
||||
image.constBits());
|
||||
}
|
||||
|
||||
void WallpaperService::uploadImage() {
|
||||
uploadTexture(m_texture, m_image);
|
||||
}
|
||||
|
||||
void WallpaperService::drawTexture(
|
||||
uint32_t texture,
|
||||
const QRectF& rect,
|
||||
float sizeW,
|
||||
float sizeH,
|
||||
float alpha) {
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glUniform2f(GLint(m_sizeUniform), sizeW, sizeH);
|
||||
glUniform4f(
|
||||
GLint(m_cropUniform),
|
||||
GLfloat(rect.x()),
|
||||
GLfloat(rect.y()),
|
||||
GLfloat(rect.width()),
|
||||
GLfloat(rect.height()));
|
||||
glUniform1f(GLint(m_alphaUniform), alpha);
|
||||
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
}
|
||||
|
||||
bool WallpaperService::decode(const QString& path) {
|
||||
@@ -564,6 +1024,8 @@ bool WallpaperService::ensureDecodeSize() {
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(m_mutex);
|
||||
|
||||
if (m_isVideo) return false;
|
||||
|
||||
largest = largestBufferSize();
|
||||
if (largest.isEmpty()) return false;
|
||||
|
||||
@@ -611,7 +1073,10 @@ QSize WallpaperService::largestBufferSize() {
|
||||
}
|
||||
|
||||
QRectF WallpaperService::sourceRect(
|
||||
const QVariantMap& crops, const QString& screen, const QSize& size) const {
|
||||
const QVariantMap& crops,
|
||||
const QString& screen,
|
||||
const QSize& size,
|
||||
const QSize& image) const {
|
||||
QVariantMap crop = cropFor(crops, screen);
|
||||
|
||||
QRectF rect(
|
||||
@@ -620,10 +1085,10 @@ QRectF WallpaperService::sourceRect(
|
||||
crop.value("width", 1.0).toDouble(),
|
||||
crop.value("height", 1.0).toDouble());
|
||||
|
||||
if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect;
|
||||
if (size.isEmpty() || rect.isEmpty() || image.isEmpty()) return rect;
|
||||
|
||||
qreal imageWidth = qreal(m_image.width());
|
||||
qreal imageHeight = qreal(m_image.height());
|
||||
qreal imageWidth = qreal(image.width());
|
||||
qreal imageHeight = qreal(image.height());
|
||||
|
||||
QRectF pixels(
|
||||
rect.x() * imageWidth,
|
||||
@@ -652,6 +1117,8 @@ QRectF WallpaperService::sourceRect(
|
||||
}
|
||||
|
||||
void WallpaperService::drawSurface(WallpaperSurface* surface) {
|
||||
if (!surface->needsDraw() || !surface->frameReady()) return;
|
||||
|
||||
EGLSurface target = surface->eglSurface();
|
||||
if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE ||
|
||||
surface->bufferSize().isEmpty())
|
||||
@@ -662,9 +1129,8 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Release the context when this scope ends (see FrameService::drawSurface).
|
||||
// Holding it across the draw loop's sleep serializes the two raw-EGL clients on
|
||||
// NVIDIA, which makes eglSwapBuffers fail and starves other processes' contexts.
|
||||
eglSwapInterval(m_eglDisplay, 0);
|
||||
|
||||
struct ContextRelease {
|
||||
EGLDisplay display;
|
||||
~ContextRelease() {
|
||||
@@ -675,7 +1141,6 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) {
|
||||
|
||||
QVariantMap crops;
|
||||
QSize size;
|
||||
bool upload = false;
|
||||
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(m_mutex);
|
||||
@@ -683,38 +1148,153 @@ void WallpaperService::drawSurface(WallpaperSurface* surface) {
|
||||
crops = m_crops;
|
||||
size = surface->bufferSize();
|
||||
|
||||
upload = m_imageChanged;
|
||||
m_imageChanged = false;
|
||||
if (size.isEmpty()) return;
|
||||
|
||||
if (m_program == 0) m_program = createProgram();
|
||||
if (m_program == 0) return;
|
||||
|
||||
const bool videoNow = m_isVideo;
|
||||
|
||||
if (videoNow && !m_videoReady.load(std::memory_order_relaxed)) return;
|
||||
|
||||
const bool videoAsBackground =
|
||||
(videoNow || (m_previousVideo && m_fading)) && m_mpv != nullptr;
|
||||
|
||||
if (videoAsBackground) {
|
||||
if (!m_mpvRender) initMpvRender();
|
||||
if (!m_mpvRender) return;
|
||||
|
||||
glDisable(GL_BLEND);
|
||||
|
||||
glViewport(0, 0, size.width(), size.height());
|
||||
renderMpvToSurface(size);
|
||||
} else {
|
||||
glUseProgram(m_program);
|
||||
glViewport(0, 0, size.width(), size.height());
|
||||
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
}
|
||||
|
||||
if (!videoNow) {
|
||||
const bool upload = m_imageChanged;
|
||||
m_imageChanged = false;
|
||||
|
||||
if (m_texture == 0 || upload) uploadImage();
|
||||
|
||||
if (m_texture == 0 || m_image.isNull()) return;
|
||||
}
|
||||
|
||||
glUseProgram(m_program);
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
if (m_fading && !m_fadeClockStarted) {
|
||||
m_fadeClockStarted = true;
|
||||
m_fadeTimer.start();
|
||||
}
|
||||
|
||||
bool fade = false;
|
||||
bool fadeDone = false;
|
||||
qreal progress = 1.0;
|
||||
|
||||
if (m_fading) {
|
||||
progress = qreal(m_fadeTimer.elapsed()) / qreal(kFadeDurationMs);
|
||||
if (progress >= 1.0)
|
||||
fadeDone = true;
|
||||
else
|
||||
fade = true;
|
||||
}
|
||||
|
||||
const float w = float(size.width());
|
||||
const float h = float(size.height());
|
||||
|
||||
if (videoNow) {
|
||||
if (fade && !m_previousImage.isNull()) {
|
||||
if (m_previousTexture == 0)
|
||||
uploadTexture(m_previousTexture, m_previousImage);
|
||||
|
||||
if (m_previousTexture != 0) {
|
||||
const QRectF previousRect = sourceRect(
|
||||
m_previousCrops,
|
||||
surface->screen(),
|
||||
size,
|
||||
m_previousImage.size());
|
||||
|
||||
drawTexture(
|
||||
m_previousTexture,
|
||||
previousRect,
|
||||
w,
|
||||
h,
|
||||
1.0f - float(progress));
|
||||
}
|
||||
} else if (fade && !m_firstFadeDone) {
|
||||
if (m_blackTex == 0) createBlackTexture(m_blackTex);
|
||||
|
||||
if (m_blackTex != 0)
|
||||
drawTexture(
|
||||
m_blackTex,
|
||||
QRectF(0.0, 0.0, 1.0, 1.0),
|
||||
w,
|
||||
h,
|
||||
1.0f - float(progress));
|
||||
}
|
||||
} else {
|
||||
const QRectF rect =
|
||||
sourceRect(crops, surface->screen(), size, m_image.size());
|
||||
|
||||
if (fade) {
|
||||
if (!m_previousVideo && !m_previousImage.isNull() &&
|
||||
m_previousTexture == 0) {
|
||||
uploadTexture(m_previousTexture, m_previousImage);
|
||||
}
|
||||
|
||||
if (!m_previousVideo && m_previousTexture != 0) {
|
||||
const QRectF previousRect = sourceRect(
|
||||
m_previousCrops,
|
||||
surface->screen(),
|
||||
size,
|
||||
m_previousImage.size());
|
||||
|
||||
drawTexture(m_previousTexture, previousRect, w, h, 1.0f);
|
||||
}
|
||||
|
||||
drawTexture(m_texture, rect, w, h, float(progress));
|
||||
} else {
|
||||
drawTexture(m_texture, rect, w, h, 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
if (fadeDone) {
|
||||
m_fading = false;
|
||||
m_firstFadeDone = true;
|
||||
|
||||
if (m_previousVideo) {
|
||||
teardownMpv();
|
||||
m_previousVideo = false;
|
||||
}
|
||||
|
||||
if (m_previousTexture != 0) {
|
||||
glDeleteTextures(1, &m_previousTexture);
|
||||
m_previousTexture = 0;
|
||||
}
|
||||
|
||||
m_previousImage = QImage();
|
||||
}
|
||||
}
|
||||
|
||||
if (size.isEmpty()) return;
|
||||
surface->requestFrame();
|
||||
|
||||
if (m_program == 0) m_program = createProgram();
|
||||
if (m_texture == 0 || upload) uploadImage();
|
||||
|
||||
if (m_program == 0 || m_texture == 0 || m_image.isNull()) return;
|
||||
|
||||
glUseProgram(m_program);
|
||||
glBindTexture(GL_TEXTURE_2D, m_texture);
|
||||
|
||||
glUniform2f(
|
||||
GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height()));
|
||||
|
||||
QRectF rect = sourceRect(crops, surface->screen(), size);
|
||||
glUniform4f(
|
||||
GLint(m_cropUniform),
|
||||
GLfloat(rect.x()),
|
||||
GLfloat(rect.y()),
|
||||
GLfloat(rect.width()),
|
||||
GLfloat(rect.height()));
|
||||
|
||||
glViewport(0, 0, size.width(), size.height());
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
|
||||
if (!eglSwapBuffers(m_eglDisplay, target))
|
||||
if (!eglSwapBuffers(m_eglDisplay, target)) {
|
||||
qWarning()
|
||||
<< "WallpaperService: eglSwapBuffers failed, eglGetError = 0x"
|
||||
<< QString::number(unsigned(eglGetError()), 16);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((m_isVideo || m_previousVideo) && m_mpvRender)
|
||||
mpv_render_context_report_swap(m_mpvRender);
|
||||
|
||||
surface->endDraw();
|
||||
}
|
||||
|
||||
void WallpaperService::addSurface(const QString& screen) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#include <QImage>
|
||||
#include <QList>
|
||||
#include <QElapsedTimer>
|
||||
#include <QRectF>
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
@@ -16,12 +17,18 @@
|
||||
#include <QVariantMap>
|
||||
#include <QtQml/qqml.h>
|
||||
#include <qtmetamacros.h>
|
||||
#include <atomic>
|
||||
|
||||
#include <qwayland-wlr-layer-shell-unstable-v1.h>
|
||||
#include <wayland-client-protocol.h>
|
||||
|
||||
#include "wallpapersurface.hpp"
|
||||
|
||||
struct mpv_handle;
|
||||
struct mpv_render_context;
|
||||
|
||||
class QProcess;
|
||||
|
||||
namespace ZShell::internal {
|
||||
|
||||
class WallpaperSurface;
|
||||
@@ -57,6 +64,29 @@ class WallpaperService : public QObject {
|
||||
private:
|
||||
friend class WallpaperSurface;
|
||||
|
||||
static bool isVideoPath(const QString& path);
|
||||
|
||||
static bool isHardwareCodec(const QString& codec);
|
||||
|
||||
[[nodiscard]] QString codecName(const QString& path) const;
|
||||
[[nodiscard]] QString cacheDir() const;
|
||||
[[nodiscard]] QString transcodeTarget(const QString& path) const;
|
||||
[[nodiscard]] bool needsTranscode(const QString& path) const;
|
||||
void transcodeVideo(const QString& src, const QString& dst);
|
||||
|
||||
void applyPath(const QString& path);
|
||||
|
||||
void startVideo(const QString& path);
|
||||
void stopVideo();
|
||||
void teardownMpv();
|
||||
|
||||
void initMpvRender();
|
||||
void renderMpvToSurface(const QSize& size);
|
||||
void pumpMpvEvents();
|
||||
|
||||
static void* getMpvProcAddress(void* ctx, const char* name);
|
||||
static void mpvUpdate(void* ctx);
|
||||
|
||||
void bindGlobals();
|
||||
static void registryGlobal(
|
||||
void* data,
|
||||
@@ -70,7 +100,16 @@ class WallpaperService : public QObject {
|
||||
[[nodiscard]] bool setupGl();
|
||||
[[nodiscard]] uint32_t createProgram();
|
||||
|
||||
void uploadTexture(uint32_t& texture, const QImage& image);
|
||||
void uploadImage();
|
||||
void createBlackTexture(uint32_t& texture);
|
||||
|
||||
void drawTexture(
|
||||
uint32_t texture,
|
||||
const QRectF& rect,
|
||||
float sizeW,
|
||||
float sizeH,
|
||||
float alpha);
|
||||
|
||||
bool decode(const QString& path);
|
||||
|
||||
@@ -95,7 +134,8 @@ class WallpaperService : public QObject {
|
||||
[[nodiscard]] QRectF sourceRect(
|
||||
const QVariantMap& crops,
|
||||
const QString& screen,
|
||||
const QSize& size) const;
|
||||
const QSize& size,
|
||||
const QSize& image) const;
|
||||
|
||||
void start();
|
||||
|
||||
@@ -108,6 +148,18 @@ class WallpaperService : public QObject {
|
||||
|
||||
QImage m_image;
|
||||
|
||||
bool m_isVideo = false;
|
||||
bool m_videoReload = false;
|
||||
bool m_previousVideo = false;
|
||||
mpv_handle* m_mpv = nullptr;
|
||||
mpv_render_context* m_mpvRender = nullptr;
|
||||
uint32_t m_blackTex = 0;
|
||||
std::atomic<bool> m_mpvFrameReady{false};
|
||||
std::atomic<bool> m_videoReady{false};
|
||||
std::atomic<bool> m_videoLoaded{false};
|
||||
|
||||
QProcess* m_transcode = nullptr;
|
||||
|
||||
QSize m_sourceSize;
|
||||
QSize m_decodeSize;
|
||||
|
||||
@@ -125,12 +177,20 @@ class WallpaperService : public QObject {
|
||||
uint32_t m_program = 0;
|
||||
int m_sizeUniform = -1;
|
||||
int m_cropUniform = -1;
|
||||
int m_alphaUniform = -1;
|
||||
|
||||
QImage m_previousImage;
|
||||
QVariantMap m_previousCrops;
|
||||
uint32_t m_previousTexture = 0;
|
||||
bool m_fading = false;
|
||||
bool m_fadeClockStarted = false;
|
||||
bool m_firstFadeDone = false;
|
||||
QElapsedTimer m_fadeTimer;
|
||||
|
||||
QList<WallpaperSurface*> m_surfaces;
|
||||
|
||||
std::thread m_thread;
|
||||
mutable std::recursive_mutex m_mutex;
|
||||
std::vector<WallpaperSurface*> m_pending;
|
||||
wl_event_queue* m_queue = nullptr;
|
||||
|
||||
int m_wake[2] = {-1, -1};
|
||||
|
||||
@@ -53,6 +53,11 @@ WallpaperSurface::WallpaperSurface(
|
||||
}
|
||||
|
||||
WallpaperSurface::~WallpaperSurface() {
|
||||
if (m_frameCallback) {
|
||||
wl_callback_destroy(m_frameCallback);
|
||||
m_frameCallback = nullptr;
|
||||
}
|
||||
|
||||
if (m_eglSurface != EGL_NO_SURFACE)
|
||||
eglDestroySurface(m_service->m_eglDisplay, m_eglSurface);
|
||||
|
||||
@@ -68,8 +73,45 @@ WallpaperSurface::~WallpaperSurface() {
|
||||
if (m_surface) wl_surface_destroy(m_surface);
|
||||
}
|
||||
|
||||
void WallpaperSurface::redraw() {
|
||||
m_service->queue(this);
|
||||
void WallpaperSurface::markDirty() {
|
||||
m_dirty.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool WallpaperSurface::needsDraw() const {
|
||||
return m_dirty.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool WallpaperSurface::frameReady() const {
|
||||
return m_frameReady.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void WallpaperSurface::requestFrame() {
|
||||
if (!m_surface || m_frameCallback) return;
|
||||
|
||||
m_frameCallback = wl_surface_frame(m_surface);
|
||||
|
||||
static const struct wl_callback_listener listener = {
|
||||
[](void* data, struct wl_callback*, uint32_t) {
|
||||
static_cast<WallpaperSurface*>(data)->onFrameDone();
|
||||
},
|
||||
};
|
||||
|
||||
wl_callback_add_listener(m_frameCallback, &listener, this);
|
||||
}
|
||||
|
||||
void WallpaperSurface::onFrameDone() {
|
||||
if (m_frameCallback) {
|
||||
wl_callback_destroy(m_frameCallback);
|
||||
m_frameCallback = nullptr;
|
||||
}
|
||||
|
||||
m_frameReady.store(true, std::memory_order_relaxed);
|
||||
m_service->wake();
|
||||
}
|
||||
|
||||
void WallpaperSurface::endDraw() {
|
||||
m_dirty.store(false, std::memory_order_relaxed);
|
||||
m_frameReady.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void WallpaperSurface::zwlr_layer_surface_v1_configure(
|
||||
@@ -141,6 +183,7 @@ void WallpaperSurface::updateBuffer() {
|
||||
if (QtWayland::wp_viewport::isInitialized())
|
||||
set_destination(m_size.width(), m_size.height());
|
||||
|
||||
requestFrame();
|
||||
m_service->queue(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <EGL/egl.h>
|
||||
#include <wayland-egl.h>
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
|
||||
@@ -32,7 +34,14 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
|
||||
[[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; }
|
||||
[[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; }
|
||||
|
||||
void redraw();
|
||||
void markDirty();
|
||||
[[nodiscard]] bool needsDraw() const;
|
||||
|
||||
[[nodiscard]] bool frameReady() const;
|
||||
|
||||
void requestFrame();
|
||||
|
||||
void endDraw();
|
||||
|
||||
private:
|
||||
void zwlr_layer_surface_v1_configure(
|
||||
@@ -42,6 +51,8 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
|
||||
|
||||
void updateBuffer();
|
||||
|
||||
void onFrameDone();
|
||||
|
||||
WallpaperService* m_service;
|
||||
QString m_screen;
|
||||
wl_surface* m_surface;
|
||||
@@ -53,6 +64,10 @@ class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
|
||||
qreal m_scale = 1.0;
|
||||
|
||||
bool configured = false;
|
||||
|
||||
std::atomic<bool> m_dirty{false};
|
||||
std::atomic<bool> m_frameReady{true};
|
||||
struct wl_callback* m_frameCallback = nullptr;
|
||||
};
|
||||
|
||||
} // namespace ZShell::internal
|
||||
|
||||
@@ -1,17 +1,183 @@
|
||||
#include "filesystemmodel.hpp"
|
||||
|
||||
#include <qcoreapplication.h>
|
||||
#include <qdatetime.h>
|
||||
#include <qdir.h>
|
||||
#include <qdiriterator.h>
|
||||
#include <qfile.h>
|
||||
#include <qfileinfo.h>
|
||||
#include <qfuturewatcher.h>
|
||||
#include <qimage.h>
|
||||
#include <qpointer.h>
|
||||
#include <qprocess.h>
|
||||
#include <qregularexpression.h>
|
||||
#include <qtemporarydir.h>
|
||||
#include <qtconcurrentrun.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace ZShell::models {
|
||||
|
||||
const QStringList kVideoExtensions = {
|
||||
"mp4",
|
||||
"mkv",
|
||||
"webm",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"m4v",
|
||||
"mpg",
|
||||
"mpeg",
|
||||
"3gp",
|
||||
"ogv",
|
||||
"ts",
|
||||
"vob",
|
||||
};
|
||||
|
||||
bool hasVideoExtension(const QString& path) {
|
||||
return kVideoExtensions.contains(QFileInfo(path).suffix().toLower());
|
||||
}
|
||||
|
||||
QString videoThumbnailPath(const QString& path) {
|
||||
const QFileInfo info(path);
|
||||
|
||||
QString key = QStringLiteral("%1_%2_%3")
|
||||
.arg(info.baseName())
|
||||
.arg(info.size())
|
||||
.arg(info.lastModified().toMSecsSinceEpoch() / 1000);
|
||||
key.replace(QRegularExpression("[^A-Za-z0-9._-]"), "_");
|
||||
|
||||
return QDir::homePath() + "/.cache/zshell/imagecache/video-previews/" +
|
||||
key + "_preview.jpg";
|
||||
}
|
||||
|
||||
bool generateVideoThumbnail(const QString& src, const QString& cachePath) {
|
||||
if (QFile::exists(cachePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const QString scale = QStringLiteral(
|
||||
"scale=1920:1080:force_original_aspect_ratio=decrease,"
|
||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2");
|
||||
|
||||
auto runProcess = [](const QString& program, const QStringList& args) {
|
||||
QProcess process;
|
||||
process.start(program, args);
|
||||
process.waitForFinished(15000);
|
||||
return qMakePair(process.exitCode(), process.readAllStandardOutput());
|
||||
};
|
||||
|
||||
double duration = 0.0;
|
||||
const auto probe = runProcess(
|
||||
"ffprobe",
|
||||
{
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=nw=1:nk=1",
|
||||
src,
|
||||
});
|
||||
if (probe.first == 0) {
|
||||
duration = QString::fromUtf8(probe.second).trimmed().toDouble();
|
||||
}
|
||||
|
||||
const int samples = duration > 0 ? std::min(10, std::max(4, int(duration)))
|
||||
: 8;
|
||||
|
||||
QTemporaryDir tmpDir;
|
||||
if (!tmpDir.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int bestIndex = -1;
|
||||
double bestChroma = -1.0;
|
||||
double bestLuma = -1.0;
|
||||
|
||||
for (int i = 0; i < samples; ++i) {
|
||||
const double frac = duration > 0 ? (i + 0.5) / samples : 0.0;
|
||||
const QString seek = duration > 0
|
||||
? QString::number(frac * duration, 'f', 3)
|
||||
: QStringLiteral("0");
|
||||
|
||||
const QString framePath =
|
||||
tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(i));
|
||||
|
||||
runProcess(
|
||||
"ffmpeg",
|
||||
{
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-ss",
|
||||
seek,
|
||||
"-i",
|
||||
src,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
scale,
|
||||
"-pix_fmt",
|
||||
"yuvj420p",
|
||||
"-q:v",
|
||||
"2",
|
||||
"-y",
|
||||
framePath,
|
||||
});
|
||||
|
||||
QImage frame(framePath);
|
||||
if (frame.isNull()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
frame = frame.convertToFormat(QImage::Format_RGB888);
|
||||
|
||||
double chroma = 0.0;
|
||||
double luma = 0.0;
|
||||
int count = 0;
|
||||
for (int y = 0; y < frame.height(); ++y) {
|
||||
const auto* line = frame.constScanLine(y);
|
||||
for (int x = 0; x < frame.width(); ++x) {
|
||||
const int r = line[x * 3];
|
||||
const int g = line[x * 3 + 1];
|
||||
const int b = line[x * 3 + 2];
|
||||
chroma += std::max({r, g, b}) - std::min({r, g, b});
|
||||
luma += r + g + b;
|
||||
++count;
|
||||
}
|
||||
}
|
||||
|
||||
const int n = std::max(1, count);
|
||||
chroma /= n;
|
||||
luma /= 3.0 * n;
|
||||
|
||||
if (chroma > bestChroma + 0.5 ||
|
||||
(chroma > bestChroma - 0.5 && luma > bestLuma)) {
|
||||
bestChroma = chroma;
|
||||
bestLuma = luma;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir().mkpath(QFileInfo(cachePath).absolutePath());
|
||||
return QFile::copy(
|
||||
tmpDir.filePath(QStringLiteral("frame_%1.jpg").arg(bestIndex)),
|
||||
cachePath);
|
||||
}
|
||||
|
||||
FileSystemEntry::FileSystemEntry(
|
||||
const QString& path, const QString& relativePath, QObject* parent)
|
||||
: QObject(parent)
|
||||
, m_fileInfo(path)
|
||||
, m_path(path)
|
||||
, m_relativePath(relativePath)
|
||||
, m_thumbnailPath(path)
|
||||
, m_isImageInitialised(false)
|
||||
, m_mimeTypeInitialised(false) {}
|
||||
|
||||
@@ -56,6 +222,23 @@ bool FileSystemEntry::isImage() const {
|
||||
return m_isImage;
|
||||
}
|
||||
|
||||
bool FileSystemEntry::isVideo() const {
|
||||
return hasVideoExtension(m_path);
|
||||
}
|
||||
|
||||
QString FileSystemEntry::thumbnailPath() const {
|
||||
return m_thumbnailPath;
|
||||
}
|
||||
|
||||
void FileSystemEntry::setThumbnailPath(const QString& path) {
|
||||
if (m_thumbnailPath == path) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_thumbnailPath = path;
|
||||
emit thumbnailPathChanged();
|
||||
}
|
||||
|
||||
QString FileSystemEntry::mimeType() const {
|
||||
if (!m_mimeTypeInitialised) {
|
||||
const QMimeDatabase db;
|
||||
@@ -318,11 +501,20 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
|
||||
|
||||
std::optional<QDirIterator> iter;
|
||||
|
||||
if (filter == Images) {
|
||||
if (filter == Images || filter == Videos || filter == Media) {
|
||||
QStringList extraNameFilters = nameFilters;
|
||||
const auto formats = QImageReader::supportedImageFormats();
|
||||
for (const auto& format : formats) {
|
||||
extraNameFilters << "*." + format;
|
||||
|
||||
if (filter == Images || filter == Media) {
|
||||
const auto formats = QImageReader::supportedImageFormats();
|
||||
for (const auto& format : formats) {
|
||||
extraNameFilters << "*." + format;
|
||||
}
|
||||
}
|
||||
|
||||
if (filter == Videos || filter == Media) {
|
||||
for (const auto& ext : kVideoExtensions) {
|
||||
extraNameFilters << "*." + ext;
|
||||
}
|
||||
}
|
||||
|
||||
QDir::Filters filters = QDir::Files;
|
||||
@@ -366,6 +558,13 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
|
||||
if (!reader.canRead()) {
|
||||
continue;
|
||||
}
|
||||
} else if (filter == Media) {
|
||||
if (!hasVideoExtension(path)) {
|
||||
QImageReader reader(path);
|
||||
if (!reader.canRead()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newPaths.insert(path);
|
||||
@@ -409,6 +608,30 @@ void FileSystemModel::updateEntriesForDir(const QString& dir) {
|
||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
}
|
||||
|
||||
void FileSystemModel::startThumbnailGeneration(FileSystemEntry* entry) {
|
||||
const QPointer<FileSystemEntry> guard(entry);
|
||||
const QString source = entry->path();
|
||||
const QString cachePath = videoThumbnailPath(source);
|
||||
|
||||
const auto future = QtConcurrent::run([source, cachePath]() {
|
||||
return generateVideoThumbnail(source, cachePath);
|
||||
});
|
||||
|
||||
const auto watcher = new QFutureWatcher<bool>(this);
|
||||
connect(
|
||||
watcher,
|
||||
&QFutureWatcher<bool>::finished,
|
||||
this,
|
||||
[guard, cachePath, watcher]() {
|
||||
if (guard && watcher->result()) {
|
||||
guard->setThumbnailPath(cachePath);
|
||||
}
|
||||
watcher->deleteLater();
|
||||
});
|
||||
watcher->setFuture(future);
|
||||
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
|
||||
}
|
||||
|
||||
void FileSystemModel::applyChanges(
|
||||
const QSet<QString>& removedPaths, const QSet<QString>& addedPaths) {
|
||||
QList<int> removedIndices;
|
||||
@@ -450,8 +673,12 @@ void FileSystemModel::applyChanges(
|
||||
// Create new entries
|
||||
QList<FileSystemEntry*> newEntries;
|
||||
for (const auto& path : addedPaths) {
|
||||
newEntries << new FileSystemEntry(
|
||||
path, m_dir.relativeFilePath(path), this);
|
||||
auto* entry =
|
||||
new FileSystemEntry(path, m_dir.relativeFilePath(path), this);
|
||||
if (entry->isVideo()) {
|
||||
startThumbnailGeneration(entry);
|
||||
}
|
||||
newEntries << entry;
|
||||
}
|
||||
std::sort(
|
||||
newEntries.begin(),
|
||||
|
||||
@@ -28,6 +28,9 @@ class FileSystemEntry : public QObject {
|
||||
Q_PROPERTY(qint64 size READ size CONSTANT)
|
||||
Q_PROPERTY(bool isDir READ isDir CONSTANT)
|
||||
Q_PROPERTY(bool isImage READ isImage CONSTANT)
|
||||
Q_PROPERTY(bool isVideo READ isVideo CONSTANT)
|
||||
Q_PROPERTY(
|
||||
QString thumbnailPath READ thumbnailPath NOTIFY thumbnailPathChanged)
|
||||
Q_PROPERTY(QString mimeType READ mimeType CONSTANT)
|
||||
|
||||
public:
|
||||
@@ -45,18 +48,24 @@ class FileSystemEntry : public QObject {
|
||||
[[nodiscard]] qint64 size() const;
|
||||
[[nodiscard]] bool isDir() const;
|
||||
[[nodiscard]] bool isImage() const;
|
||||
[[nodiscard]] bool isVideo() const;
|
||||
[[nodiscard]] QString thumbnailPath() const;
|
||||
[[nodiscard]] QString mimeType() const;
|
||||
|
||||
void setThumbnailPath(const QString& path);
|
||||
|
||||
void updateRelativePath(const QDir& dir);
|
||||
|
||||
signals:
|
||||
void relativePathChanged();
|
||||
void thumbnailPathChanged();
|
||||
|
||||
private:
|
||||
const QFileInfo m_fileInfo;
|
||||
|
||||
const QString m_path;
|
||||
QString m_relativePath;
|
||||
QString m_thumbnailPath;
|
||||
|
||||
mutable bool m_isImage;
|
||||
mutable bool m_isImageInitialised;
|
||||
@@ -91,7 +100,7 @@ class FileSystemModel : public QAbstractListModel {
|
||||
NOTIFY entriesChanged)
|
||||
|
||||
public:
|
||||
enum Filter { NoFilter, Images, Files, Dirs };
|
||||
enum Filter { NoFilter, Images, Videos, Media, Files, Dirs };
|
||||
Q_ENUM(Filter)
|
||||
|
||||
explicit FileSystemModel(QObject* parent = nullptr);
|
||||
@@ -156,6 +165,7 @@ class FileSystemModel : public QAbstractListModel {
|
||||
void updateEntriesForDir(const QString& dir);
|
||||
void applyChanges(
|
||||
const QSet<QString>& removedPaths, const QSet<QString>& addedPaths);
|
||||
void startThumbnailGeneration(FileSystemEntry* entry);
|
||||
[[nodiscard]] bool compareEntries(
|
||||
const FileSystemEntry* a, const FileSystemEntry* b) const;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -31,6 +32,23 @@ from zshell.utils.schemepalettes import (
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
VIDEO_EXTENSIONS = {
|
||||
".mp4",
|
||||
".mkv",
|
||||
".webm",
|
||||
".avi",
|
||||
".mov",
|
||||
".wmv",
|
||||
".flv",
|
||||
".m4v",
|
||||
".mpg",
|
||||
".mpeg",
|
||||
".3gp",
|
||||
".ogv",
|
||||
".ts",
|
||||
".vob",
|
||||
}
|
||||
|
||||
|
||||
def _complete_scheme_name(incomplete):
|
||||
schemes = [
|
||||
@@ -351,6 +369,109 @@ def generate(
|
||||
safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key)
|
||||
return thumb_dir / f"{safe_key}_thumbnail.jpg"
|
||||
|
||||
def is_video(path: Path) -> bool:
|
||||
return path.suffix.lower() in VIDEO_EXTENSIONS
|
||||
|
||||
def video_duration(path: Path) -> float:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=nw=1:nk=1",
|
||||
str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return float(out.stdout.strip())
|
||||
except (ValueError, OSError):
|
||||
return 0.0
|
||||
|
||||
def video_frame_chroma(path: Path) -> tuple[float, float]:
|
||||
"""Return (mean chroma, mean luminance) of a frame.
|
||||
|
||||
Mean chroma (mean of max-min across RGB channels) is ~0 for black,
|
||||
white and grey frames, and rises with saturation - so it identifies a
|
||||
"good", colorful frame and rejects black or fade frames.
|
||||
"""
|
||||
with Image.open(path) as im:
|
||||
im = im.convert("RGB")
|
||||
pixels = list(im.getdata())
|
||||
|
||||
chroma = 0
|
||||
luma = 0
|
||||
for r, g, b in pixels:
|
||||
chroma += max(r, g, b) - min(r, g, b)
|
||||
luma += r + g + b
|
||||
|
||||
n = max(1, len(pixels))
|
||||
return chroma / n, luma / (3 * n)
|
||||
|
||||
def generate_video_thumbnail(
|
||||
video_path: Path, cache_path: Path, size=(128, 128)
|
||||
) -> None:
|
||||
w, h = size
|
||||
duration = video_duration(video_path)
|
||||
|
||||
samples = 8 if duration <= 0 else min(10, max(4, int(duration)))
|
||||
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="zshell-vthumb-"))
|
||||
try:
|
||||
best: Path | None = None
|
||||
best_key: tuple[float, float] = (-1.0, -1.0)
|
||||
|
||||
for i in range(samples):
|
||||
frac = (i + 0.5) / samples if duration > 0 else 0.0
|
||||
seek = f"{frac * duration:.3f}" if duration > 0 else "0"
|
||||
frame = tmp_dir / f"frame_{i}.jpg"
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-ss",
|
||||
seek,
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
f"scale={w}:{h}",
|
||||
"-pix_fmt",
|
||||
"yuvj420p",
|
||||
"-q:v",
|
||||
"2",
|
||||
"-y",
|
||||
str(frame),
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
if not frame.exists():
|
||||
continue
|
||||
|
||||
key = video_frame_chroma(frame)
|
||||
if key > best_key:
|
||||
best_key = key
|
||||
best = frame
|
||||
|
||||
if best is None:
|
||||
raise RuntimeError(
|
||||
f"Could not extract a frame from {video_path}"
|
||||
)
|
||||
|
||||
shutil.copy(best, cache_path)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
def generate_thumbnail(
|
||||
image_path: Path, thumb_dir: Path, size=(128, 128)
|
||||
) -> Path:
|
||||
@@ -360,6 +481,10 @@ def generate(
|
||||
if cache_path.exists():
|
||||
return cache_path
|
||||
|
||||
if is_video(image_path):
|
||||
generate_video_thumbnail(image_path, cache_path, size)
|
||||
return cache_path
|
||||
|
||||
image = Image.open(image_path)
|
||||
image.draft("RGB", size)
|
||||
image = image.convert("RGB")
|
||||
|
||||
Reference in New Issue
Block a user