feat: M5 背景图上传/压缩+智能取色(FR-A8/A9,smart-color 下沉 shared,canvas 采样→analyzeImage 推导强调色/模糊/表面不透明尊重手动项,持久化+重开恢复)

This commit is contained in:
hunter-dev
2026-08-26 09:20:45 +08:00
parent 545bc38370
commit 81b410e281
11 changed files with 222 additions and 96 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ export function registerIpc(vault: VaultService) {
});
ipcMain.handle(IPC.appearanceLoad, async () => vault.getAppearance());
ipcMain.handle(IPC.appearanceSave, async (_e, p: { themeId: string; params: Record<string, unknown>; effect: string }) => { await vault.saveAppearance(p); });
ipcMain.handle(IPC.appearanceSave, async (_e, p: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }) => { await vault.saveAppearance(p); });
ipcMain.handle(IPC.keymapLoad, async () => vault.getKeymaps());
ipcMain.handle(IPC.keymapSave, async (_e, p: { id: string; combo: string }[]) => { await vault.saveKeymaps(p); });
+2 -81
View File
@@ -1,81 +1,2 @@
// 智能取色:分析背景图亮度/主色 → 推荐深浅模式与强调色(FR-A9)
// 纯函数,输入采样后的像素数据,输出推荐参数
export interface ImageSample {
/** 采样像素的 [r,g,b] 列表(已降采样) */
pixels: [number, number, number][];
}
export interface SmartColorResult {
mode: 'dark' | 'light';
accentHue: number; // 0-360,约束在冷色/紫红区间
accent: string; // hex 强调色
suggestedBgOpacity: number; // 0-1
suggestedBlur: number; // px
suggestedSurfaceOpacity: number; // 0-1
}
// 相对亮度(Rec.709)
function luminance(r: number, g: number, b: number): number {
const f = (c: number) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0; const l = (max + min) / 2; const d = max - min; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
if (d !== 0) {
switch (max) {
case r: h = ((g - b) / d) % 6; break;
case g: h = (b - r) / d + 2; break;
default: h = (r - g) / d + 4;
}
h *= 60; if (h < 0) h += 360;
}
return [h, s, l];
}
function hslToHex(h: number, s: number, l: number): string {
const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x];
const to = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
return `#${to(r)}${to(g)}${to(b)}`;
}
// 色相约束:冷色(180-270) 与紫红(280-330) 区间,否则就近映射
function constrainHue(h: number): number {
if (h >= 180 && h <= 330) return h;
// 映射到最近区间端点
if (h < 180) return h < 90 ? 200 : 210; // 暖色偏冷蓝
return 290; // >330 映射紫红
}
export function analyzeImage(sample: ImageSample): SmartColorResult {
const { pixels } = sample;
if (pixels.length === 0) {
return { mode: 'dark', accentHue: 210, accent: '#4a9eff', suggestedBgOpacity: 0.6, suggestedBlur: 20, suggestedSurfaceOpacity: 0.5 };
}
// 平均亮度
const avgLum = pixels.reduce((s, p) => s + luminance(p[0], p[1], p[2]), 0) / pixels.length;
const mode: 'dark' | 'light' = avgLum < 0.5 ? 'dark' : 'light';
// 主色相:色相直方图(12 桶),取高频且饱和度足够
const buckets = new Array(12).fill(0);
let dominantHue = 210;
for (const p of pixels) {
const [h, s] = rgbToHsl(p[0], p[1], p[2]);
if (s > 0.15) buckets[Math.floor(h / 30) % 12]++;
}
let max = 0;
for (let i = 0; i < 12; i++) if (buckets[i] > max) { max = buckets[i]; dominantHue = i * 30 + 15; }
const accentHue = constrainHue(dominantHue);
const accent = hslToHex(accentHue, 0.7, mode === 'dark' ? 0.6 : 0.45);
// 依亮度推导不透明度
const suggestedBgOpacity = Math.round(Math.max(0.3, Math.min(0.8, 0.5 + (0.5 - avgLum) * 0.4)) * 100) / 100;
const suggestedBlur = avgLum < 0.4 ? 24 : avgLum > 0.7 ? 12 : 18;
const suggestedSurfaceOpacity = mode === 'dark' ? 0.5 : 0.7;
return { mode, accentHue, accent, suggestedBgOpacity, suggestedBlur, suggestedSurfaceOpacity };
}
// 智能取色纯函数已下沉到 @hunter/shared;此处保留薄层 re-export(兼容历史引用)
export { analyzeImage, type ImageSample, type SmartColorResult } from '@hunter/shared';
@@ -6,13 +6,13 @@ import { RECENT_VAULT_MAX } from '@hunter/shared';
export class ConfigStore {
private file: string;
private cache: { vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string }; keymaps?: { id: string; combo: string }[] } | null = null;
private cache: { vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }; keymaps?: { id: string; combo: string }[] } | null = null;
constructor(userDataDir: string) {
this.file = nodePath.join(userDataDir, 'config.json');
}
private async read(): Promise<{ vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string }; keymaps?: { id: string; combo: string }[] }> {
private async read(): Promise<{ vault?: VaultMeta; appearance?: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }; keymaps?: { id: string; combo: string }[] }> {
if (this.cache) return this.cache;
try {
const raw = await fs.readFile(this.file, 'utf8');
@@ -46,11 +46,11 @@ export class ConfigStore {
return meta;
}
async getAppearance(): Promise<{ themeId: string; params: Record<string, unknown>; effect: string } | null> {
async getAppearance(): Promise<{ themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null } | null> {
const c = await this.read();
return c.appearance ?? null;
}
async saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string }): Promise<void> {
async saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }): Promise<void> {
(await this.read()).appearance = data;
await this.write();
}
@@ -51,7 +51,7 @@ export class VaultService {
async setDefaultRoot(root: string): Promise<void> { await this.config.setDefaultRoot(root); }
getAppearance() { return this.config.getAppearance(); }
saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string }) { return this.config.saveAppearance(data); }
saveAppearance(data: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }) { return this.config.saveAppearance(data); }
getKeymaps() { return this.config.getKeymaps(); }
saveKeymaps(data: { id: string; combo: string }[]) { return this.config.saveKeymaps(data); }
}