From 04e6e6e4261c8b220970f06e9ad9c332d8827101 Mon Sep 17 00:00:00 2001 From: CalvinDawn <85914531+CCDawn@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:08:06 +0800 Subject: [PATCH] feat(windows): add dynamic video wallpapers --- README.en.md | 5 +- README.md | 3 +- windows/CHANGELOG.md | 6 + windows/README.en.md | 4 +- windows/README.md | 4 +- windows/assets/dream-skin.css | 18 ++ windows/assets/renderer-inject.js | 134 +++++++++++- windows/scripts/injector.mjs | 240 +++++++++++++++++----- windows/scripts/theme-windows.ps1 | 93 +++++++-- windows/scripts/tray-dream-skin.ps1 | 10 +- windows/tests/dynamic-media.test.mjs | 91 ++++++++ windows/tests/injector-bootstrap.test.mjs | 8 +- windows/tests/renderer-inject.test.mjs | 44 +++- windows/tests/run-tests.ps1 | 43 +++- 14 files changed, 610 insertions(+), 93 deletions(-) create mode 100644 windows/tests/dynamic-media.test.mjs diff --git a/README.en.md b/README.en.md index 1264676..911759a 100644 --- a/README.en.md +++ b/README.en.md @@ -81,8 +81,9 @@ powershell -ExecutionPolicy Bypass -File .\windows\scripts\start-dream-skin.ps1 ``` After launch, switch directly through **已保存主题 → 桥本有菜**; no cross-folder -manual import is required. **更换背景图** still imports your own UI-free -wallpaper, which can then be saved for one-click switching. +manual import is required. The enhanced **更换背景图或视频** action accepts +PNG/JPEG/WebP artwork and muted looping MP4/WebM video, which can then be saved +for one-click switching. > The downloadable user source is [`docs/images/presets/romantic-rose-source.png`](./docs/images/presets/romantic-rose-source.png) (`1672 × 941`); the macOS one-click preset uses the normalized derived [`background.jpg`](./macos/presets/preset-romantic-rose/background.jpg) (`2560 × 1440`). Do not import either screenshot above: they contain real UI and are previews only. The background is a user-provided AI-generated example, not an official OpenAI/Codex visual or endorsement; confirm likeness and asset rights before redistributing it. diff --git a/README.md b/README.md index 58e5b61..2435ad5 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ powershell -ExecutionPolicy Bypass -File .\windows\scripts\install-dream-skin.ps powershell -ExecutionPolicy Bypass -File .\windows\scripts\start-dream-skin.ps1 ``` -启动后可直接从「已保存主题 → 桥本有菜」切换;不需要跨目录手动导入。托盘里的「更换背景图」仍可导入你自己的纯背景,保存后继续一键切换。 +启动后可直接从「已保存主题 → 桥本有菜」切换;不需要跨目录手动导入。增强版托盘里的「更换背景图或视频」支持 PNG/JPEG/WebP 与静音循环的 MP4/WebM,保存后可继续一键切换。 > 可下载的用户源图是 [`docs/images/presets/romantic-rose-source.png`](./docs/images/presets/romantic-rose-source.png)(`1672 × 941`);macOS 一键预设使用 [`macos/presets/preset-romantic-rose/background.jpg`](./macos/presets/preset-romantic-rose/background.jpg)(规范化派生 `2560 × 1440`)。上面两个效果图包含真实 UI,**只作预览,绝不能当背景导入**。背景为用户提供的 AI 生成示例,不代表 OpenAI/Codex 官方视觉或背书;公开再分发前请确认人物与素材权利。 @@ -126,6 +126,7 @@ powershell -ExecutionPolicy Bypass -File .\windows\scripts\start-dream-skin.ps1 - **真·可交互**:侧栏、建议卡、项目选择、输入框都是原生控件,不是整窗假截图贴上去 - **真背景层**:一张 16:9 纯壁纸连续铺满整窗,首页突出氛围,任务页自动降低干扰 - **可换图**:换一张喜欢的纯背景,自适应焦点、安全区和配色后变成你的主题 +- **可播视频**:Windows 增强版支持本地 MP4/WebM 静音循环动态壁纸,页面隐藏时自动暂停 - **可存主题**:macOS 菜单栏与 Windows 系统托盘都能保存/切换本地主题 - **可恢复**:一键还原官方外观 - **相对安全**:本机回环 CDP 注入,不改官方二进制与签名 diff --git a/windows/CHANGELOG.md b/windows/CHANGELOG.md index 8ea8ae9..416fcb8 100644 --- a/windows/CHANGELOG.md +++ b/windows/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### 新增 + +- Windows 主题仓库与托盘支持本地 MP4/WebM 动态壁纸;视频静音循环、页面隐藏时暂停,并可像图片主题一样保存和切换。 +- 视频采用上限 128 MB、单块 512 KiB 的 CDP 分块传输,在 renderer 内组装为 Blob URL,避免把完整视频塞进 early bootstrap payload;换主题、暂停和清理时会释放播放器与 Blob URL。 +- MP4/WebM 导入会校验扩展名、最小容器签名、大小、受管目录边界和 reparse point,伪造扩展名或越界路径会在复制前拒绝。 + ### 修复 - 兼容新版 Codex 不再提供 `[role="main"]` 的任务页面:回退到 `main.main-surface` 承载任务背景,收起左侧边栏后也不会清除皮肤。 diff --git a/windows/README.en.md b/windows/README.en.md index 4f8ea58..dabb1f5 100644 --- a/windows/README.en.md +++ b/windows/README.en.md @@ -65,13 +65,15 @@ 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. +- Import a PNG, JPEG, or WebP background, or an MP4/WebM animated wallpaper. - Save the active theme and switch through saved themes. - Pause or resume the skin. - Reapply the theme or fully restore Codex. Import a UI-free wallpaper rather than a preview containing a window, sidebar, composer, text, or buttons. Images may be at most 16 MB, 16384 pixels on either side, and 50 million total pixels. +Animated wallpapers are always muted, looped, pointer-transparent, and paused while the Codex page is hidden. Videos may be at most 128 MB and are transferred to the current renderer in bounded CDP chunks; they are not uploaded or embedded wholesale in the bootstrap script. Audio, playlists, and Live2D are outside this MVP. + ## Restore and remove shortcuts Restore the stock appearance. If Codex is running, confirm its closure and relaunch: diff --git a/windows/README.md b/windows/README.md index 8c44b49..7c0b6a7 100644 --- a/windows/README.md +++ b/windows/README.md @@ -65,13 +65,15 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-dream-s 打开 `Codex Dream Skin - Tray` 后可以: -- 更换 PNG、JPEG 或 WebP 背景图。 +- 更换 PNG、JPEG、WebP 背景图,或 MP4、WebM 动态壁纸。 - 保存当前主题并从「已保存主题」切换。 - 暂停或继续显示皮肤。 - 重新应用主题,或完整恢复 Codex。 导入图片必须是纯背景,不要使用包含窗口、侧栏、输入框、文字或按钮的效果截图。图片上限为 16 MB;宽或高不能超过 16384 像素,总像素不能超过 5000 万。 +动态壁纸始终静音、循环且不拦截鼠标操作;Codex 页面隐藏时会自动暂停。视频上限为 128 MB,并通过有界 CDP 分块传入当前 renderer,不会上传到网络,也不会把整段视频嵌入启动脚本。当前 MVP 不提供声音、播放列表或 Live2D。 + ## 恢复与卸载快捷方式 恢复官方外观;如果 Codex 正在运行,确认后关闭并重新打开: diff --git a/windows/assets/dream-skin.css b/windows/assets/dream-skin.css index 9947666..5a2f529 100644 --- a/windows/assets/dream-skin.css +++ b/windows/assets/dream-skin.css @@ -56,6 +56,8 @@ } html.codex-dream-skin body { + position: relative; + isolation: isolate; background: var(--dream-canvas) !important; color: var(--dream-text) !important; font-family: "Segoe UI Variable Text", "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif !important; @@ -133,6 +135,22 @@ html.codex-dream-skin.dream-theme-dark [class~="group/application-menu-top-bar"] pointer-events: none !important; } +#codex-dream-skin-media { + position: fixed; + z-index: -1; + inset: 0; + width: 100vw; + height: 100vh; + border: 0; + object-fit: cover; + object-position: var(--dream-art-position); + pointer-events: none; +} + +html.codex-dream-skin.dream-art-video body { + background-image: none !important; +} + html.codex-dream-skin [role="main"] { color: var(--dream-text); } diff --git a/windows/assets/renderer-inject.js b/windows/assets/renderer-inject.js index 085e264..d70ab9f 100644 --- a/windows/assets/renderer-inject.js +++ b/windows/assets/renderer-inject.js @@ -2,10 +2,12 @@ const STATE_KEY = "__CODEX_DREAM_SKIN_STATE__"; const STYLE_ID = "codex-dream-skin-style"; const CHROME_ID = "codex-dream-skin-chrome"; + const MEDIA_ID = "codex-dream-skin-media"; const ROOT_CLASSES = [ "codex-dream-skin", "dream-theme-light", "dream-theme-dark", + "dream-art-video", "dream-art-wide", "dream-art-standard", "dream-focus-left", @@ -73,6 +75,15 @@ const taskMode = ["auto", "ambient", "banner", "off"].includes(art.taskMode) ? art.taskMode : "auto"; + const media = config.media && typeof config.media === "object" ? config.media : {}; + const mediaType = media.type === "video" ? "video" : "image"; + const mediaMime = ["video/mp4", "video/webm"].includes(media.mime) ? media.mime : null; + const mediaSize = Number.isSafeInteger(Number(media.size)) && Number(media.size) > 0 + ? Number(media.size) + : null; + const playbackRate = Number.isFinite(Number(media.playbackRate)) + ? clamp(Number(media.playbackRate), .25, 2) + : 1; const metadataRatio = Number(config?.artMetadata?.ratio); return { appearance, @@ -82,6 +93,10 @@ focusY: hasNumber(art.focusY) ? clamp(art.focusY) : null, accent: safeAccent, initialAspect: Number.isFinite(metadataRatio) && metadataRatio > 0 ? metadataRatio : null, + mediaType, + mediaMime, + mediaSize, + playbackRate, }; }; @@ -90,8 +105,15 @@ if (previous?.timer) clearInterval(previous.timer); if (previous?.scheduler?.timeout) clearTimeout(previous.scheduler.timeout); if (previous?.artUrl) URL.revokeObjectURL(previous.artUrl); + if (previous?.mediaUrl) URL.revokeObjectURL(previous.mediaUrl); + if (previous?.visibilityHandler) { + document.removeEventListener?.("visibilitychange", previous.visibilityHandler); + } + document.getElementById(MEDIA_ID)?.remove(); const artUrl = (() => { + if (!artDataUrl) return null; const comma = artDataUrl.indexOf(","); + if (comma < 0) return null; const binary = atob(artDataUrl.slice(comma + 1)); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); @@ -99,9 +121,11 @@ return URL.createObjectURL(new Blob([bytes], { type: mime })); })(); const config = normalizeConfig(rawConfig); + let mediaUrl = null; + let mediaTransfer = null; let profile = { ...defaultProfile, - aspect: config.initialAspect ?? defaultProfile.aspect, + aspect: config.initialAspect ?? (config.mediaType === "video" ? 16 / 9 : defaultProfile.aspect), }; const existingStyle = document.getElementById(STYLE_ID); if (existingStyle) { @@ -110,7 +134,14 @@ } const analyzeArt = () => new Promise((resolve) => { - if (typeof Image !== "function") { + if (config.mediaType === "video") { + resolve({ + ...defaultProfile, + aspect: config.initialAspect ?? 16 / 9, + }); + return; + } + if (!artUrl || typeof Image !== "function") { resolve(defaultProfile); return; } @@ -285,6 +316,7 @@ document.querySelectorAll(`.${HOME_UTILITY_CLASS}`).forEach((node) => node.classList.remove(HOME_UTILITY_CLASS)); document.getElementById(STYLE_ID)?.remove(); document.getElementById(CHROME_ID)?.remove(); + document.getElementById(MEDIA_ID)?.remove(); }; const applyProfile = (root) => { @@ -301,6 +333,7 @@ const accentInk = luminance(...profile.accent) > .42 ? "rgb(26 24 28)" : "rgb(250 248 251)"; root.classList.toggle("dream-theme-light", appearance === "light"); root.classList.toggle("dream-theme-dark", appearance === "dark"); + root.classList.toggle("dream-art-video", config.mediaType === "video"); root.classList.toggle("dream-art-wide", profile.aspect >= 1.75); root.classList.toggle("dream-art-standard", profile.aspect < 1.75); for (const value of ["left", "center", "right"]) { @@ -312,7 +345,7 @@ for (const value of ["ambient", "banner", "off"]) { root.classList.toggle(`dream-task-${value}`, taskMode === value); } - root.style.setProperty("--dream-art", `url("${artUrl}")`); + root.style.setProperty("--dream-art", artUrl ? `url("${artUrl}")` : "none"); root.style.setProperty("--dream-art-position", `${Math.round(focusX * 100)}% ${Math.round(focusY * 100)}%`); root.style.setProperty("--dream-focus-x", String(focusX)); root.style.setProperty("--dream-focus-y", String(focusY)); @@ -321,6 +354,89 @@ root.style.setProperty("--dream-image-luma", profile.luma.toFixed(3)); }; + const syncMediaElement = () => { + let media = document.getElementById(MEDIA_ID); + const shellPresent = Boolean(document.querySelector("main.main-surface")); + if (config.mediaType !== "video" || !shellPresent) { + media?.remove(); + return null; + } + if (!media || media.parentElement !== document.body) { + media?.remove(); + media = document.createElement("video"); + media.id = MEDIA_ID; + media.setAttribute("aria-hidden", "true"); + media.autoplay = true; + media.controls = false; + media.disablePictureInPicture = true; + media.loop = true; + media.muted = true; + media.playsInline = true; + media.preload = "auto"; + document.body.appendChild(media); + } + media.defaultMuted = true; + media.muted = true; + media.loop = true; + media.playbackRate = config.playbackRate; + if (mediaUrl && media.src !== mediaUrl) { + media.src = mediaUrl; + media.load?.(); + } + if (!mediaUrl || document.hidden) { + media.pause?.(); + } else { + const playback = media.play?.(); + playback?.catch?.(() => {}); + } + return media; + }; + + const beginMedia = ({ mime, size } = {}) => { + if (config.mediaType !== "video" || mime !== config.mediaMime || + !Number.isSafeInteger(size) || size !== config.mediaSize) return false; + mediaTransfer = { chunks: [], received: 0, expected: size, mime }; + if (mediaUrl) URL.revokeObjectURL(mediaUrl); + mediaUrl = null; + const media = syncMediaElement(); + if (media) { + media.pause?.(); + media.removeAttribute?.("src"); + media.load?.(); + } + const state = window[STATE_KEY]; + if (state?.installToken === installToken) state.mediaUrl = null; + return true; + }; + + const appendMedia = (encoded) => { + if (!mediaTransfer || typeof encoded !== "string" || encoded.length > 1024 * 1024) return false; + let binary; + try { + binary = atob(encoded); + } catch { + return false; + } + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + if (mediaTransfer.received + bytes.length > mediaTransfer.expected) return false; + mediaTransfer.chunks.push(bytes); + mediaTransfer.received += bytes.length; + return true; + }; + + const commitMedia = () => { + if (!mediaTransfer || mediaTransfer.received !== mediaTransfer.expected) return false; + const nextUrl = URL.createObjectURL(new Blob(mediaTransfer.chunks, { type: mediaTransfer.mime })); + mediaTransfer = null; + if (mediaUrl) URL.revokeObjectURL(mediaUrl); + mediaUrl = nextUrl; + const state = window[STATE_KEY]; + if (state?.installToken === installToken) state.mediaUrl = mediaUrl; + syncMediaElement(); + return true; + }; + const ensure = () => { if (window.__CODEX_DREAM_SKIN_DISABLED__) return; const root = document.documentElement; @@ -369,6 +485,7 @@ document.body.appendChild(chrome); } chrome.classList.toggle("dream-home-shell", Boolean(home)); + syncMediaElement(); }; const cleanup = () => { @@ -380,6 +497,10 @@ if (state?.timer) clearInterval(state.timer); if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout); if (state?.artUrl) URL.revokeObjectURL(state.artUrl); + if (state?.mediaUrl) URL.revokeObjectURL(state.mediaUrl); + if (state?.visibilityHandler) { + document.removeEventListener?.("visibilitychange", state.visibilityHandler); + } delete window[STATE_KEY]; return true; }; @@ -403,8 +524,11 @@ attributeFilter: ["class", "data-theme", "data-appearance", "data-color-mode"], }); const timer = setInterval(ensure, 5000); + const visibilityHandler = () => syncMediaElement(); + document.addEventListener?.("visibilitychange", visibilityHandler); window[STATE_KEY] = { - ensure, cleanup, observer, timer, scheduler, artUrl, profile, config, installToken, version: "1.2.0", + ensure, cleanup, observer, timer, scheduler, artUrl, mediaUrl, beginMedia, appendMedia, commitMedia, + visibilityHandler, profile, config, installToken, version: "1.2.0", }; ensure(); analyzeArt().then((result) => { @@ -414,5 +538,5 @@ state.profile = result; ensure(); }); - return { installed: true, version: "1.2.0", adaptive: true }; + return { installed: true, version: "1.2.0", adaptive: true, mediaType: config.mediaType }; })(__DREAM_CSS_JSON__, __DREAM_ART_JSON__, __DREAM_THEME_JSON__) diff --git a/windows/scripts/injector.mjs b/windows/scripts/injector.mjs index 29e03c9..2f2f446 100644 --- a/windows/scripts/injector.mjs +++ b/windows/scripts/injector.mjs @@ -9,6 +9,8 @@ const here = path.dirname(scriptPath); const root = path.resolve(here, ".."); const SKIN_VERSION = "1.2.0"; const MAX_ART_BYTES = 16 * 1024 * 1024; +const MAX_VIDEO_BYTES = 128 * 1024 * 1024; +const VIDEO_CHUNK_BYTES = 512 * 1024; const STRONG_THEME_AUDIT_MS = 30000; const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); const BROWSER_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/; @@ -304,6 +306,25 @@ function normalizedText(value, name, fallback, maxLength = 120) { return value; } +async function readVideoSignature(videoPath, extension) { + const handle = await fs.open(videoPath, "r"); + try { + const buffer = Buffer.alloc(64); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const header = buffer.subarray(0, bytesRead); + if (extension === ".mp4" && header.length >= 12 && header.toString("ascii", 4, 8) === "ftyp") { + return "video/mp4"; + } + if (extension === ".webm" && header.length >= 4 && + header[0] === 0x1a && header[1] === 0x45 && header[2] === 0xdf && header[3] === 0xa3) { + return "video/webm"; + } + throw new Error(`Video container signature does not match ${extension}`); + } finally { + await handle.close(); + } +} + async function loadTheme(themeDir) { const realThemeDir = await fs.realpath(themeDir); const themePath = path.join(realThemeDir, "theme.json"); @@ -312,29 +333,48 @@ async function loadTheme(themeDir) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { throw new Error("Theme root must be an object"); } - const image = normalizedText(raw.image, "image", null, 240); - if (!image || path.isAbsolute(image)) throw new Error("Theme image must be a relative path"); - const imagePath = path.resolve(realThemeDir, image); - const relativeImage = path.relative(realThemeDir, imagePath); - if (!relativeImage || relativeImage.startsWith("..") || path.isAbsolute(relativeImage)) { - throw new Error("Theme image must remain inside the selected theme directory"); + const mediaFile = normalizedText(raw.image, "image", null, 240); + if (!mediaFile || path.isAbsolute(mediaFile)) throw new Error("Theme media must be a relative path"); + const mediaPath = path.resolve(realThemeDir, mediaFile); + const relativeMedia = path.relative(realThemeDir, mediaPath); + if (!relativeMedia || relativeMedia.startsWith("..") || path.isAbsolute(relativeMedia)) { + throw new Error("Theme media must remain inside the selected theme directory"); } - const extension = path.extname(imagePath).toLowerCase(); - if (![".png", ".jpg", ".jpeg", ".webp"].includes(extension)) { - throw new Error(`Unsupported theme image format: ${extension || "missing"}`); + const extension = path.extname(mediaPath).toLowerCase(); + const imageExtensions = new Set([".png", ".jpg", ".jpeg", ".webp"]); + const videoExtensions = new Set([".mp4", ".webm"]); + if (!imageExtensions.has(extension) && !videoExtensions.has(extension)) { + throw new Error(`Unsupported theme media format: ${extension || "missing"}`); } - const realImagePath = await fs.realpath(imagePath); - const realRelativeImage = path.relative(realThemeDir, realImagePath); - if (!realRelativeImage || realRelativeImage.startsWith("..") || path.isAbsolute(realRelativeImage)) { - throw new Error("Theme image cannot escape through a link or junction"); + const realMediaPath = await fs.realpath(mediaPath); + const realRelativeMedia = path.relative(realThemeDir, realMediaPath); + if (!realRelativeMedia || realRelativeMedia.startsWith("..") || path.isAbsolute(realRelativeMedia)) { + throw new Error("Theme media cannot escape through a link or junction"); } const art = raw.art && typeof raw.art === "object" && !Array.isArray(raw.art) ? raw.art : {}; const palette = raw.palette && typeof raw.palette === "object" && !Array.isArray(raw.palette) ? raw.palette : {}; + const rawMedia = raw.media && typeof raw.media === "object" && !Array.isArray(raw.media) + ? raw.media : {}; + const inferredMediaType = videoExtensions.has(extension) ? "video" : "image"; + const requestedMediaType = normalizedChoice( + rawMedia.type, + "media.type", + new Set(["image", "video"]), + inferredMediaType, + ); + if (requestedMediaType !== inferredMediaType) { + throw new Error(`Theme media type ${requestedMediaType} does not match ${extension}`); + } + const playbackRate = rawMedia.playbackRate === null || rawMedia.playbackRate === undefined + ? 1 : Number(rawMedia.playbackRate); + if (!Number.isFinite(playbackRate) || playbackRate < 0.25 || playbackRate > 2) { + throw new Error("media.playbackRate must be between 0.25 and 2"); + } const theme = { id: normalizedText(raw.id, "id", "custom", 80), name: normalizedText(raw.name, "name", "Codex Dream Skin", 120), - image, + image: mediaFile, appearance: normalizedChoice(raw.appearance, "appearance", THEME_CHOICES.appearance, "auto"), art: { focusX: normalizedUnit(art.focusX, "art.focusX"), @@ -343,6 +383,10 @@ async function loadTheme(themeDir) { taskMode: normalizedChoice(art.taskMode, "art.taskMode", THEME_CHOICES.taskMode, "auto"), }, palette: {}, + media: { + type: inferredMediaType, + playbackRate, + }, }; if (typeof palette.accent === "string" && palette.accent.trim()) { const accent = palette.accent.trim(); @@ -351,33 +395,53 @@ async function loadTheme(themeDir) { } theme.palette.accent = accent; } - const [themeStat, imageStat] = await Promise.all([fs.stat(themePath), fs.stat(realImagePath)]); - if (!imageStat.isFile()) throw new Error("Theme image is not a file"); - if (imageStat.size < 1) throw new Error("Theme image cannot be empty"); - if (imageStat.size > MAX_ART_BYTES) { - throw new Error(`Theme image exceeds the ${MAX_ART_BYTES / 1024 / 1024} MB limit`); - } - const imageBytes = await fs.readFile(realImagePath); - if (imageBytes.length < 1 || imageBytes.length > MAX_ART_BYTES) { - throw new Error(`Theme image must be between 1 byte and ${MAX_ART_BYTES / 1024 / 1024} MB`); - } - const artMetadata = readImageMetadata(imageBytes, extension); - if (!artMetadata) { - throw new Error("Theme image metadata is invalid or exceeds the 16384px / 50MP safety limit"); - } - theme.artMetadata = artMetadata; - const fingerprint = createHash("sha256") - .update(themeText, "utf8") - .update("\0") - .update(imageBytes) - .digest("hex"); + const [themeStat, mediaStat] = await Promise.all([fs.stat(themePath), fs.stat(realMediaPath)]); + if (!mediaStat.isFile()) throw new Error("Theme media is not a file"); + if (mediaStat.size < 1) throw new Error("Theme media cannot be empty"); + const fingerprintHash = createHash("sha256").update(themeText, "utf8").update("\0"); + let mediaBytes = null; + let mediaMime = null; + if (inferredMediaType === "image") { + if (mediaStat.size > MAX_ART_BYTES) { + throw new Error(`Theme image exceeds the ${MAX_ART_BYTES / 1024 / 1024} MB limit`); + } + mediaBytes = await fs.readFile(realMediaPath); + if (mediaBytes.length < 1 || mediaBytes.length > MAX_ART_BYTES) { + throw new Error(`Theme image must be between 1 byte and ${MAX_ART_BYTES / 1024 / 1024} MB`); + } + const artMetadata = readImageMetadata(mediaBytes, extension); + if (!artMetadata) { + throw new Error("Theme image metadata is invalid or exceeds the 16384px / 50MP safety limit"); + } + theme.artMetadata = artMetadata; + mediaMime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" + : extension === ".webp" ? "image/webp" : "image/png"; + fingerprintHash.update(mediaBytes); + } else { + if (mediaStat.size > MAX_VIDEO_BYTES) { + throw new Error(`Theme video exceeds the ${MAX_VIDEO_BYTES / 1024 / 1024} MB limit`); + } + mediaMime = await readVideoSignature(realMediaPath, extension); + theme.artMetadata = { ratio: 16 / 9 }; + fingerprintHash + .update(String(mediaStat.size)) + .update("\0") + .update(String(mediaStat.mtimeMs)); + } + theme.media.mime = mediaMime; + theme.media.size = mediaStat.size; + const fingerprint = fingerprintHash.digest("hex"); return { theme, themePath, - imagePath: realImagePath, - imageBytes, + imagePath: realMediaPath, + mediaPath: realMediaPath, + mediaBytes, + mediaMime, + mediaSize: mediaStat.size, + mediaType: inferredMediaType, fingerprint, - sourceStamp: `${themeStat.size}:${themeStat.mtimeMs}:${imageStat.size}:${imageStat.mtimeMs}`, + sourceStamp: `${themeStat.size}:${themeStat.mtimeMs}:${mediaStat.size}:${mediaStat.mtimeMs}`, }; } @@ -387,15 +451,14 @@ async function loadPayload(themeDir = path.join(root, "assets"), candidateTheme fs.readFile(path.join(root, "assets", "dream-skin.css"), "utf8"), fs.readFile(path.join(root, "assets", "renderer-inject.js"), "utf8"), ]); - const extension = path.extname(loadedTheme.imagePath).toLowerCase(); - const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" - : extension === ".webp" ? "image/webp" : "image/png"; - const artDataUrl = `data:${mime};base64,${loadedTheme.imageBytes.toString("base64")}`; + const artDataUrl = loadedTheme.mediaType === "image" + ? `data:${loadedTheme.mediaMime};base64,${loadedTheme.mediaBytes.toString("base64")}` + : ""; const payload = template .replace("__DREAM_CSS_JSON__", JSON.stringify(css)) .replace("__DREAM_ART_JSON__", JSON.stringify(artDataUrl)) .replace("__DREAM_THEME_JSON__", JSON.stringify(loadedTheme.theme)); - const { imageBytes: _imageBytes, ...themeState } = loadedTheme; + const { mediaBytes: _mediaBytes, ...themeState } = loadedTheme; return { ...themeState, payload }; } @@ -412,7 +475,7 @@ async function fileExists(filePath) { async function readThemeSourceStamp(loadedTheme) { const [themeStat, imageStat] = await Promise.all([ fs.stat(loadedTheme.themePath), - fs.stat(loadedTheme.imagePath), + fs.stat(loadedTheme.mediaPath), ]); return `${themeStat.size}:${themeStat.mtimeMs}:${imageStat.size}:${imageStat.mtimeMs}`; } @@ -481,8 +544,63 @@ async function connectCodexTargets(port, timeoutMs, expectedBrowserId) { throw new Error(`No verified Codex renderer on 127.0.0.1:${port}: ${lastError?.message ?? "timed out"}`); } -async function applyToSession(session, payload) { - return session.evaluate(payload); +export async function transferVideoToSession(session, loadedPayload) { + if (!loadedPayload?.mediaPath || + (loadedPayload.mediaType && loadedPayload.mediaType !== "video")) { + throw new Error("Video transfer requires a loaded video theme"); + } + const mediaPath = loadedPayload.mediaPath; + const mediaMime = loadedPayload.mediaMime; + const mediaSize = loadedPayload.mediaSize; + if (!["video/mp4", "video/webm"].includes(mediaMime) || + !Number.isSafeInteger(mediaSize) || mediaSize < 1 || mediaSize > MAX_VIDEO_BYTES) { + throw new Error("Video transfer metadata is invalid"); + } + const stat = await fs.stat(mediaPath); + if (!stat.isFile() || stat.size !== mediaSize) { + throw new Error("Theme video changed before transfer completed"); + } + const stateExpression = "window.__CODEX_DREAM_SKIN_STATE__"; + const began = await session.evaluate( + `${stateExpression}?.beginMedia(${JSON.stringify({ mime: mediaMime, size: mediaSize })}) === true`, + ); + if (!began) throw new Error("Renderer rejected the video transfer"); + const handle = await fs.open(mediaPath, "r"); + let offset = 0; + let chunks = 0; + try { + const buffer = Buffer.alloc(VIDEO_CHUNK_BYTES); + while (offset < mediaSize) { + const length = Math.min(buffer.length, mediaSize - offset); + const { bytesRead } = await handle.read(buffer, 0, length, offset); + if (bytesRead < 1) throw new Error("Theme video ended during transfer"); + const encoded = buffer.subarray(0, bytesRead).toString("base64"); + const accepted = await session.evaluate( + `${stateExpression}?.appendMedia(${JSON.stringify(encoded)}) === true`, + ); + if (!accepted) throw new Error("Renderer rejected a video transfer chunk"); + offset += bytesRead; + chunks += 1; + } + } finally { + await handle.close(); + } + const completedStat = await fs.stat(mediaPath); + if (!completedStat.isFile() || completedStat.size !== stat.size || + completedStat.mtimeMs !== stat.mtimeMs) { + throw new Error("Theme video changed during transfer"); + } + const committed = await session.evaluate(`${stateExpression}?.commitMedia() === true`); + if (!committed) throw new Error("Renderer rejected the completed video transfer"); + return { bytes: offset, chunks }; +} + +async function applyToSession(session, loadedPayload) { + const result = await session.evaluate(loadedPayload.payload); + if (loadedPayload.mediaType === "video") { + await transferVideoToSession(session, loadedPayload); + } + return result; } export function earlyPayloadFor(payload, revision) { @@ -538,7 +656,7 @@ async function removeFromSession(session) { const state = window.__CODEX_DREAM_SKIN_STATE__; if (state?.cleanup) return state.cleanup(); document.documentElement?.classList.remove( - 'codex-dream-skin', 'dream-theme-light', 'dream-theme-dark', + 'codex-dream-skin', 'dream-theme-light', 'dream-theme-dark', 'dream-art-video', 'dream-art-wide', 'dream-art-standard', 'dream-focus-left', 'dream-focus-center', 'dream-focus-right', 'dream-safe-left', 'dream-safe-center', 'dream-safe-right', 'dream-safe-none', @@ -553,6 +671,7 @@ async function removeFromSession(session) { document.querySelectorAll('.dream-home-shell').forEach((node) => node.classList.remove('dream-home-shell')); document.getElementById('codex-dream-skin-style')?.remove(); document.getElementById('codex-dream-skin-chrome')?.remove(); + document.getElementById('codex-dream-skin-media')?.remove(); delete window.__CODEX_DREAM_SKIN_STATE__; return true; })()`); @@ -567,6 +686,7 @@ async function verifyRemovedSession(session) { !document.querySelector('.dream-home-shell') && !document.getElementById('codex-dream-skin-style') && !document.getElementById('codex-dream-skin-chrome') && + !document.getElementById('codex-dream-skin-media') && !window.__CODEX_DREAM_SKIN_STATE__ )()`); } @@ -588,6 +708,9 @@ async function verifySession(session) { stylePresent: Boolean(document.getElementById('codex-dream-skin-style')), 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', + mediaPresent: Boolean(document.getElementById('codex-dream-skin-media')), + mediaReady: Boolean(window.__CODEX_DREAM_SKIN_STATE__?.mediaUrl), homePresent: Boolean(home), suggestionsPresent: Boolean(suggestions), hero: box(home?.firstElementChild?.firstElementChild?.firstElementChild), @@ -603,6 +726,7 @@ 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)) && (!result.homePresent || (Boolean(result.hero) && (!result.suggestionsPresent || (result.cards.length >= 2 && result.cards.length <= 4)))); return result; @@ -651,21 +775,20 @@ async function runOneShot(options) { const connected = await connectCodexTargets(options.port, options.timeoutMs, options.browserId); const loadedPayload = (options.mode === "once" || options.reload) ? await loadPayload(options.themeDir) : null; - const payload = loadedPayload?.payload ?? null; const results = []; let screenshotCaptured = false; try { for (const { target, session, probe } of connected) { try { if (options.mode === "remove") await removeFromSession(session); - else if (options.mode === "once") await applyToSession(session, payload); + else if (options.mode === "once") await applyToSession(session, loadedPayload); if (options.mode === "once") { await new Promise((resolve) => setTimeout(resolve, 850)); } if (options.reload) { await session.send("Page.reload", { ignoreCache: true }); await new Promise((resolve) => setTimeout(resolve, 1600)); - if (options.mode !== "remove") await applyToSession(session, payload); + if (options.mode !== "remove") await applyToSession(session, loadedPayload); } const verified = options.mode === "remove" ? await verifyRemovedSession(session) @@ -721,9 +844,9 @@ async function runWatch(options) { fallbackListeners.add(id); let lastReinjectErrorLogAt = 0; session.on("Page.loadEventFired", () => { - if (!fallbackTargets.get(id)) return; + if (!fallbackTargets.get(id) && loadedPayload?.mediaType !== "video") return; setTimeout(() => { - const operation = paused ? removeFromSession(session) : applyToSession(session, loadedPayload.payload); + const operation = paused ? removeFromSession(session) : applyToSession(session, loadedPayload); operation.catch((error) => { if (Date.now() - lastReinjectErrorLogAt >= 30000) { console.error(`[dream-skin] reinject failed for ${target.id}: ${error.message}`); @@ -804,7 +927,6 @@ async function runWatch(options) { await removeEarlyPayload(session, previousEarlyScript); earlyScripts.delete(id); fallbackTargets.delete(id); - fallbackListeners.delete(id); } else { let nextEarlyScript = null; try { @@ -823,7 +945,10 @@ async function runWatch(options) { if (nextEarlyScript) earlyScripts.set(id, nextEarlyScript); else earlyScripts.delete(id); await removeEarlyPayload(session, previousEarlyScript); - await applyToSession(session, loadedPayload.payload); + if (loadedPayload.mediaType === "video") { + attachLoadFallback(id, { id }, session); + } + await applyToSession(session, loadedPayload); } } catch (error) { console.error(`[dream-skin] live theme update failed for ${id}: ${error.message}`); @@ -888,7 +1013,9 @@ async function runWatch(options) { continue; } fallbackTargets.set(target.id, earlyInjectionFallback); - if (earlyInjectionFallback) attachLoadFallback(target.id, target, session); + if (earlyInjectionFallback || loadedPayload.mediaType === "video") { + attachLoadFallback(target.id, target, session); + } if (identityAnchor.closed) throw new CdpIdentityMismatchError("Original CDP browser identity closed"); let earlyApplied = false; if (!paused && !earlyInjectionFallback) { @@ -897,7 +1024,9 @@ async function runWatch(options) { ).catch(() => false); } if (paused) await removeFromSession(session); - else if (!earlyApplied) await applyToSession(session, loadedPayload.payload); + else if (!earlyApplied || loadedPayload.mediaType === "video") { + await applyToSession(session, loadedPayload); + } sessions.set(target.id, session); if (earlyScriptId) earlyScripts.set(target.id, earlyScriptId); targetFailures.delete(target.id); @@ -987,6 +1116,7 @@ if (path.resolve(process.argv[1] || "") === path.resolve(scriptPath)) { appearance: loaded.theme.appearance, art: loaded.theme.art, artMetadata: loaded.theme.artMetadata ?? null, + media: loaded.theme.media, })); } else if (options.mode === "watch") await runWatch(options); else await runOneShot(options); diff --git a/windows/scripts/theme-windows.ps1 b/windows/scripts/theme-windows.ps1 index 364d250..4650366 100644 --- a/windows/scripts/theme-windows.ps1 +++ b/windows/scripts/theme-windows.ps1 @@ -3,6 +3,7 @@ } $script:DreamSkinMaxImageBytes = 16 * 1024 * 1024 +$script:DreamSkinMaxVideoBytes = 128 * 1024 * 1024 function Assert-DreamSkinNoReparseComponents { param([Parameter(Mandatory = $true)][string]$Path) @@ -89,6 +90,60 @@ function Assert-DreamSkinImageFile { } } +function Assert-DreamSkinVideoFile { + param([Parameter(Mandatory = $true)][string]$Path) + $fullPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + throw "Video does not exist: $fullPath" + } + $extension = [System.IO.Path]::GetExtension($fullPath).ToLowerInvariant() + if ($extension -notin @('.mp4', '.webm')) { + throw "Unsupported video format: $extension" + } + $length = (Get-Item -LiteralPath $fullPath -Force).Length + if ($length -lt 1) { throw 'Theme video cannot be empty.' } + if ($length -gt $script:DreamSkinMaxVideoBytes) { + throw 'Theme video exceeds the 128 MB limit.' + } + $header = New-Object byte[] 12 + $stream = [System.IO.File]::Open( + $fullPath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::Read + ) + try { $read = $stream.Read($header, 0, $header.Length) } finally { $stream.Dispose() } + $valid = if ($extension -ceq '.mp4') { + $read -ge 12 -and [System.Text.Encoding]::ASCII.GetString($header, 4, 4) -ceq 'ftyp' + } else { + $read -ge 4 -and $header[0] -eq 0x1a -and $header[1] -eq 0x45 -and + $header[2] -eq 0xdf -and $header[3] -eq 0xa3 + } + if (-not $valid) { throw "Video container signature does not match $extension" } +} + +function Get-DreamSkinMediaType { + param([Parameter(Mandatory = $true)][string]$Path) + $extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant() + if ($extension -in @('.png', '.jpg', '.jpeg', '.webp')) { return 'image' } + if ($extension -in @('.mp4', '.webm')) { return 'video' } + throw "Unsupported theme media format: $extension" +} + +function Assert-DreamSkinMediaFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [switch]$SkipImageMetadata + ) + $mediaType = Get-DreamSkinMediaType -Path $Path + if ($mediaType -ceq 'video') { + Assert-DreamSkinVideoFile -Path $Path + } else { + Assert-DreamSkinImageFile -Path $Path -SkipImageMetadata:$SkipImageMetadata + } + return $mediaType +} + function Get-DreamSkinThemePaths { param([string]$StateRoot = (Join-Path $env:LOCALAPPDATA 'CodexDreamSkin')) $fullRoot = [System.IO.Path]::GetFullPath($StateRoot) @@ -151,20 +206,25 @@ function Read-DreamSkinTheme { throw "Theme metadata is invalid JSON: $themePath" } if ($null -eq $theme -or $theme -is [string] -or $theme -is [array] -or -not $theme.image) { - throw "Theme metadata must be an object with a relative image path: $themePath" + throw "Theme metadata must be an object with a relative media path: $themePath" + } + $media = "$($theme.image)" + if ([System.IO.Path]::IsPathRooted($media)) { throw 'Theme media path must be relative.' } + $mediaPath = [System.IO.Path]::GetFullPath((Join-Path $directory $media)) + if (-not (Test-DreamSkinThemePathWithin -Path $mediaPath -Root $directory) -or + -not (Test-Path -LiteralPath $mediaPath -PathType Leaf)) { + throw 'Theme media must remain inside its theme directory and exist.' } - $image = "$($theme.image)" - if ([System.IO.Path]::IsPathRooted($image)) { throw 'Theme image path must be relative.' } - $imagePath = [System.IO.Path]::GetFullPath((Join-Path $directory $image)) - if (-not (Test-DreamSkinThemePathWithin -Path $imagePath -Root $directory) -or - -not (Test-Path -LiteralPath $imagePath -PathType Leaf)) { - throw 'Theme image must remain inside its theme directory and exist.' + $mediaType = Assert-DreamSkinMediaFile -Path $mediaPath -SkipImageMetadata:$SkipImageMetadata + if ($theme.media -and $theme.media.type -and "$($theme.media.type)" -cne $mediaType) { + throw "Theme media type does not match its file extension: $mediaPath" } - Assert-DreamSkinImageFile -Path $imagePath -SkipImageMetadata:$SkipImageMetadata return [pscustomobject]@{ Directory = $directory ThemePath = $themePath - ImagePath = $imagePath + ImagePath = $mediaPath + MediaPath = $mediaPath + MediaType = $mediaType Theme = $theme } } @@ -251,7 +311,7 @@ function Set-DreamSkinActiveTheme { Ensure-DreamSkinManagedDirectory -Path $paths.Active -Root $paths.Root Ensure-DreamSkinManagedDirectory -Path $paths.Images -Root $paths.Root $source = [System.IO.Path]::GetFullPath($ImagePath) - Assert-DreamSkinImageFile -Path $source + $mediaType = Assert-DreamSkinMediaFile -Path $source $extension = [System.IO.Path]::GetExtension($source).ToLowerInvariant() $oldImage = $null try { $oldImage = (Read-DreamSkinTheme -ThemeDirectory $paths.Active).ImagePath } catch {} @@ -262,6 +322,7 @@ function Set-DreamSkinActiveTheme { appearance = 'auto' art = [pscustomobject]@{ focusX = $null; focusY = $null; safeArea = 'auto'; taskMode = 'auto' } palette = [pscustomobject]@{} + media = [pscustomobject]@{ type = $mediaType; playbackRate = 1 } } } $imageName = New-DreamSkinThemeImageName -Extension $extension @@ -272,11 +333,15 @@ function Set-DreamSkinActiveTheme { Assert-DreamSkinNoReparseComponents -Path $temporary Copy-Item -LiteralPath $source -Destination $temporary -Force Assert-DreamSkinNoReparseComponents -Path $temporary - Assert-DreamSkinImageFile -Path $temporary + $null = Assert-DreamSkinMediaFile -Path $temporary Move-Item -LiteralPath $temporary -Destination $target -Force Assert-DreamSkinNoReparseComponents -Path $target - Assert-DreamSkinImageFile -Path $target + $null = Assert-DreamSkinMediaFile -Path $target $Theme | Add-Member -NotePropertyName image -NotePropertyValue $imageName -Force + $Theme | Add-Member -NotePropertyName media -NotePropertyValue ` + ([pscustomobject]@{ type = $mediaType; playbackRate = if ($Theme.media.playbackRate) { + [double]$Theme.media.playbackRate + } else { 1 } }) -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 } @@ -300,7 +365,7 @@ function Set-DreamSkinActiveTheme { Assert-DreamSkinNoReparseComponents -Path $imageArchive Copy-Item -LiteralPath $target -Destination $imageArchive -Force Assert-DreamSkinNoReparseComponents -Path $imageArchive - Assert-DreamSkinImageFile -Path $imageArchive + $null = Assert-DreamSkinMediaFile -Path $imageArchive return Read-DreamSkinTheme -ThemeDirectory $paths.Active } @@ -326,7 +391,7 @@ function Save-DreamSkinCurrentTheme { Assert-DreamSkinNoReparseComponents -Path $destinationImage Copy-Item -LiteralPath $active.ImagePath -Destination $destinationImage -Force Assert-DreamSkinNoReparseComponents -Path $destinationImage - Assert-DreamSkinImageFile -Path $destinationImage + $null = Assert-DreamSkinMediaFile -Path $destinationImage $theme = $active.Theme | ConvertTo-Json -Depth 8 | ConvertFrom-Json $theme.id = $id $theme.name = $trimmed diff --git a/windows/scripts/tray-dream-skin.ps1 b/windows/scripts/tray-dream-skin.ps1 index 8f73381..2564f77 100644 --- a/windows/scripts/tray-dream-skin.ps1 +++ b/windows/scripts/tray-dream-skin.ps1 @@ -90,16 +90,16 @@ try { Set-DreamSkinPaused -Paused $nextPaused -StateRoot $StateRoot | Out-Null }.GetNewClosure() $null = Add-DreamSkinTrayItem -Items $menu.Items -Text $pauseText -Action $pauseAction - $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '更换背景图' -Action { + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '更换背景图或视频' -Action { $dialog = [System.Windows.Forms.OpenFileDialog]::new() - $dialog.Title = '选择 Codex Dream Skin 背景图' - $dialog.Filter = 'Image files|*.png;*.jpg;*.jpeg;*.webp|All files|*.*' + $dialog.Title = '选择 Codex Dream Skin 背景图或视频' + $dialog.Filter = 'Wallpaper files|*.png;*.jpg;*.jpeg;*.webp;*.mp4;*.webm|Image files|*.png;*.jpg;*.jpeg;*.webp|Video files|*.mp4;*.webm|All files|*.*' $dialog.Multiselect = $false try { if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $null = Set-DreamSkinActiveTheme -ImagePath $dialog.FileName -Theme $null -StateRoot $StateRoot Set-DreamSkinPaused -Paused $false -StateRoot $StateRoot | Out-Null - $notify.ShowBalloonTip(1800, 'Codex Dream Skin', '背景图已更新。', [System.Windows.Forms.ToolTipIcon]::Info) + $notify.ShowBalloonTip(1800, 'Codex Dream Skin', '背景素材已更新。', [System.Windows.Forms.ToolTipIcon]::Info) } } finally { $dialog.Dispose() @@ -133,7 +133,7 @@ try { } [void]$menu.Items.Add($savedMenu) - $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '打开图片文件夹' -Action { + $null = Add-DreamSkinTrayItem -Items $menu.Items -Text '打开素材文件夹' -Action { Start-Process -FilePath explorer.exe -ArgumentList @($paths.Images) | Out-Null } [void]$menu.Items.Add([System.Windows.Forms.ToolStripSeparator]::new()) diff --git a/windows/tests/dynamic-media.test.mjs b/windows/tests/dynamic-media.test.mjs new file mode 100644 index 0000000..4258ebf --- /dev/null +++ b/windows/tests/dynamic-media.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +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"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const injectorPath = path.resolve(here, "../scripts/injector.mjs"); +const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "codex-dream-skin-video-")); +const themeDirectory = path.join(temporary, "theme"); +const videoPath = path.join(themeDirectory, "loop.mp4"); + +const runInjector = (directory) => new Promise((resolve, reject) => { + const child = spawn(process.execPath, [ + injectorPath, + "--check-payload", + "--theme-dir", directory, + ], { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.once("error", reject); + child.once("close", (code) => resolve({ code, stdout, stderr })); +}); + +try { + await fs.mkdir(themeDirectory, { recursive: true }); + const videoBytes = Buffer.alloc(900_000, 0x2a); + videoBytes.writeUInt32BE(24, 0); + videoBytes.write("ftyp", 4, "ascii"); + videoBytes.write("isom", 8, "ascii"); + await fs.writeFile(videoPath, videoBytes); + await fs.writeFile(path.join(themeDirectory, "theme.json"), JSON.stringify({ + schemaVersion: 2, + id: "video-fixture", + name: "Video Fixture", + image: "loop.mp4", + appearance: "dark", + media: { type: "video", playbackRate: 1.25 }, + art: { focusX: 0.5, focusY: 0.5, safeArea: "center", taskMode: "ambient" }, + })); + + const checked = await runInjector(themeDirectory); + assert.equal(checked.code, 0, checked.stderr); + const summary = JSON.parse(checked.stdout); + assert.equal(summary.media.type, "video"); + assert.equal(summary.media.mime, "video/mp4"); + assert.equal(summary.media.size, videoBytes.length); + assert.ok(summary.payloadBytes < videoBytes.length, + "The CDP bootstrap payload must not embed the complete video."); + + const expressions = []; + const session = { + async evaluate(expression) { + expressions.push(expression); + return true; + }, + }; + const transferred = await transferVideoToSession(session, { + mediaPath: videoPath, + mediaMime: "video/mp4", + mediaSize: videoBytes.length, + }); + assert.equal(transferred.bytes, videoBytes.length); + assert.ok(transferred.chunks >= 2, "The fixture should exercise chunked CDP transfer."); + assert.match(expressions[0], /\.beginMedia\(/); + assert.match(expressions.at(-1), /\.commitMedia\(\)/); + assert.ok(expressions.slice(1, -1).every((expression) => expression.includes(".appendMedia("))); + assert.ok(expressions.every((expression) => Buffer.byteLength(expression) < 1024 * 1024)); + + const invalidDirectory = path.join(temporary, "invalid"); + await fs.mkdir(invalidDirectory); + await fs.writeFile(path.join(invalidDirectory, "fake.mp4"), Buffer.from("not an mp4")); + await fs.writeFile(path.join(invalidDirectory, "theme.json"), JSON.stringify({ + id: "invalid-video", + image: "fake.mp4", + media: { type: "video" }, + })); + const rejected = await runInjector(invalidDirectory); + assert.notEqual(rejected.code, 0); + assert.match(rejected.stderr, /signature|container/i); +} finally { + await fs.rm(temporary, { recursive: true, force: true }); +} + +console.log("PASS: video themes stay out of bootstrap payloads and transfer in bounded CDP chunks."); diff --git a/windows/tests/injector-bootstrap.test.mjs b/windows/tests/injector-bootstrap.test.mjs index 1c2e918..5e8fd09 100644 --- a/windows/tests/injector-bootstrap.test.mjs +++ b/windows/tests/injector-bootstrap.test.mjs @@ -72,10 +72,10 @@ const evaluateStart = source.indexOf("await session.evaluate(earlyPayloadFor", r const probeStart = source.indexOf("const probe = await waitForCodexProbe", registrationStart); assert.ok(registrationStart >= 0 && evaluateStart > registrationStart && probeStart > evaluateStart, "New targets must register and run the early payload before full shell probing."); -assert.match(source, /if \(earlyInjectionFallback\) attachLoadFallback\(/, - "Load-event reinjection must be attached only when early injection falls back."); -assert.match(source, /if \(!fallbackTargets\.get\(id\)\) return;/, - "Fallback listeners must stay inert after a successful early registration."); +assert.match(source, /if \(earlyInjectionFallback \|\| loadedPayload\.mediaType === "video"\)/, + "Load-event reinjection must cover early-injection fallback and post-load video transfer."); +assert.match(source, /if \(!fallbackTargets\.get\(id\) && loadedPayload\?\.mediaType !== "video"\) return;/, + "Load listeners must stay inert unless fallback or video transfer is required."); assert.match(source, /Page\.removeScriptToEvaluateOnNewDocument/, "Watcher shutdown and theme refresh must unregister persistent Page scripts."); diff --git a/windows/tests/renderer-inject.test.mjs b/windows/tests/renderer-inject.test.mjs index 7803e97..3ae2771 100644 --- a/windows/tests/renderer-inject.test.mjs +++ b/windows/tests/renderer-inject.test.mjs @@ -8,9 +8,9 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const windowsRoot = path.resolve(here, ".."); const template = await fs.readFile(path.join(windowsRoot, "assets", "renderer-inject.js"), "utf8"); const css = await fs.readFile(path.join(windowsRoot, "assets", "dream-skin.css"), "utf8"); -const buildPayload = (config = {}) => template +const buildPayload = (config = {}, artDataUrl = "data:image/png;base64,AA==") => template .replace("__DREAM_CSS_JSON__", JSON.stringify(".fixture { color: blue; }")) - .replace("__DREAM_ART_JSON__", JSON.stringify("data:image/png;base64,AA==")) + .replace("__DREAM_ART_JSON__", JSON.stringify(artDataUrl)) .replace("__DREAM_THEME_JSON__", JSON.stringify(config)); const payload = buildPayload(); @@ -52,8 +52,10 @@ function createFixture({ const rootStyles = new Map(staleSkin ? [["--dream-art", "url(\"blob:stale\")"]] : []); const revokedUrls = []; const observers = []; + const documentListeners = new Map(); let objectUrlCount = 0; let hasShell = shellPresent; + let documentHidden = false; let root; const queueRootClassMutation = () => { @@ -145,9 +147,15 @@ function createFixture({ style: {}, classList: makeClassList(), parentElement: null, + src: "", + paused: true, textContent: "", innerHTML: "", setAttribute() {}, + removeAttribute(name) { if (name === "src") this.src = ""; }, + load() {}, + pause() { this.paused = true; }, + play() { this.paused = false; return Promise.resolve(); }, remove() { nodes.delete(this.id); }, }; }; @@ -164,6 +172,11 @@ function createFixture({ documentElement: root, head: root, body, + get hidden() { return documentHidden; }, + addEventListener(type, listener) { documentListeners.set(type, listener); }, + removeEventListener(type, listener) { + if (documentListeners.get(type) === listener) documentListeners.delete(type); + }, createElement, getElementById(id) { return nodes.get(id) ?? null; }, querySelector(selector) { @@ -250,6 +263,10 @@ function createFixture({ shellMainClasses, utilityClasses, setShellPresent(value) { hasShell = value; }, + setDocumentHidden(value) { + documentHidden = value; + documentListeners.get("visibilitychange")?.(); + }, }; } @@ -404,4 +421,27 @@ vm.runInNewContext(buildPayload({ artMetadata: { ratio: 16 / 9 } }), metadataWid assert.equal(metadataWide.rootClasses.has("dream-art-wide"), true); 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 }, + artMetadata: { ratio: 16 / 9 }, +}, ""), videoTheme.context); +assert.equal(videoTheme.rootClasses.has("dream-art-video"), true); +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); +assert.equal(videoState.commitMedia(), true); +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); +videoTheme.setDocumentHidden(true); +assert.equal(videoTheme.nodes.get("codex-dream-skin-media").paused, true); +videoTheme.setDocumentHidden(false); +assert.equal(videoTheme.nodes.get("codex-dream-skin-media").paused, false); +assert.equal(videoTheme.rootStyles.get("--dream-art"), "none"); +assert.equal(videoState.cleanup(), true); +assert.equal(videoTheme.nodes.has("codex-dream-skin-media"), false); +assert.deepEqual(videoTheme.revokedUrls, ["blob:fixture-1"]); + console.log("PASS: renderer applies adaptive theme metadata and preserves transparent auxiliary windows."); diff --git a/windows/tests/run-tests.ps1 b/windows/tests/run-tests.ps1 index d04393f..9a37ab5 100644 --- a/windows/tests/run-tests.ps1 +++ b/windows/tests/run-tests.ps1 @@ -647,6 +647,38 @@ try { } $null = Use-DreamSkinSavedTheme -ThemeDirectory $savedTheme.Directory -StateRoot $themeStateRoot + $videoFixture = Join-Path $temporaryRoot 'loop.mp4' + $videoBytes = New-Object byte[] 1024 + $videoBytes[0] = 0; $videoBytes[1] = 0; $videoBytes[2] = 0; $videoBytes[3] = 24 + [System.Text.Encoding]::ASCII.GetBytes('ftypisom') | ForEach-Object -Begin { $i = 4 } -Process { + $videoBytes[$i++] = $_ + } + [System.IO.File]::WriteAllBytes($videoFixture, $videoBytes) + $videoTheme = Set-DreamSkinActiveTheme -ImagePath $videoFixture -Theme $null ` + -Name '动态测试主题' -StateRoot $themeStateRoot + if ($videoTheme.Theme.media.type -cne 'video' -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.' + } + $savedVideoTheme = Save-DreamSkinCurrentTheme -Name '已保存动态主题' -StateRoot $themeStateRoot + if ($savedVideoTheme.Theme.media.type -cne 'video' -or + @(Get-DreamSkinSavedThemes -StateRoot $themeStateRoot).Count -ne 3) { + throw 'Dynamic theme save or discovery failed.' + } + $restoredVideoTheme = Use-DreamSkinSavedTheme -ThemeDirectory $savedVideoTheme.Directory ` + -StateRoot $themeStateRoot + if ($restoredVideoTheme.Theme.media.type -cne 'video') { + throw 'Saved dynamic theme did not preserve its media contract.' + } + $fakeVideo = Join-Path $temporaryRoot 'fake.mp4' + [System.IO.File]::WriteAllText($fakeVideo, 'not an mp4') + $fakeVideoRejected = $false + try { + $null = Set-DreamSkinActiveTheme -ImagePath $fakeVideo -Theme $null -StateRoot $themeStateRoot + } catch { $fakeVideoRejected = $true } + if (-not $fakeVideoRejected) { throw 'A file with a forged MP4 extension was imported as a dynamic theme.' } + $outsideTheme = Join-Path $temporaryRoot 'outside-theme' New-Item -ItemType Directory -Path $outsideTheme | Out-Null Copy-Item -LiteralPath (Join-Path $Root 'assets\dream-reference.jpg') ` @@ -723,7 +755,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', '暂停皮肤', '更换背景图', '已保存主题', '完全恢复 Codex')) { + foreach ($requiredTrayAction in @('System.Windows.Forms.NotifyIcon', '暂停皮肤', '更换背景图或视频', '*.mp4;*.webm', '已保存主题', '完全恢复 Codex')) { if (-not $traySource.Contains($requiredTrayAction)) { throw "Tray action is missing: $requiredTrayAction" } } if (-not $traySource.Contains('[AllowEmptyCollection()][System.Windows.Forms.ToolStripItemCollection]$Items')) { @@ -787,8 +819,10 @@ try { 'Ensure-DreamSkinManagedDirectory', 'Get-DreamSkinValidatedImageMetadata', '16384px / 50MP safety limit', - 'Assert-DreamSkinImageFile -Path $temporary', - 'Assert-DreamSkinImageFile -Path $imageArchive' + 'Assert-DreamSkinVideoFile', + 'Video container signature does not match', + 'Assert-DreamSkinMediaFile -Path $temporary', + 'Assert-DreamSkinMediaFile -Path $imageArchive' )) { if (-not $themeSource.Contains($requiredThemeSafety)) { throw "PowerShell theme-store safety is missing: $requiredThemeSafety" @@ -826,6 +860,9 @@ try { $rendererTest = Invoke-DreamSkinNative -FilePath $node.Path -ArgumentList @( (Join-Path $PSScriptRoot 'renderer-inject.test.mjs')) if ($rendererTest.ExitCode -ne 0) { throw 'Renderer auxiliary-window regression test failed.' } + $dynamicMediaTest = Invoke-DreamSkinNative -FilePath $node.Path -ArgumentList @( + (Join-Path $PSScriptRoot 'dynamic-media.test.mjs')) + if ($dynamicMediaTest.ExitCode -ne 0) { throw 'Dynamic media transfer regression test failed.' } $bootstrapTest = Invoke-DreamSkinNative -FilePath $node.Path -ArgumentList @( (Join-Path $PSScriptRoot 'injector-bootstrap.test.mjs')) if ($bootstrapTest.ExitCode -ne 0) { throw 'Injector early-bootstrap regression test failed.' }