diff --git a/windows/CHANGELOG.md b/windows/CHANGELOG.md index 416fcb8..98d5a70 100644 --- a/windows/CHANGELOG.md +++ b/windows/CHANGELOG.md @@ -4,12 +4,14 @@ ### 新增 +- Windows 系统托盘新增「壁纸透出」滑杆;100% 时视频保持原始画面,整页主题蒙层降为零,并随当前主题持久化;拖动滑杆不会重新传输整段视频。 - Windows 主题仓库与托盘支持本地 MP4/WebM 动态壁纸;视频静音循环、页面隐藏时暂停,并可像图片主题一样保存和切换。 - 视频采用上限 128 MB、单块 512 KiB 的 CDP 分块传输,在 renderer 内组装为 Blob URL,避免把完整视频塞进 early bootstrap payload;换主题、暂停和清理时会释放播放器与 Blob URL。 - MP4/WebM 导入会校验扩展名、最小容器签名、大小、受管目录边界和 reparse point,伪造扩展名或越界路径会在复制前拒绝。 ### 修复 +- 浅色与深色外观现在都为原生应用菜单提供稳定的半透明可读性层,并强制菜单文字与图标使用完整主题前景色;动态视频模式还会单独保护第二层 Codex 工具栏,避免画面逐帧变化导致顶栏内容失去对比度。 - 兼容新版 Codex 不再提供 `[role="main"]` 的任务页面:回退到 `main.main-surface` 承载任务背景,收起左侧边栏后也不会清除皮肤。 - 任务页回退模式保留原生固定顶栏,并移除顶栏半透明底色与主区顶部渐隐造成的横向断层。 - 托盘右键菜单允许从空的 `ToolStripItemCollection` 开始填充,避免首次展开时触发参数绑定异常。 diff --git a/windows/README.en.md b/windows/README.en.md index dabb1f5..5fdddce 100644 --- a/windows/README.en.md +++ b/windows/README.en.md @@ -66,6 +66,7 @@ Next, use the generated screenshot to check horizontal overflow and text contras Open `Codex Dream Skin - Tray` to: - Import a PNG, JPEG, or WebP background, or an MP4/WebM animated wallpaper. +- Adjust wallpaper reveal: at 100% the video stays at its original opacity while the full-window theme veil reaches zero; the value persists with the theme. - Save the active theme and switch through saved themes. - Pause or resume the skin. - Reapply the theme or fully restore Codex. diff --git a/windows/README.md b/windows/README.md index 7c0b6a7..f114c8b 100644 --- a/windows/README.md +++ b/windows/README.md @@ -66,6 +66,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-dream-s 打开 `Codex Dream Skin - Tray` 后可以: - 更换 PNG、JPEG、WebP 背景图,或 MP4、WebM 动态壁纸。 +- 使用「壁纸透出」滑杆调整覆盖在壁纸上的主题面板:100% 时视频保持原始画面,整页主题蒙层为零;设置会随主题持久化。 - 保存当前主题并从「已保存主题」切换。 - 暂停或继续显示皮肤。 - 重新应用主题,或完整恢复 Codex。 diff --git a/windows/assets/dream-skin.css b/windows/assets/dream-skin.css index 5a2f529..d3bad4f 100644 --- a/windows/assets/dream-skin.css +++ b/windows/assets/dream-skin.css @@ -15,17 +15,28 @@ --dream-accent-hover: color-mix(in oklab, var(--dream-accent) 20%, var(--dream-surface)); --dream-hero-shade: color-mix(in oklab, var(--dream-surface) 93%, transparent); --dream-ambient-opacity: .18; + --dream-wallpaper-reveal: 1; + --dream-wallpaper-cover: 0%; + --dream-media-overlay: color-mix(in oklab, var(--dream-canvas) var(--dream-wallpaper-cover), transparent); --dream-shadow: 0 16px 42px color-mix(in oklab, var(--dream-accent) 9%, transparent); - --dream-immersive-edge: color-mix(in oklab, var(--dream-surface) 46%, transparent); - --dream-immersive-mid: color-mix(in oklab, var(--dream-surface) 28%, transparent); - --dream-immersive-far: color-mix(in oklab, var(--dream-surface) 14%, transparent); - --dream-immersive-sidebar: color-mix(in oklab, var(--dream-sidebar) 50%, transparent); - --dream-task-immersive-sidebar: color-mix(in oklab, var(--dream-sidebar) 72%, transparent); + --dream-immersive-edge-base: color-mix(in oklab, var(--dream-surface) 46%, transparent); + --dream-immersive-mid-base: color-mix(in oklab, var(--dream-surface) 28%, transparent); + --dream-immersive-far-base: color-mix(in oklab, var(--dream-surface) 14%, transparent); + --dream-immersive-sidebar-base: color-mix(in oklab, var(--dream-sidebar) 50%, transparent); + --dream-task-immersive-sidebar-base: color-mix(in oklab, var(--dream-sidebar) 72%, transparent); + --dream-task-immersive-edge-base: color-mix(in oklab, var(--dream-surface) 86%, transparent); + --dream-task-immersive-mid-base: color-mix(in oklab, var(--dream-surface) 78%, transparent); + --dream-task-immersive-far-base: color-mix(in oklab, var(--dream-surface) 66%, transparent); + --dream-immersive-edge: color-mix(in oklab, var(--dream-immersive-edge-base) var(--dream-wallpaper-cover), transparent); + --dream-immersive-mid: color-mix(in oklab, var(--dream-immersive-mid-base) var(--dream-wallpaper-cover), transparent); + --dream-immersive-far: color-mix(in oklab, var(--dream-immersive-far-base) var(--dream-wallpaper-cover), transparent); + --dream-immersive-sidebar: color-mix(in oklab, var(--dream-immersive-sidebar-base) var(--dream-wallpaper-cover), transparent); + --dream-task-immersive-sidebar: color-mix(in oklab, var(--dream-task-immersive-sidebar-base) var(--dream-wallpaper-cover), transparent); + --dream-task-immersive-edge: color-mix(in oklab, var(--dream-task-immersive-edge-base) var(--dream-wallpaper-cover), transparent); + --dream-task-immersive-mid: color-mix(in oklab, var(--dream-task-immersive-mid-base) var(--dream-wallpaper-cover), transparent); + --dream-task-immersive-far: color-mix(in oklab, var(--dream-task-immersive-far-base) var(--dream-wallpaper-cover), transparent); --dream-immersive-composer: color-mix(in oklab, var(--dream-surface-raised) 76%, var(--dream-accent) 4%); --dream-immersive-line: color-mix(in oklab, var(--dream-line) 76%, transparent); - --dream-task-immersive-edge: color-mix(in oklab, var(--dream-surface) 86%, transparent); - --dream-task-immersive-mid: color-mix(in oklab, var(--dream-surface) 78%, transparent); - --dream-task-immersive-far: color-mix(in oklab, var(--dream-surface) 66%, transparent); } :root.codex-dream-skin.dream-theme-dark { @@ -43,16 +54,16 @@ --dream-hero-shade: color-mix(in oklab, var(--dream-surface) 91%, transparent); --dream-ambient-opacity: .22; --dream-shadow: 0 18px 48px color-mix(in oklab, oklch(0.06 0.01 245) 72%, transparent); - --dream-immersive-edge: color-mix(in oklab, var(--dream-surface) 43%, transparent); - --dream-immersive-mid: color-mix(in oklab, var(--dream-surface) 27%, transparent); - --dream-immersive-far: color-mix(in oklab, var(--dream-surface) 16%, transparent); - --dream-immersive-sidebar: color-mix(in oklab, var(--dream-sidebar) 48%, transparent); - --dream-task-immersive-sidebar: color-mix(in oklab, var(--dream-sidebar) 70%, transparent); + --dream-immersive-edge-base: color-mix(in oklab, var(--dream-surface) 43%, transparent); + --dream-immersive-mid-base: color-mix(in oklab, var(--dream-surface) 27%, transparent); + --dream-immersive-far-base: color-mix(in oklab, var(--dream-surface) 16%, transparent); + --dream-immersive-sidebar-base: color-mix(in oklab, var(--dream-sidebar) 48%, transparent); + --dream-task-immersive-sidebar-base: color-mix(in oklab, var(--dream-sidebar) 70%, transparent); + --dream-task-immersive-edge-base: color-mix(in oklab, var(--dream-surface) 82%, transparent); + --dream-task-immersive-mid-base: color-mix(in oklab, var(--dream-surface) 74%, transparent); + --dream-task-immersive-far-base: color-mix(in oklab, var(--dream-surface) 60%, transparent); --dream-immersive-composer: color-mix(in oklab, var(--dream-surface-raised) 88%, var(--dream-accent) 5%); --dream-immersive-line: color-mix(in oklab, var(--dream-line) 82%, transparent); - --dream-task-immersive-edge: color-mix(in oklab, var(--dream-surface) 82%, transparent); - --dream-task-immersive-mid: color-mix(in oklab, var(--dream-surface) 74%, transparent); - --dream-task-immersive-far: color-mix(in oklab, var(--dream-surface) 60%, transparent); } html.codex-dream-skin body { @@ -114,20 +125,28 @@ html.codex-dream-skin main.main-surface > header.app-header-tint { backdrop-filter: blur(12px) saturate(1.05) !important; } -/* Wide art also reaches the native application menu, which sits outside the - main surface and needs its own dark-theme legibility layer. */ -html.codex-dream-skin.dream-theme-dark [class~="group/application-menu-top-bar"] { +/* The native application menu sits outside the main surface. Give both shell + appearances a stable readability layer because media behind it can change + from bright to dark independently of Codex's light or dark preference. */ +html.codex-dream-skin [class~="group/application-menu-top-bar"] { color: var(--dream-text) !important; - background: color-mix(in oklab, var(--dream-sidebar) 90%, transparent) !important; + background: color-mix(in oklab, + color-mix(in oklab, var(--dream-sidebar) 88%, transparent) + var(--dream-wallpaper-cover), transparent) !important; border-bottom: 1px solid var(--dream-line-soft) !important; box-shadow: 0 1px 12px color-mix(in oklab, var(--dream-canvas) 72%, transparent) !important; - backdrop-filter: blur(16px) saturate(.9) !important; + backdrop-filter: blur(18px) saturate(.88) !important; +} + +html.codex-dream-skin.dream-art-video [class~="group/application-menu-top-bar"] { + background: color-mix(in oklab, + color-mix(in oklab, var(--dream-sidebar) 94%, transparent) + var(--dream-wallpaper-cover), transparent) !important; } -html.codex-dream-skin.dream-theme-dark [class~="group/application-menu-top-bar"] button, -html.codex-dream-skin.dream-theme-dark [class~="group/application-menu-top-bar"] svg { +html.codex-dream-skin [class~="group/application-menu-top-bar"] button, +html.codex-dream-skin [class~="group/application-menu-top-bar"] svg { color: var(--dream-text) !important; - text-shadow: 0 1px 2px color-mix(in oklab, var(--dream-canvas) 82%, transparent) !important; } #codex-dream-skin-chrome { @@ -144,6 +163,7 @@ html.codex-dream-skin.dream-theme-dark [class~="group/application-menu-top-bar"] border: 0; object-fit: cover; object-position: var(--dream-art-position); + opacity: 1; pointer-events: none; } @@ -170,7 +190,7 @@ html.codex-dream-skin .dream-task::before { z-index: 0; inset: 0; pointer-events: none; - opacity: var(--dream-ambient-opacity); + opacity: calc(var(--dream-ambient-opacity) * var(--dream-wallpaper-reveal)); background-image: var(--dream-art); background-repeat: no-repeat; background-position: var(--dream-art-position); @@ -192,7 +212,7 @@ html.codex-dream-skin.dream-art-wide:not(.dream-task-banner) .dream-task::before html.codex-dream-skin.dream-task-banner .dream-task::before { bottom: auto; height: min(46vh, 520px); - opacity: calc(var(--dream-ambient-opacity) * 1.8); + opacity: calc(var(--dream-ambient-opacity) * var(--dream-wallpaper-reveal) * 1.8); mask-image: linear-gradient(to bottom, oklch(0 0 0) 0, oklch(0 0 0 / .9) 52%, transparent 100%); } @@ -254,7 +274,9 @@ html.codex-dream-skin .dream-home { overflow: hidden !important; border: 1px solid var(--dream-line) !important; border-radius: 20px !important; - background-image: var(--dream-art) !important; + background-image: + linear-gradient(var(--dream-media-overlay), var(--dream-media-overlay)), + var(--dream-art) !important; background-repeat: no-repeat !important; background-size: cover !important; background-position: var(--dream-art-position) !important; @@ -426,7 +448,9 @@ html.codex-dream-skin article { html.codex-dream-skin.dream-art-wide:has(main.main-surface.dream-home-shell) body, html.codex-dream-skin.dream-art-wide:is(.dream-task-ambient, .dream-task-banner):has(main.main-surface:not(.dream-home-shell)) body { background-color: var(--dream-canvas) !important; - background-image: var(--dream-art) !important; + background-image: + linear-gradient(var(--dream-media-overlay), var(--dream-media-overlay)), + var(--dream-art) !important; background-position: var(--dream-art-position) !important; background-size: cover !important; background-repeat: no-repeat !important; @@ -499,6 +523,26 @@ html.codex-dream-skin.dream-art-wide main.main-surface > header.app-header-tint filter: drop-shadow(0 1px 2px color-mix(in oklab, var(--dream-canvas) 72%, transparent)); } +/* Unlike a static image, a video can invalidate foreground contrast on every + frame. Keep the secondary Codex toolbar translucent but independently + readable instead of relying on text shadows over the moving media. */ +html.codex-dream-skin.dream-art-video main.main-surface > header.app-header-tint { + color: var(--dream-text) !important; + background: color-mix(in oklab, + color-mix(in oklab, var(--dream-surface) 86%, transparent) + var(--dream-wallpaper-cover), transparent) !important; + border-bottom: 1px solid var(--dream-line-soft) !important; + box-shadow: 0 1px 12px color-mix(in oklab, var(--dream-canvas) 44%, transparent) !important; + backdrop-filter: blur(16px) saturate(.9) !important; + text-shadow: 0 1px 2px color-mix(in oklab, var(--dream-canvas) 86%, transparent), + 0 0 8px color-mix(in oklab, var(--dream-canvas) 58%, transparent) !important; +} + +html.codex-dream-skin.dream-art-video main.main-surface > header.app-header-tint svg { + color: var(--dream-text) !important; + filter: drop-shadow(0 1px 2px color-mix(in oklab, var(--dream-canvas) 72%, transparent)); +} + html.codex-dream-skin.dream-art-wide main.main-surface [role="main"] { background: transparent !important; } diff --git a/windows/assets/renderer-inject.js b/windows/assets/renderer-inject.js index d70ab9f..bd15d17 100644 --- a/windows/assets/renderer-inject.js +++ b/windows/assets/renderer-inject.js @@ -29,6 +29,8 @@ "--dream-accent", "--dream-accent-ink", "--dream-image-luma", + "--dream-wallpaper-reveal", + "--dream-wallpaper-cover", ]; const HOME_UTILITY_CLASS = "dream-home-utility"; const installToken = {}; @@ -84,6 +86,9 @@ const playbackRate = Number.isFinite(Number(media.playbackRate)) ? clamp(Number(media.playbackRate), .25, 2) : 1; + const wallpaperReveal = Number.isFinite(Number(media.opacity)) + ? clamp(Number(media.opacity)) + : 1; const metadataRatio = Number(config?.artMetadata?.ratio); return { appearance, @@ -97,6 +102,7 @@ mediaMime, mediaSize, playbackRate, + wallpaperReveal, }; }; @@ -352,6 +358,18 @@ root.style.setProperty("--dream-accent", accent); root.style.setProperty("--dream-accent-ink", accentInk); root.style.setProperty("--dream-image-luma", profile.luma.toFixed(3)); + root.style.setProperty("--dream-wallpaper-reveal", config.wallpaperReveal.toFixed(2)); + root.style.setProperty("--dream-wallpaper-cover", `${Math.round((1 - config.wallpaperReveal) * 100)}%`); + }; + + const setWallpaperReveal = (value) => { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0 || numeric > 1) return null; + config.wallpaperReveal = numeric; + const root = document.documentElement; + root?.style.setProperty("--dream-wallpaper-reveal", numeric.toFixed(2)); + root?.style.setProperty("--dream-wallpaper-cover", `${Math.round((1 - numeric) * 100)}%`); + return numeric; }; const syncMediaElement = () => { @@ -528,7 +546,7 @@ document.addEventListener?.("visibilitychange", visibilityHandler); window[STATE_KEY] = { ensure, cleanup, observer, timer, scheduler, artUrl, mediaUrl, beginMedia, appendMedia, commitMedia, - visibilityHandler, profile, config, installToken, version: "1.2.0", + setWallpaperReveal, visibilityHandler, profile, config, installToken, version: "1.2.0", }; ensure(); analyzeArt().then((result) => { diff --git a/windows/scripts/injector.mjs b/windows/scripts/injector.mjs index 2f2f446..fbb7416 100644 --- a/windows/scripts/injector.mjs +++ b/windows/scripts/injector.mjs @@ -371,6 +371,11 @@ async function loadTheme(themeDir) { if (!Number.isFinite(playbackRate) || playbackRate < 0.25 || playbackRate > 2) { throw new Error("media.playbackRate must be between 0.25 and 2"); } + const opacity = rawMedia.opacity === null || rawMedia.opacity === undefined + ? 1 : Number(rawMedia.opacity); + if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) { + throw new Error("media.opacity must be between 0 and 1"); + } const theme = { id: normalizedText(raw.id, "id", "custom", 80), name: normalizedText(raw.name, "name", "Codex Dream Skin", 120), @@ -386,6 +391,7 @@ async function loadTheme(themeDir) { media: { type: inferredMediaType, playbackRate, + opacity, }, }; if (typeof palette.accent === "string" && palette.accent.trim()) { @@ -462,6 +468,21 @@ async function loadPayload(themeDir = path.join(root, "assets"), candidateTheme return { ...themeState, payload }; } +export function isOpacityOnlyThemeChange(previous, next) { + if (!previous || !next || + previous.mediaPath !== next.mediaPath || + previous.mediaType !== next.mediaType || + previous.mediaMime !== next.mediaMime || + previous.mediaSize !== next.mediaSize) return false; + const withoutOpacity = (theme) => { + const comparable = structuredClone(theme); + if (comparable.media) delete comparable.media.opacity; + return comparable; + }; + return JSON.stringify(withoutOpacity(previous.theme)) === JSON.stringify(withoutOpacity(next.theme)) && + previous.theme?.media?.opacity !== next.theme?.media?.opacity; +} + async function fileExists(filePath) { if (!filePath) return false; try { @@ -595,6 +616,18 @@ export async function transferVideoToSession(session, loadedPayload) { return { bytes: offset, chunks }; } +export async function setOpacityOnSession(session, value) { + const opacity = normalizedUnit(value, "media.opacity"); + if (opacity === null) throw new Error("media.opacity must be a number between 0 and 1"); + const applied = await session.evaluate(`(() => { + const requested = ${JSON.stringify(opacity)}; + const actual = window.__CODEX_DREAM_SKIN_STATE__?.setWallpaperReveal?.(requested); + return Number.isFinite(actual) && Math.abs(actual - requested) < 0.000001; + })()`); + if (!applied) throw new Error("Renderer rejected the wallpaper reveal update"); + return opacity; +} + async function applyToSession(session, loadedPayload) { const result = await session.evaluate(loadedPayload.payload); if (loadedPayload.mediaType === "video") { @@ -664,7 +697,8 @@ async function removeFromSession(session) { ); for (const property of [ '--dream-art', '--dream-art-position', '--dream-focus-x', '--dream-focus-y', - '--dream-accent', '--dream-accent-ink', '--dream-image-luma' + '--dream-accent', '--dream-accent-ink', '--dream-image-luma', + '--dream-wallpaper-reveal', '--dream-wallpaper-cover' ]) document.documentElement?.style.removeProperty(property); document.querySelectorAll('.dream-home').forEach((node) => node.classList.remove('dream-home')); document.querySelectorAll('.dream-task').forEach((node) => node.classList.remove('dream-task')); @@ -709,8 +743,14 @@ async function verifySession(session) { chromePresent: Boolean(document.getElementById('codex-dream-skin-chrome')), chromePointerEvents: getComputedStyle(document.getElementById('codex-dream-skin-chrome') || document.body).pointerEvents, mediaType: window.__CODEX_DREAM_SKIN_STATE__?.config?.mediaType ?? 'image', + wallpaperReveal: window.__CODEX_DREAM_SKIN_STATE__?.config?.wallpaperReveal ?? null, mediaPresent: Boolean(document.getElementById('codex-dream-skin-media')), mediaReady: Boolean(window.__CODEX_DREAM_SKIN_STATE__?.mediaUrl), + mediaElementOpacity: document.getElementById('codex-dream-skin-media') + ? getComputedStyle(document.getElementById('codex-dream-skin-media')).opacity + : null, + mainBackground: getComputedStyle(document.querySelector('main.main-surface') || document.body).backgroundImage, + sidebarBackground: getComputedStyle(document.querySelector('aside.app-shell-left-panel') || document.body).backgroundImage, homePresent: Boolean(home), suggestionsPresent: Boolean(suggestions), hero: box(home?.firstElementChild?.firstElementChild?.firstElementChild), @@ -726,7 +766,9 @@ async function verifySession(session) { result.pass = result.installed && result.version === result.expectedVersion && result.stylePresent && result.chromePresent && result.chromePointerEvents === 'none' && Boolean(result.composer) && Boolean(result.sidebar) && - (result.mediaType !== 'video' || (result.mediaPresent && result.mediaReady)) && + Number.isFinite(result.wallpaperReveal) && result.wallpaperReveal >= 0 && result.wallpaperReveal <= 1 && + (result.mediaType !== 'video' || + (result.mediaPresent && result.mediaReady && result.mediaElementOpacity === '1')) && (!result.homePresent || (Boolean(result.hero) && (!result.suggestionsPresent || (result.cards.length >= 2 && result.cards.length <= 4)))); return result; @@ -915,6 +957,8 @@ async function runWatch(options) { } const pauseChanged = nextPaused !== paused; const payloadChanged = !nextPaused && nextPayload !== loadedPayload; + const opacityOnlyChanged = payloadChanged && !pauseChanged && + isOpacityOnlyThemeChange(loadedPayload, nextPayload); loadedPayload = nextPayload; paused = nextPaused; @@ -948,7 +992,11 @@ async function runWatch(options) { if (loadedPayload.mediaType === "video") { attachLoadFallback(id, { id }, session); } - await applyToSession(session, loadedPayload); + if (opacityOnlyChanged) { + await setOpacityOnSession(session, loadedPayload.theme.media.opacity); + } else { + await applyToSession(session, loadedPayload); + } } } catch (error) { console.error(`[dream-skin] live theme update failed for ${id}: ${error.message}`); diff --git a/windows/scripts/theme-windows.ps1 b/windows/scripts/theme-windows.ps1 index 4650366..03624a6 100644 --- a/windows/scripts/theme-windows.ps1 +++ b/windows/scripts/theme-windows.ps1 @@ -130,6 +130,46 @@ function Get-DreamSkinMediaType { throw "Unsupported theme media format: $extension" } +function ConvertTo-DreamSkinMediaOpacity { + param( + [AllowNull()][object]$Value, + [double]$Default = 1 + ) + if ($null -eq $Value -or "$Value" -eq '') { return $Default } + try { $opacity = [double]$Value } catch { throw 'Theme media opacity must be a number between 0 and 1.' } + if ([double]::IsNaN($opacity) -or [double]::IsInfinity($opacity) -or + $opacity -lt 0 -or $opacity -gt 1) { + throw 'Theme media opacity must be a number between 0 and 1.' + } + return $opacity +} + +function Get-DreamSkinThemeMediaOpacity { + param([AllowNull()][object]$Theme) + if ($null -eq $Theme -or $null -eq $Theme.media) { return [double]1 } + $property = $Theme.media.PSObject.Properties['opacity'] + if ($null -eq $property) { return [double]1 } + return ConvertTo-DreamSkinMediaOpacity -Value $property.Value +} + +function Get-DreamSkinThemeMediaRevealPercent { + param([AllowNull()][object]$Theme) + $opacity = Get-DreamSkinThemeMediaOpacity -Theme $Theme + return [int][math]::Round($opacity * 100) +} + +function ConvertTo-DreamSkinMediaOpacityFromRevealPercent { + param([Parameter(Mandatory = $true)][object]$Percent) + try { $reveal = [double]$Percent } catch { + throw 'Theme wallpaper reveal must be a number between 0 and 100.' + } + if ([double]::IsNaN($reveal) -or [double]::IsInfinity($reveal) -or + $reveal -lt 0 -or $reveal -gt 100) { + throw 'Theme wallpaper reveal must be a number between 0 and 100.' + } + return [double]($reveal / 100) +} + function Assert-DreamSkinMediaFile { param( [Parameter(Mandatory = $true)][string]$Path, @@ -219,6 +259,7 @@ function Read-DreamSkinTheme { if ($theme.media -and $theme.media.type -and "$($theme.media.type)" -cne $mediaType) { throw "Theme media type does not match its file extension: $mediaPath" } + $null = Get-DreamSkinThemeMediaOpacity -Theme $theme return [pscustomobject]@{ Directory = $directory ThemePath = $themePath @@ -322,7 +363,7 @@ function Set-DreamSkinActiveTheme { appearance = 'auto' art = [pscustomobject]@{ focusX = $null; focusY = $null; safeArea = 'auto'; taskMode = 'auto' } palette = [pscustomobject]@{} - media = [pscustomobject]@{ type = $mediaType; playbackRate = 1 } + media = [pscustomobject]@{ type = $mediaType; playbackRate = 1; opacity = 1 } } } $imageName = New-DreamSkinThemeImageName -Extension $extension @@ -341,7 +382,7 @@ function Set-DreamSkinActiveTheme { $Theme | Add-Member -NotePropertyName media -NotePropertyValue ` ([pscustomobject]@{ type = $mediaType; playbackRate = if ($Theme.media.playbackRate) { [double]$Theme.media.playbackRate - } else { 1 } }) -Force + } else { 1 }; opacity = Get-DreamSkinThemeMediaOpacity -Theme $Theme }) -Force if ($Name) { $Theme | Add-Member -NotePropertyName name -NotePropertyValue $Name -Force } if (-not $Theme.id) { $Theme | Add-Member -NotePropertyName id -NotePropertyValue 'custom' -Force } if (-not $Theme.appearance) { $Theme | Add-Member -NotePropertyName appearance -NotePropertyValue 'auto' -Force } @@ -369,6 +410,25 @@ function Set-DreamSkinActiveTheme { return Read-DreamSkinTheme -ThemeDirectory $paths.Active } +function Set-DreamSkinActiveThemeMediaOpacity { + param( + [Parameter(Mandatory = $true)][object]$Opacity, + [string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin') + ) + $normalized = ConvertTo-DreamSkinMediaOpacity -Value $Opacity + $paths = Get-DreamSkinThemePaths -StateRoot $StateRoot + $active = Read-DreamSkinTheme -ThemeDirectory $paths.Active -SkipImageMetadata + $theme = $active.Theme | ConvertTo-Json -Depth 8 | ConvertFrom-Json + if ($null -eq $theme.media) { + $theme | Add-Member -NotePropertyName media -NotePropertyValue ` + ([pscustomobject]@{ type = $active.MediaType; playbackRate = 1; opacity = $normalized }) -Force + } else { + $theme.media | Add-Member -NotePropertyName opacity -NotePropertyValue $normalized -Force + } + Write-DreamSkinTheme -ThemeDirectory $paths.Active -Theme $theme + return Read-DreamSkinTheme -ThemeDirectory $paths.Active -SkipImageMetadata +} + function Save-DreamSkinCurrentTheme { param( [Parameter(Mandatory = $true)][string]$Name, diff --git a/windows/scripts/tray-dream-skin.ps1 b/windows/scripts/tray-dream-skin.ps1 index 2564f77..afe72ba 100644 --- a/windows/scripts/tray-dream-skin.ps1 +++ b/windows/scripts/tray-dream-skin.ps1 @@ -66,6 +66,51 @@ try { return $item } + function Add-DreamSkinRevealControl { + param( + [Parameter(Mandatory = $true)][AllowEmptyCollection()][System.Windows.Forms.ToolStripItemCollection]$Items, + [Parameter(Mandatory = $true)][object]$Theme + ) + $percent = Get-DreamSkinThemeMediaRevealPercent -Theme $Theme + $label = [System.Windows.Forms.ToolStripLabel]::new("壁纸透出:$percent%") + $label.Margin = [System.Windows.Forms.Padding]::new(8, 3, 8, 0) + [void]$Items.Add($label) + + $trackBar = [System.Windows.Forms.TrackBar]::new() + $trackBar.Minimum = 0 + $trackBar.Maximum = 100 + $trackBar.Value = $percent + $trackBar.TickFrequency = 10 + $trackBar.SmallChange = 5 + $trackBar.LargeChange = 10 + $trackBar.AutoSize = $false + $trackBar.Size = [System.Drawing.Size]::new(250, 38) + $trackBar.AccessibleName = '壁纸透出程度' + + $updateLabel = { + $label.Text = "壁纸透出:$($trackBar.Value)%" + }.GetNewClosure() + $commitReveal = { + try { + $label.Text = "壁纸透出:$($trackBar.Value)%" + $null = Set-DreamSkinActiveThemeMediaOpacity ` + -Opacity (ConvertTo-DreamSkinMediaOpacityFromRevealPercent -Percent $trackBar.Value) ` + -StateRoot $StateRoot + } catch { + Show-DreamSkinTrayError -Message $_.Exception.Message + } + }.GetNewClosure() + $trackBar.add_Scroll($updateLabel) + $trackBar.add_MouseUp($commitReveal) + $trackBar.add_KeyUp($commitReveal) + + $controlHost = [System.Windows.Forms.ToolStripControlHost]::new($trackBar) + $controlHost.AutoSize = $false + $controlHost.Size = [System.Drawing.Size]::new(270, 42) + $controlHost.Margin = [System.Windows.Forms.Padding]::new(4, 0, 4, 4) + [void]$Items.Add($controlHost) + } + function Rebuild-DreamSkinTrayMenu { $menu.Items.Clear() $paused = Test-DreamSkinPaused -StateRoot $StateRoot @@ -78,6 +123,9 @@ try { $status += " · $($active.Theme.name)" } $null = Add-DreamSkinTrayItem -Items $menu.Items -Text $status -Action $null -Enabled $false + if ($null -ne $active -and $null -ne $active.Theme) { + Add-DreamSkinRevealControl -Items $menu.Items -Theme $active.Theme + } [void]$menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '应用或重新应用' -Action { diff --git a/windows/tests/dynamic-media.test.mjs b/windows/tests/dynamic-media.test.mjs index 4258ebf..811f970 100644 --- a/windows/tests/dynamic-media.test.mjs +++ b/windows/tests/dynamic-media.test.mjs @@ -4,7 +4,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { transferVideoToSession } from "../scripts/injector.mjs"; +import { + isOpacityOnlyThemeChange, + setOpacityOnSession, + transferVideoToSession, +} from "../scripts/injector.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const injectorPath = path.resolve(here, "../scripts/injector.mjs"); @@ -41,7 +45,7 @@ try { name: "Video Fixture", image: "loop.mp4", appearance: "dark", - media: { type: "video", playbackRate: 1.25 }, + media: { type: "video", playbackRate: 1.25, opacity: 0.4 }, art: { focusX: 0.5, focusY: 0.5, safeArea: "center", taskMode: "ambient" }, })); @@ -51,6 +55,7 @@ try { assert.equal(summary.media.type, "video"); assert.equal(summary.media.mime, "video/mp4"); assert.equal(summary.media.size, videoBytes.length); + assert.equal(summary.media.opacity, 0.4); assert.ok(summary.payloadBytes < videoBytes.length, "The CDP bootstrap payload must not embed the complete video."); @@ -73,6 +78,29 @@ try { assert.ok(expressions.slice(1, -1).every((expression) => expression.includes(".appendMedia("))); assert.ok(expressions.every((expression) => Buffer.byteLength(expression) < 1024 * 1024)); + const opacityExpressions = []; + const opacitySession = { + async evaluate(expression) { + opacityExpressions.push(expression); + return true; + }, + }; + assert.equal(await setOpacityOnSession(opacitySession, 0.65), 0.65); + assert.match(opacityExpressions[0], /setWallpaperReveal/); + + const previousTheme = { + mediaPath: videoPath, + mediaType: "video", + mediaMime: "video/mp4", + mediaSize: videoBytes.length, + theme: { id: "same", media: { type: "video", playbackRate: 1, opacity: 0.4 } }, + }; + const opacityTheme = structuredClone(previousTheme); + opacityTheme.theme.media.opacity = 0.65; + assert.equal(isOpacityOnlyThemeChange(previousTheme, opacityTheme), true); + opacityTheme.theme.media.playbackRate = 1.25; + assert.equal(isOpacityOnlyThemeChange(previousTheme, opacityTheme), false); + const invalidDirectory = path.join(temporary, "invalid"); await fs.mkdir(invalidDirectory); await fs.writeFile(path.join(invalidDirectory, "fake.mp4"), Buffer.from("not an mp4")); @@ -84,6 +112,18 @@ try { const rejected = await runInjector(invalidDirectory); assert.notEqual(rejected.code, 0); assert.match(rejected.stderr, /signature|container/i); + + const invalidOpacityDirectory = path.join(temporary, "invalid-opacity"); + await fs.mkdir(invalidOpacityDirectory); + await fs.copyFile(videoPath, path.join(invalidOpacityDirectory, "loop.mp4")); + await fs.writeFile(path.join(invalidOpacityDirectory, "theme.json"), JSON.stringify({ + id: "invalid-opacity", + image: "loop.mp4", + media: { type: "video", opacity: 1.1 }, + })); + const rejectedOpacity = await runInjector(invalidOpacityDirectory); + assert.notEqual(rejectedOpacity.code, 0); + assert.match(rejectedOpacity.stderr, /opacity/i); } finally { await fs.rm(temporary, { recursive: true, force: true }); } diff --git a/windows/tests/renderer-inject.test.mjs b/windows/tests/renderer-inject.test.mjs index 3ae2771..da9a6ad 100644 --- a/windows/tests/renderer-inject.test.mjs +++ b/windows/tests/renderer-inject.test.mjs @@ -34,6 +34,36 @@ assert.match( /main\.main-surface\.dream-task\s+\.app-shell-main-content-top-fade\s*\{[^}]*display:\s*none !important;/, "Fallback task routes must remove the native top fade seam.", ); +assert.match( + css, + /html\.codex-dream-skin \[class~="group\/application-menu-top-bar"\]\s*\{[^}]*background:\s*color-mix\([^}]*backdrop-filter:\s*blur\(/, + "The native application menu must have a legibility layer in both light and dark themes.", +); +assert.match( + css, + /html\.codex-dream-skin \[class~="group\/application-menu-top-bar"\] button,[\s\S]*?\[class~="group\/application-menu-top-bar"\] svg\s*\{[^}]*color:\s*var\(--dream-text\) !important;/, + "Application menu controls must use the opaque adaptive theme text color.", +); +assert.match( + css, + /\.dream-art-video main\.main-surface\s*>\s*header\.app-header-tint\s*\{[^}]*background:\s*color-mix\([^}]*backdrop-filter:\s*blur\(/, + "Dynamic wallpaper mode must protect the secondary Codex toolbar from changing video frames.", +); +assert.match( + css, + /#codex-dream-skin-media\s*\{[^}]*opacity:\s*1\b/, + "Dynamic wallpaper must stay at its original opacity.", +); +assert.match( + css, + /linear-gradient\(var\(--dream-media-overlay\),\s*var\(--dream-media-overlay\)\),\s*var\(--dream-art\)/, + "Static wallpaper surfaces must blend with the canvas through the shared reveal control.", +); +assert.match( + css, + /--dream-immersive-edge:\s*color-mix\([^;]*var\(--dream-wallpaper-cover\)[^;]*transparent\)/, + "The full-window foreground veil must disappear as wallpaper reveal reaches 100%.", +); function createFixture({ shellPresent, @@ -335,6 +365,7 @@ const configuredPayload = buildPayload({ appearance: "light", palette: { accent: "#d45a70" }, art: { focusX: .15, focusY: .8, safeArea: "right", taskMode: "off" }, + media: { opacity: 0.35 }, }); const configuredResult = vm.runInNewContext(configuredPayload, configured.context); assert.equal(configuredResult.adaptive, true); @@ -345,6 +376,8 @@ assert.equal(configured.rootClasses.has("dream-safe-right"), true); assert.equal(configured.rootClasses.has("dream-task-off"), true); assert.equal(configured.rootStyles.get("--dream-art-position"), "15% 80%"); assert.equal(configured.rootStyles.get("--dream-accent"), "#d45a70"); +assert.equal(configured.rootStyles.get("--dream-wallpaper-reveal"), "0.35"); +assert.equal(configured.rootStyles.get("--dream-wallpaper-cover"), "65%"); assert.equal(configured.routeClasses.has("dream-home"), true); assert.equal(configured.routeClasses.has("dream-task"), false); assert.equal(configured.utilityClasses.has("dream-home-utility"), true); @@ -423,10 +456,11 @@ assert.equal(metadataWide.rootClasses.has("dream-art-standard"), false); const videoTheme = createFixture({ shellPresent: true }); vm.runInNewContext(buildPayload({ - media: { type: "video", mime: "video/mp4", size: 4, playbackRate: 1 }, + media: { type: "video", mime: "video/mp4", size: 4, playbackRate: 1, opacity: 0.6 }, artMetadata: { ratio: 16 / 9 }, }, ""), videoTheme.context); assert.equal(videoTheme.rootClasses.has("dream-art-video"), true); +assert.equal(videoTheme.rootStyles.get("--dream-wallpaper-reveal"), "0.60"); const videoState = videoTheme.context.window.__CODEX_DREAM_SKIN_STATE__; assert.equal(videoState.beginMedia({ mime: "video/mp4", size: 4 }), true); assert.equal(videoState.appendMedia("AAECAw=="), true); @@ -435,6 +469,10 @@ assert.equal(videoTheme.nodes.has("codex-dream-skin-media"), true); assert.equal(videoTheme.nodes.get("codex-dream-skin-media").muted, true); assert.equal(videoTheme.nodes.get("codex-dream-skin-media").loop, true); assert.equal(videoTheme.nodes.get("codex-dream-skin-media").paused, false); +assert.equal(videoState.setWallpaperReveal(0.25), 0.25); +assert.equal(videoTheme.rootStyles.get("--dream-wallpaper-reveal"), "0.25"); +assert.equal(videoTheme.rootStyles.get("--dream-wallpaper-cover"), "75%"); +assert.equal(videoState.setWallpaperReveal(2), null); videoTheme.setDocumentHidden(true); assert.equal(videoTheme.nodes.get("codex-dream-skin-media").paused, true); videoTheme.setDocumentHidden(false); diff --git a/windows/tests/run-tests.ps1 b/windows/tests/run-tests.ps1 index 9a37ab5..fa5a9df 100644 --- a/windows/tests/run-tests.ps1 +++ b/windows/tests/run-tests.ps1 @@ -632,9 +632,29 @@ try { $updatedTheme.Theme.id -cne 'custom' -or $updatedTheme.Theme.art.safeArea -cne 'auto' -or $updatedTheme.Theme.art.taskMode -cne 'auto' -or + [double]$updatedTheme.Theme.media.opacity -ne 1 -or -not (Test-DreamSkinThemePathWithin -Path $updatedTheme.ImagePath -Root $themePaths.Active)) { throw 'Imported image did not reset to the generic adaptive contract inside the managed directory.' } + $opacityTheme = Set-DreamSkinActiveThemeMediaOpacity -Opacity 0.42 -StateRoot $themeStateRoot + if ([math]::Abs((Get-DreamSkinThemeMediaOpacity -Theme $opacityTheme.Theme) - 0.42) -gt 0.000001) { + throw 'Active theme opacity was not persisted.' + } + if ((Get-DreamSkinThemeMediaRevealPercent -Theme ([pscustomobject]@{ + media = [pscustomobject]@{ opacity = 1 } + })) -ne 100 -or + (Get-DreamSkinThemeMediaRevealPercent -Theme ([pscustomobject]@{ + media = [pscustomobject]@{ opacity = 0 } + })) -ne 0 -or + [math]::Abs((ConvertTo-DreamSkinMediaOpacityFromRevealPercent -Percent 100) - 1) -gt 0.000001 -or + [math]::Abs((ConvertTo-DreamSkinMediaOpacityFromRevealPercent -Percent 55) - 0.55) -gt 0.000001) { + throw 'Tray wallpaper reveal percentage does not match the foreground veil contract.' + } + $invalidOpacityRejected = $false + try { $null = Set-DreamSkinActiveThemeMediaOpacity -Opacity 1.1 -StateRoot $themeStateRoot } catch { + $invalidOpacityRejected = $true + } + if (-not $invalidOpacityRejected) { throw 'Theme opacity accepted a value above 1.' } $null = Initialize-DreamSkinThemeStore -SkillRoot $Root -StateRoot $themeStateRoot $idempotentTheme = Read-DreamSkinTheme -ThemeDirectory $themePaths.Active if ($idempotentTheme.Theme.id -cne 'custom' -or @@ -642,7 +662,9 @@ try { throw 'Theme-store initialization overwrote the active custom theme or duplicated its bundled preset.' } $savedTheme = Save-DreamSkinCurrentTheme -Name '已保存主题' -StateRoot $themeStateRoot - if ($savedTheme.Theme.name -cne '已保存主题' -or @(Get-DreamSkinSavedThemes -StateRoot $themeStateRoot).Count -ne 2) { + if ($savedTheme.Theme.name -cne '已保存主题' -or + [math]::Abs((Get-DreamSkinThemeMediaOpacity -Theme $savedTheme.Theme) - 0.42) -gt 0.000001 -or + @(Get-DreamSkinSavedThemes -StateRoot $themeStateRoot).Count -ne 2) { throw 'Saved theme creation or discovery failed.' } $null = Use-DreamSkinSavedTheme -ThemeDirectory $savedTheme.Directory -StateRoot $themeStateRoot @@ -657,6 +679,7 @@ try { $videoTheme = Set-DreamSkinActiveTheme -ImagePath $videoFixture -Theme $null ` -Name '动态测试主题' -StateRoot $themeStateRoot if ($videoTheme.Theme.media.type -cne 'video' -or + [double]$videoTheme.Theme.media.opacity -ne 1 -or [System.IO.Path]::GetExtension($videoTheme.ImagePath) -cne '.mp4' -or -not (Test-DreamSkinThemePathWithin -Path $videoTheme.ImagePath -Root $themePaths.Active)) { throw 'Imported video did not become a managed dynamic theme.' @@ -742,6 +765,9 @@ try { $css = Read-DreamSkinUtf8File -Path (Join-Path $Root 'assets\dream-skin.css') foreach ($requiredCss in @( 'background-image: var(--dream-art)', + '--dream-wallpaper-reveal', + '--dream-wallpaper-cover', + '--dream-media-overlay', 'main.main-surface > header.app-header-tint', '[class~="group/application-menu-top-bar"]', '.app-shell-main-content-top-fade', @@ -755,7 +781,7 @@ try { if (-not $css.Contains($requiredCss)) { throw "Windows immersive CSS is missing: $requiredCss" } } $traySource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\tray-dream-skin.ps1') - foreach ($requiredTrayAction in @('System.Windows.Forms.NotifyIcon', '暂停皮肤', '更换背景图或视频', '*.mp4;*.webm', '已保存主题', '完全恢复 Codex')) { + foreach ($requiredTrayAction in @('System.Windows.Forms.NotifyIcon', 'System.Windows.Forms.TrackBar', '壁纸透出', '暂停皮肤', '更换背景图或视频', '*.mp4;*.webm', '已保存主题', '完全恢复 Codex')) { if (-not $traySource.Contains($requiredTrayAction)) { throw "Tray action is missing: $requiredTrayAction" } } if (-not $traySource.Contains('[AllowEmptyCollection()][System.Windows.Forms.ToolStripItemCollection]$Items')) {