Browse Source

added hdr video support

UwU 3 days ago
parent
commit
44c64f48fa

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "we-manager",
-  "version": "0.7.1",
+  "version": "0.7.2",
   "type": "module",
   "description": "Wallpaper Engine workshop manager",
   "author": "UwU <260361707+WallpaperEngineLover@users.noreply.github.com>",

+ 8 - 0
src/main/services/lwe.service.ts

@@ -37,6 +37,7 @@ import {
 } from './config.service'
 import { DEFAULT_LWE_FPS, DEFAULT_LWE_REPO } from '@shared/constants'
 import { engineFlagArgs } from '@shared/engineFlags'
+import { imageAdjustmentArgs, imageAdjustmentLines, type ImageAdjustments } from '@shared/imageAdjustments'
 
 export const ALL_SCREENS: ScreenTarget = '*'
 
@@ -855,6 +856,7 @@ export interface LweLaunchOptions {
   disableParallax?: boolean
   expandCanvas?: boolean
   cornerColor?: string
+  imageAdjustments?: ImageAdjustments
   speed?: number
   audioSensitivity?: Record<string, number>
   soundVolume?: Record<string, number>
@@ -875,6 +877,9 @@ function buildLweArgs(
   const assetsDir = findWeAssetsDir()
   if (assetsDir) args.push('--assets-dir', assetsDir)
 
+  // given before any --screen-root they are the default of every screen this engine draws
+  if (supportsFlag('--image-filter')) args.push(...imageAdjustmentArgs(options.imageAdjustments))
+
   // --screen-root uses layer-shell (Wayland) or root-window overlay (X11)
   if (capture) {
     args.push('--window', capture.window)
@@ -1189,6 +1194,7 @@ export interface HotswapOptions {
   disableParallax?: boolean
   expandCanvas?: boolean
   cornerColor?: string
+  imageAdjustments?: ImageAdjustments
   speed?: number
   propertyOverrides?: Record<string, string>
   /** Empty string clears the restriction back to "every screen can produce audio" */
@@ -1225,6 +1231,7 @@ export function buildHotswapLines(options: HotswapOptions): string[] {
   if (options.disableParallax !== undefined) lines.push(`disable-parallax=${options.disableParallax ? 'on' : 'off'}`)
   if (options.expandCanvas !== undefined) lines.push(`expand-canvas=${options.expandCanvas ? 'on' : 'off'}`)
   if (options.cornerColor !== undefined) lines.push(`corner-color=${options.cornerColor}`)
+  lines.push(...imageAdjustmentLines(options.imageAdjustments))
   if (options.speed !== undefined) lines.push(`speed=${options.speed}`)
   if (options.audioScreen !== undefined) lines.push(`audio-screen=${options.audioScreen}`)
   if (options.ambientVolume !== undefined) lines.push(`ambient-volume=${options.ambientVolume}`)
@@ -1321,6 +1328,7 @@ function hotswapOptionsFor(wallpaperPath: string, options: LweLaunchOptions): Ho
     disableParallax: options.disableParallax ?? false,
     expandCanvas: options.expandCanvas ?? false,
     cornerColor: options.cornerColor ?? '000000',
+    imageAdjustments: options.imageAdjustments,
     speed: options.speed ?? 1,
     propertyOverrides: options.propertyOverrides ?? {},
     audioSensitivity: {

+ 1 - 0
src/main/services/wallpaper.service.ts

@@ -238,6 +238,7 @@ export async function lweOptionsFor(
     // Explicit even when off, same reasoning as xrayFullReveal above.
     expandCanvas: wallpaper.expandCanvas ?? false,
     cornerColor: wallpaper.cornerColor,
+    imageAdjustments: wallpaper.imageAdjustments,
     speed: wallpaper.playbackSpeed,
     audioSensitivity: wallpaper.audioSensitivity,
     soundVolume: wallpaper.soundVolume,

+ 2 - 0
src/preload/index.ts

@@ -26,6 +26,7 @@ import type {
   PlaylistPlaybackState,
   SteamIdentity,
   EngineFlags,
+  ImageAdjustments,
   EngineFlagPreset,
   DisplayMode,
   ScreenTarget,
@@ -309,6 +310,7 @@ const api = {
       disableParallax?: boolean
       expandCanvas?: boolean
       cornerColor?: string
+      imageAdjustments?: ImageAdjustments
       speed?: number
       propertyOverrides?: Record<string, string>
       audioScreen?: string

+ 13 - 0
src/renderer/src/components/common/DetailSidebar.tsx

@@ -46,6 +46,7 @@ import { useToast } from './Toast'
 import ScreenSelect from './ScreenSelect'
 import { getPreviewSrc } from '../../utils/preview'
 import EngineFlagsSection from './sidebar/EngineFlagsSection'
+import ImageAdjustmentsSection from './sidebar/ImageAdjustmentsSection'
 import CompatSection from './sidebar/CompatSection'
 import ThumbnailSection from './sidebar/ThumbnailSection'
 import { ShortcutRow } from './ShortcutRow'
@@ -57,6 +58,7 @@ import type {
   LweAudioObject,
   WallpaperMeta,
   ScalingMode,
+  ImageAdjustments,
   ScreenTarget
 } from '@shared/types'
 
@@ -561,6 +563,7 @@ export default function DetailSidebar({
             disableParallax: patch.disableParallax,
             expandCanvas: patch.expandCanvas,
             cornerColor: patch.cornerColor,
+            imageAdjustments: patch.imageAdjustments,
             speed: patch.playbackSpeed,
             propertyOverrides: patch.propertyOverrides,
             audioSensitivity: patch.audioSensitivity,
@@ -592,6 +595,7 @@ export default function DetailSidebar({
     offsetX?: number
     offsetY?: number
     speed?: number
+    imageAdjustments?: ImageAdjustments
   }) {
     if (!isActive) return
     if (livePreviewTimer.current) clearTimeout(livePreviewTimer.current)
@@ -1268,6 +1272,15 @@ export default function DetailSidebar({
           </div>
         )}
 
+        {localPath && lweInstalled && (libraryMeta?.type === 'scene' || libraryMeta?.type === 'video') && (
+          <ImageAdjustmentsSection
+            adjustments={libraryMeta.imageAdjustments}
+            live={isActive}
+            onCommit={(imageAdjustments) => persistAndMaybeRelaunch({ imageAdjustments })}
+            onPreview={(imageAdjustments) => previewLive({ imageAdjustments })}
+          />
+        )}
+
         {localPath && lweInstalled && libraryMeta?.type && libraryMeta.type !== 'application' && (
           <div className="border-t border-white/5 pt-3">
             <label className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">

+ 146 - 0
src/renderer/src/components/common/sidebar/ImageAdjustmentsSection.tsx

@@ -0,0 +1,146 @@
+import { useState } from 'react'
+import { Loader2, RotateCcw, SlidersHorizontal } from 'lucide-react'
+import type { ImageAdjustments } from '@shared/types'
+import { IMAGE_FILTERS, NEUTRAL_COLOR_VALUE, hasImageAdjustments } from '@shared/imageAdjustments'
+
+interface Props {
+  adjustments?: ImageAdjustments
+  live: boolean
+  onCommit: (adjustments: ImageAdjustments | undefined) => Promise<void>
+  onPreview: (adjustments: ImageAdjustments) => void
+}
+
+type SliderKey = 'filterStrength' | 'brightness' | 'contrast' | 'saturation' | 'hue'
+
+const COLOR_SLIDERS: { key: SliderKey; label: string }[] = [
+  { key: 'brightness', label: 'Brightness' },
+  { key: 'contrast', label: 'Contrast' },
+  { key: 'saturation', label: 'Saturation' },
+  { key: 'hue', label: 'Hue shift' }
+]
+
+const selectClass = 'w-full rounded bg-white/5 px-1.5 py-1 text-xs text-gray-200 outline-none disabled:opacity-50'
+
+export default function ImageAdjustmentsSection({ adjustments = {}, live, onCommit, onPreview }: Props) {
+  const [draft, setDraft] = useState<ImageAdjustments | null>(null)
+  const [busy, setBusy] = useState(false)
+  const current = draft ?? adjustments
+
+  async function commit(next: ImageAdjustments) {
+    setBusy(true)
+    try {
+      await onCommit(hasImageAdjustments(next) ? next : undefined)
+    } finally {
+      setDraft(null)
+      setBusy(false)
+    }
+  }
+
+  function slide(key: SliderKey, value: number) {
+    const next = { ...current, [key]: value }
+    setDraft(next)
+    onPreview(next)
+  }
+
+  function slider(key: SliderKey, label: string, fallback: number) {
+    const value = current[key] ?? fallback
+    return (
+      <div key={key}>
+        <div className="flex items-center text-[11px] text-gray-400">
+          <span className="flex-1">{label}</span>
+          <span>{value}</span>
+        </div>
+        <input
+          type="range"
+          min={0}
+          max={100}
+          value={value}
+          disabled={busy}
+          onChange={(e) => slide(key, Number(e.target.value))}
+          onMouseUp={() => draft && commit(draft)}
+          onTouchEnd={() => draft && commit(draft)}
+          onKeyUp={() => draft && commit(draft)}
+          className="w-full accent-indigo-500 disabled:opacity-50"
+        />
+      </div>
+    )
+  }
+
+  const colorsChanged = COLOR_SLIDERS.some(({ key }) => (current[key] ?? NEUTRAL_COLOR_VALUE) !== NEUTRAL_COLOR_VALUE)
+
+  return (
+    <div className="border-t border-white/5 pt-3">
+      <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
+        <SlidersHorizontal size={12} />
+        <span className="flex-1 text-left">Image</span>
+        {live && <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">live</span>}
+        {busy && <Loader2 size={11} className="animate-spin" />}
+      </div>
+
+      <div className="space-y-2">
+        <label className="block text-[11px] text-gray-400">
+          Image filter
+          <select
+            value={current.filter ?? ''}
+            disabled={busy}
+            onChange={(e) => commit({ ...current, filter: e.target.value })}
+            className={selectClass}
+          >
+            <option value="">None</option>
+            {IMAGE_FILTERS.map((f, i) => (
+              <option key={f.value} value={f.value}>
+                {i + 1} {f.label}
+              </option>
+            ))}
+          </select>
+        </label>
+
+        {current.filter && slider('filterStrength', 'Filter strength', 100)}
+
+        <label className="flex items-center gap-2 text-[11px] text-gray-400">
+          <input
+            type="checkbox"
+            checked={current.colorEnabled ?? false}
+            disabled={busy}
+            onChange={(e) => commit({ ...current, colorEnabled: e.target.checked })}
+            className="accent-indigo-500"
+          />
+          <span className="flex-1">Color options</span>
+          {current.colorEnabled && colorsChanged && (
+            <button
+              disabled={busy}
+              onClick={(e) => {
+                e.preventDefault()
+                // explicit values, a running engine keeps whatever a request leaves out
+                void commit({
+                  ...current,
+                  brightness: NEUTRAL_COLOR_VALUE,
+                  contrast: NEUTRAL_COLOR_VALUE,
+                  saturation: NEUTRAL_COLOR_VALUE,
+                  hue: NEUTRAL_COLOR_VALUE
+                })
+              }}
+              title="Back to the original colors"
+              className="text-gray-500 hover:text-gray-300 disabled:opacity-50"
+            >
+              <RotateCcw size={11} />
+            </button>
+          )}
+        </label>
+
+        {current.colorEnabled && COLOR_SLIDERS.map(({ key, label }) => slider(key, label, NEUTRAL_COLOR_VALUE))}
+
+        <label className="flex items-center gap-2 text-[11px] text-gray-400">
+          <input
+            type="checkbox"
+            checked={current.flip ?? false}
+            disabled={busy}
+            onChange={(e) => commit({ ...current, flip: e.target.checked })}
+            className="accent-indigo-500"
+          />
+          Flip horizontally
+        </label>
+      </div>
+    </div>
+  )
+}

+ 6 - 1
src/renderer/src/constants/engineFlags.ts

@@ -8,7 +8,12 @@ export const BOOLEAN_FLAGS: { key: BooleanFlag; label: string; hint: string }[]
   { key: 'disableMouse', label: 'Ignore the mouse', hint: 'No parallax or cursor effects' },
   { key: 'noAudioProcessing', label: 'No audio visualizer', hint: 'Stops capturing system audio for audio-reactive wallpapers' },
   { key: 'automute', label: 'Mute when other apps play sound', hint: '' },
-  { key: 'silent', label: 'Always muted', hint: '' }
+  { key: 'silent', label: 'Always muted', hint: '' },
+  {
+    key: 'hdr',
+    label: 'HDR output',
+    hint: 'Sends HDR to monitors running in HDR mode (KDE Plasma with HDR enabled); HDR videos keep their highlights'
+  }
 ]
 
 export const FULLSCREEN_PAUSE_OPTIONS: { value: FullscreenPauseMode; label: string }[] = [

+ 1 - 0
src/shared/engineFlags.test.ts

@@ -18,6 +18,7 @@ describe('engineFlagArgs', () => {
     ).toEqual(['--disable-particles', '--fullscreen-pause-only-active', '--silent'])
     expect(engineFlagArgs({ fullscreenPause: 'off' })).toEqual(['--no-fullscreen-pause'])
     expect(engineFlagArgs({ fullscreenPause: 'default' })).toEqual([])
+    expect(engineFlagArgs({ hdr: true })).toEqual(['--hdr'])
   })
 
   it('leaves disableAnimations to the caller, it needs its own capability probe', () => {

+ 1 - 0
src/shared/engineFlags.ts

@@ -54,6 +54,7 @@ export function engineFlagArgs(flags: EngineFlags): string[] {
   if (flags.fullscreenPause === 'active-only') args.push('--fullscreen-pause-only-active')
   if (flags.automute) args.push('--automute')
   if (flags.silent) args.push('--silent')
+  if (flags.hdr) args.push('--hdr')
   return args
 }
 

+ 33 - 0
src/shared/imageAdjustments.test.ts

@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import { hasImageAdjustments, imageAdjustmentArgs, imageAdjustmentLines } from './imageAdjustments'
+
+describe('imageAdjustmentArgs', () => {
+  it('leaves unset fields out', () => {
+    expect(imageAdjustmentArgs(undefined)).toEqual([])
+    expect(imageAdjustmentArgs({ flip: true })).toEqual(['--flip', 'on'])
+  })
+
+  it('turns an empty filter into none', () => {
+    expect(imageAdjustmentArgs({ filter: '' })).toEqual(['--image-filter', 'none'])
+  })
+
+  it('puts the color switch after the sliders so it can turn them off', () => {
+    const args = imageAdjustmentArgs({ colorEnabled: false, brightness: 70 })
+    expect(args.indexOf('--color-options')).toBeGreaterThan(args.indexOf('--brightness'))
+  })
+})
+
+describe('imageAdjustmentLines', () => {
+  it('writes every set field', () => {
+    expect(
+      imageAdjustmentLines({ filter: 'lutx32_amber', filterStrength: 40, colorEnabled: true, hue: 60, flip: false })
+    ).toEqual(['image-filter=lutx32_amber', 'image-filter-strength=40', 'hue=60', 'color-options=on', 'flip=off'])
+  })
+})
+
+describe('hasImageAdjustments', () => {
+  it('is false for an empty object', () => {
+    expect(hasImageAdjustments({})).toBe(false)
+    expect(hasImageAdjustments({ filter: '' })).toBe(true)
+  })
+})

+ 81 - 0
src/shared/imageAdjustments.ts

@@ -0,0 +1,81 @@
+/**
+ * Wallpaper Engine's per-wallpaper image settings (its properties panel: image filter, color options, flip), in its
+ * slider units: brightness/contrast/saturation/hue 0-100 with 50 leaving the image as is, filter strength 0-100.
+ * Scene and video wallpapers only.
+ */
+export interface ImageAdjustments {
+  /** A lut from the engine's assets/materials/lut folder without the extension, '' for none */
+  filter?: string
+  filterStrength?: number
+  /** The four color sliders only apply while this is on */
+  colorEnabled?: boolean
+  brightness?: number
+  contrast?: number
+  saturation?: number
+  hue?: number
+  flip?: boolean
+}
+
+export const NEUTRAL_COLOR_VALUE = 50
+
+/** In Wallpaper Engine's own order, with its English names (ui_browse_lut_filter_*) */
+export const IMAGE_FILTERS: { value: string; label: string }[] = [
+  { value: 'k23_b', label: 'Vibrant Contrast' },
+  { value: 'lutx32_adventure', label: 'Vibrant Darkness' },
+  { value: 'lutx32_coloration', label: 'Color Boost' },
+  { value: 'simple_film', label: 'Shadow Boost' },
+  { value: 'lutx32_bluenavy', label: 'Moon Light' },
+  { value: '80s_post-apocalyptic_action', label: 'Late Night' },
+  { value: 'desert_4', label: 'Desert' },
+  { value: 'desperado', label: 'Midday Sun' },
+  { value: 'lutx32_dusk', label: 'Sunrise' },
+  { value: 'lutx32_honeyb', label: 'Honey' },
+  { value: 'lutx32_sandyskyd', label: 'Autumn' },
+  { value: 'lutx32_slate', label: 'Sepia Modern' },
+  { value: 'lutx32_westernf', label: 'Western' },
+  { value: 'setting_sun', label: 'Sunset' },
+  { value: 'tower', label: 'Color Crush' },
+  { value: 'lutx32_amber', label: 'Amber' },
+  { value: 'aliens_2', label: 'Toxic Green' },
+  { value: 'lutx32_daisy', label: 'Daisy' },
+  { value: 'lutx32_emeraldd', label: 'Emerald' },
+  { value: 'lutx32_ferne', label: 'Overcast' },
+  { value: 'lutx32_backsea', label: 'Blueshift' },
+  { value: 'lutx32_beach', label: 'Beach' },
+  { value: 'lutx32_studio', label: 'Studio Lighting' },
+  { value: 'sharp_wasteland', label: 'Wasteland' },
+  { value: 'gamebob_2', label: 'Retro Handheld' }
+]
+
+export function hasImageAdjustments(adjustments: ImageAdjustments | undefined): boolean {
+  return Object.values(adjustments ?? {}).some((v) => v !== undefined)
+}
+
+export function imageAdjustmentArgs(adjustments: ImageAdjustments | undefined): string[] {
+  const a = adjustments ?? {}
+  const args: string[] = []
+  if (a.filter !== undefined) args.push('--image-filter', a.filter === '' ? 'none' : a.filter)
+  if (a.filterStrength !== undefined) args.push('--image-filter-strength', String(a.filterStrength))
+  // the sliders turn the color options on by themselves, so the switch goes last to be able to turn them off
+  if (a.brightness !== undefined) args.push('--brightness', String(a.brightness))
+  if (a.contrast !== undefined) args.push('--contrast', String(a.contrast))
+  if (a.saturation !== undefined) args.push('--saturation', String(a.saturation))
+  if (a.hue !== undefined) args.push('--hue', String(a.hue))
+  if (a.colorEnabled !== undefined) args.push('--color-options', a.colorEnabled ? 'on' : 'off')
+  if (a.flip !== undefined) args.push('--flip', a.flip ? 'on' : 'off')
+  return args
+}
+
+export function imageAdjustmentLines(adjustments: ImageAdjustments | undefined): string[] {
+  const a = adjustments ?? {}
+  const lines: string[] = []
+  if (a.filter !== undefined) lines.push(`image-filter=${a.filter === '' ? 'none' : a.filter}`)
+  if (a.filterStrength !== undefined) lines.push(`image-filter-strength=${a.filterStrength}`)
+  if (a.brightness !== undefined) lines.push(`brightness=${a.brightness}`)
+  if (a.contrast !== undefined) lines.push(`contrast=${a.contrast}`)
+  if (a.saturation !== undefined) lines.push(`saturation=${a.saturation}`)
+  if (a.hue !== undefined) lines.push(`hue=${a.hue}`)
+  if (a.colorEnabled !== undefined) lines.push(`color-options=${a.colorEnabled ? 'on' : 'off'}`)
+  if (a.flip !== undefined) lines.push(`flip=${a.flip ? 'on' : 'off'}`)
+  return lines
+}

+ 7 - 0
src/shared/types.ts

@@ -1,3 +1,7 @@
+import type { ImageAdjustments } from './imageAdjustments'
+
+export type { ImageAdjustments } from './imageAdjustments'
+
 export type WallpaperType = 'scene' | 'video' | 'web' | 'application'
 
 /** Matches linux-wallpaperengine's --scaling choices */
@@ -75,6 +79,8 @@ export interface WallpaperMeta {
    * "RRGGBB"/"RRGGBBAA" hex string, applied via linux-wallpaperengine --corner-color. Default: black.
    */
   cornerColor?: string
+  /** Wallpaper Engine's image filter, color options and flip (scene and video only), applied live */
+  imageAdjustments?: ImageAdjustments
   /**
    * Global playback speed multiplier for animations/particles/effects, applied via
    * linux-wallpaperengine --speed (1 = normal speed, less than 1 = slower)
@@ -131,6 +137,7 @@ export interface EngineFlags {
   fullscreenPause?: FullscreenPauseMode
   automute?: boolean
   silent?: boolean
+  hdr?: boolean
 }
 
 export interface EngineFlagPreset {