mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-28 14:24:03 +08:00
capture-previews writes previews into the dsh-skins working tree, so market:fetch --force does not pick them up: --force only re-materializes an input whose worktree already sits on the pinned commit, and a worktree elsewhere is ignored in favour of the pinned tarball. The header now names the local-content mode for looking at fresh previews before the commit is pinned (and that the resulting market/dist must not be committed), and the pinned flow for the copy that ships. docs/development.md's skin section carried the same --force step.
179 lines
8.1 KiB
JavaScript
Executable File
179 lines
8.1 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
'use strict'
|
||
|
||
/**
|
||
* capture-previews — regenerate every skin's light/dark preview screenshots.
|
||
*
|
||
* Opens market/dist/preview.html (the v2 static renderer: the simulator
|
||
* injects the skin's stylesheets into the official-facade snapshot and
|
||
* paints declarative backgroundMedia; skin hooks JS is never executed) in headless Chromium and
|
||
* screenshots each skin × {light, dark} into the skin source working tree,
|
||
* satellites/dsh-skins/skins/<id>/preview/{light,dark}.jpg. dsh-skins is a
|
||
* submodule and the market input pinned by its gitlink (market-inputs.lock.json),
|
||
* so the previews land in the repository that owns the skins.
|
||
*
|
||
* Requires playwright + its chromium browser:
|
||
* npm i -D playwright && npx playwright install chromium
|
||
* or run via npx: npx playwright@latest install chromium
|
||
*
|
||
* Usage:
|
||
* node scripts/capture-previews # all skins, light + dark
|
||
* node scripts/capture-previews xp miku # only named skins
|
||
* node scripts/capture-previews --serve # serve via http instead of file://
|
||
*
|
||
* Outputs 1440×900 JPEGs (quality 85) at device scale 1; the market cards
|
||
* downscale them, so they stay crisp while the published package stays
|
||
* small (the old 2880×1800 PNGs made dsh-client-ui-skin-center ~78MB).
|
||
* Re-run after any skin change and commit the JPEGs in dsh-skins together with
|
||
* the skin source. To see them in the market before that commit is pinned here,
|
||
* read the submodule working tree:
|
||
*
|
||
* pnpm market:fetch --local --force # the working tree, not the pinned commit
|
||
* pnpm market:build # unpinned content: do not commit market/dist
|
||
*
|
||
* Once the submodule gitlink on this branch names the new dsh-skins commit, refresh
|
||
* the committed copy with `pnpm market:fetch && pnpm market:build` and commit
|
||
* market/dist together with the gitlink.
|
||
*/
|
||
|
||
const { execSync } = require('node:child_process')
|
||
const fs = require('node:fs')
|
||
const http = require('node:http')
|
||
const path = require('node:path')
|
||
|
||
const ROOT = path.resolve(__dirname, '..')
|
||
const MARKET_DIST = path.join(ROOT, 'market', 'dist')
|
||
const SKINS_REPO = path.join(ROOT, 'satellites', 'dsh-skins')
|
||
|
||
// --- tiny static server (fallback for environments where file:// screenshots
|
||
// --- misbehave) -------------------------------------------------------------
|
||
let server = null
|
||
|
||
/** True when `file` is strictly inside `root` (matched at a path boundary). */
|
||
function isInside(root, file) {
|
||
const rel = path.relative(root, file)
|
||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
||
}
|
||
|
||
function serve() {
|
||
const types = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }
|
||
server = http.createServer((req, res) => {
|
||
const url = decodeURIComponent((req.url || '/').split('?')[0])
|
||
const file = path.normalize(path.join(MARKET_DIST, url === '/' ? 'preview.html' : url))
|
||
if (!isInside(MARKET_DIST, file)) { res.writeHead(403); return res.end('forbidden') }
|
||
fs.readFile(file, (err, buf) => {
|
||
if (err) { res.writeHead(404); return res.end('not found') }
|
||
res.writeHead(200, { 'content-type': types[path.extname(file)] || 'application/octet-stream' })
|
||
res.end(buf)
|
||
})
|
||
})
|
||
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
|
||
resolve(`http://127.0.0.1:${server.address().port}`)
|
||
}))
|
||
}
|
||
|
||
function loadPlaywright() {
|
||
try { return require('playwright') } catch (e) { /* fall through */ }
|
||
try {
|
||
const local = path.join(ROOT, 'node_modules', 'playwright')
|
||
return require(local)
|
||
} catch (e) {
|
||
console.error('playwright not found. Install it:\n npm i -D playwright && npx playwright install chromium')
|
||
process.exit(1)
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const args = process.argv.slice(2)
|
||
const want = args.filter((a) => !a.startsWith('--'))
|
||
const useServer = args.includes('--serve')
|
||
|
||
const manifest = JSON.parse(fs.readFileSync(path.join(MARKET_DIST, 'manifest.js'), 'utf8')
|
||
.replace(/^\/\*[\s\S]*?\*\/\s*/, '') // 头部生成注释
|
||
.replace(/^window\.SKIN_MANIFEST = /, '')
|
||
.replace(/;\s*$/, ''))
|
||
let skins = manifest.skins
|
||
if (want.length) {
|
||
const set = new Set(want)
|
||
skins = skins.filter((s) => set.has(s.id))
|
||
}
|
||
|
||
const base = useServer ? await serve() : 'file://' + MARKET_DIST
|
||
console.log(`capture-previews: ${skins.length} skins × {light,dark} @ ${base}`)
|
||
|
||
// hooks 动态场景皮肤清单(作用于 capture 渲染,不影响模拟器默认行为)
|
||
const HOOK_SCENE_SKINS = ['maid-atelier', 'orca-link']
|
||
|
||
const { chromium } = loadPlaywright()
|
||
const browser = await chromium.launch()
|
||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 })
|
||
|
||
for (const skin of skins) {
|
||
const skinDir = path.join(SKINS_REPO, 'skins', skin.id)
|
||
if (!fs.existsSync(skinDir)) {
|
||
throw new Error(`${skin.id}: ${path.relative(ROOT, skinDir)} not found — the dsh-skins submodule is missing or sits on another commit; run 'git submodule update --init satellites/dsh-skins'`)
|
||
}
|
||
const dir = path.join(skinDir, 'preview')
|
||
fs.mkdirSync(dir, { recursive: true })
|
||
for (const theme of ['light', 'dark']) {
|
||
const url = `${base}/preview.html?skin=${skin.id}&theme=${theme}&chrome=0`
|
||
await page.goto(url, { waitUntil: 'networkidle' })
|
||
// 等皮肤样式注入完成:模拟器在 <html> 上写 data-dsh-skin(与真实加载器
|
||
// 的作用域属性一致),或渲染错误横幅。
|
||
await page.waitForFunction(() => {
|
||
const err = document.getElementById('skinErr')
|
||
if (err && err.style.display === 'block') return true
|
||
return document.documentElement.dataset.dshSkin !== undefined
|
||
}, undefined, { timeout: 10000 })
|
||
const errText = await page.evaluate(() => {
|
||
const el = document.getElementById('skinErr')
|
||
return el && el.style.display === 'block' ? el.textContent : null
|
||
})
|
||
if (errText) throw new Error(`${skin.id}/${theme}: ${errText}`)
|
||
// 等声明式背景画真正解码完成(冷启动首次加载 1600x900 WebP 可能慢于
|
||
// 固定等待,只等 data-dsh-skin 会先于 renderSkinBackdrop 命中)
|
||
await page.waitForFunction(() => {
|
||
const layer = document.getElementById('skin-backdrop')
|
||
if (!layer) return true
|
||
const img = layer.querySelector('img')
|
||
if (img) return img.complete && img.naturalWidth > 0
|
||
const video = layer.querySelector('video')
|
||
if (video) return video.readyState >= 2
|
||
return true
|
||
}, undefined, { timeout: 15000 })
|
||
await page.waitForTimeout(300) // 让 scrim/过渡稳定
|
||
// 部分皮肤的角色场景由 hooks.mjs 动态绘制(如 maid-atelier 的宫殿+双女仆),
|
||
// 模拟器默认不执行皮肤 JS;capture 时对这类皮肤用最小 ctx shim 运行 apply(),
|
||
// 让预览与真实观感一致(需 --serve:模块加载走 http)。
|
||
if (useServer && HOOK_SCENE_SKINS.indexOf(skin.id) !== -1) {
|
||
await page.evaluate(async (id) => {
|
||
const mod = await import('/assets/skins/' + id + '/hooks.mjs?t=' + Date.now())
|
||
const factory = mod && mod.default
|
||
if (typeof factory !== 'function') return
|
||
const hooks = factory()
|
||
if (!hooks || typeof hooks.apply !== 'function') return
|
||
hooks.apply({
|
||
assetBase: '/assets/skins/' + id,
|
||
scopeAttr: id,
|
||
theme: { get: () => (document.body.dataset.dsDarkTheme !== undefined ? 'dark' : 'light') },
|
||
onCleanup: function () {},
|
||
})
|
||
}, skin.id)
|
||
await page.waitForTimeout(700)
|
||
}
|
||
const out = path.join(dir, `${theme}.jpg`)
|
||
await page.screenshot({ path: out, type: 'jpeg', quality: 85 })
|
||
console.log(` ok ${skin.id}/${theme} → ${path.relative(ROOT, out)}`)
|
||
}
|
||
}
|
||
|
||
await browser.close()
|
||
if (server) server.close()
|
||
console.log(`done. commit the previews in ${path.relative(ROOT, SKINS_REPO)}, then run: pnpm market:fetch --local --force && pnpm market:build`)
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error(err)
|
||
process.exit(1)
|
||
})
|