From 83f27d9e9024921c2a7ff26f38d06af94457a480 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Sat, 5 Sep 2026 18:24:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BE=E5=BA=93=E5=8F=8C=E6=BA=90?= =?UTF-8?q?=E6=8E=A5=E5=85=A5(Pexels+Unsplash,=E7=BB=9F=E4=B8=80=20PhotoPr?= =?UTF-8?q?ovider)=E4=B8=8E=E5=87=A0=E4=BD=95=E9=A2=84=E8=AE=BE=E6=89=A9?= =?UTF-8?q?=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - providers.ts: PHOTO_PROVIDERS 注册表,Pexels 在前(dev 速率高);providerById - unsplash.ts: 改造成 PhotoProvider(parseUnsplashResponse/unsplashProvider/ notifyDownload),共享 PhotoProviderError 定义在此叶模块避免注册表互引成环; legacy searchUnsplash/unsplashStatus/toAsset/trackDownload 保留(基座旧 SourcePanel 还在用,删了这轮 typecheck 过不了) - pexels.ts(新): parsePexelsResponse 纯函数 + pexelsProvider;Authorization 头; fullUrl 取 large2x(≈1880)不取原图;无下载统计端点,不实现 notifyDownload - 没配 key 一律静默 missing,search 抛 PhotoProviderError,模块顶层不抛 - geometry.ts: 新增 GEOMETRY_PRESETS 16 条(固定图案+人工配色,新背景菜单卡片 数据源)+ 4 个新图案家族(waves/dots/horizon/glow)渲染,复用线性亮度钳制 保证白字对比度;公开 GeometryKind 不动(旧面板缩略图字典闭合),遗留种子机制 原样;导出 contrastToWhite/relativeLuminance 供自验 - selfcheck: 手写官方形状样本断言两家解析、缺失态/错误类、预设数量与对比度 --- src/sources/geometry.ts | 331 ++++++++++++++++++++++++++++++++++++++- src/sources/index.ts | 44 ++++-- src/sources/pexels.ts | 147 +++++++++++++++++ src/sources/providers.ts | 20 +-- src/sources/selfcheck.ts | 275 ++++++++++++++++++++++++++++++-- src/sources/unsplash.ts | 226 ++++++++++++++++++++------ 6 files changed, 960 insertions(+), 83 deletions(-) create mode 100644 src/sources/pexels.ts diff --git a/src/sources/geometry.ts b/src/sources/geometry.ts index 5905663..f50a181 100644 --- a/src/sources/geometry.ts +++ b/src/sources/geometry.ts @@ -23,6 +23,11 @@ import type { BackgroundAsset } from "../types"; * 配色目标是「能压白字的背板」而不是装饰画:所有颜色都在线性空间里限过亮度—— * 底色对纯白的 WCAG 对比度至少 ~7:1,最亮的强调色也 ≥ ~4.9:1, * 饱和度压到低饱和、偏中性。 + * + * 预设(`GEOMETRY_PRESETS`,T14 新增):新背景菜单的卡片数据源,点一下直接用, + * 不需要种子。每条预设 = 固定图案 + **人工挑过的主色相**,与 legacy 4 种不同, + * 不做哈希随机配色。颜色沿用同一套线性亮度钳制,白字一定压得住。 + * 取参数走 `derivePresetParams(id, w, h)`,拿 BackgroundAsset 走 `createPresetAsset`。 */ export type GeometryKind = "stripes" | "noise" | "grid" | "arcs"; @@ -36,12 +41,22 @@ export const GEOMETRY_KINDS = [ export type RGB = readonly [number, number, number]; +/** + * 渲染种类 = 遗留的 4 种种子随机图案 + 预设专用扩展图案。 + * + * 公开的 `GeometryKind` 保持 4 种不动:基座旧 SourcePanel 的缩略图字典 + * (`Record`)按那 4 个键闭合,扩它就会砸掉 typecheck。 + * 新增图案只供 `GEOMETRY_PRESETS` 走,不进旧 UI 的种子流程。 + */ +export type RenderKind = GeometryKind | "waves" | "dots" | "horizon" | "glow"; + /** * 一张底纹的全部决定因素。只含派生结果,不含输入—— * 拿到这个结构就能渲染,也就能在 Node 里对它断言确定性。 */ export interface GeometryParams { - kind: GeometryKind; + /** 图案种类:遗留 4 种由种子派生,预设专用图案由 `derivePresetParams` 固定。 */ + kind: RenderKind; width: number; height: number; /** 种子全文的 FNV hash(u32),像素级噪声用它做盐。 */ @@ -103,6 +118,76 @@ export function deriveGeometryParams( return { kind, width, height, seed32: hFull >>> 0, u, base, toneA, toneB }; } +/* ---------------------------------------------------------------- 预设(T14 新增) */ + +/** + * 一条预设底纹:固定图案 + 人工挑过的主色相。是新背景菜单的卡片数据源 + * (遍历 `GEOMETRY_PRESETS` 渲染缩略图、点一下换上),与 legacy 的 + * 「种子随机配色」流程互不相干。 + * + * 颜色照 legacy 同一套线性亮度钳制(base≤0.10、toneA≤0.165、toneB≤0.135), + * 白字对比度由构造保证,不需要对每张缩略图人工验——selfcheck 会逐条断言。 + */ +export interface GeometryPreset { + id: string; + label: string; + kind: RenderKind; + /** 底色的主色相(度)。人工挑的,16 张卡片的色相尽量互相错开。 */ + hue: number; + /** 强调色相对底色的色相偏移(度),toneA 取整、toneB 取半(同 legacy)。 */ + hueOffset: number; +} + +/** + * 预设注册表(16 条)。顺序即界面上卡片网格的顺序:同图案的相邻放, + * 但整体按「条纹 / 噪点 / 网格 / 同心弧 / 波纹 / 点阵 / 色带 / 光斑」展开。 + */ +export const GEOMETRY_PRESETS = [ + { id: "stripe-indigo", label: "斜纹 · 靛青", kind: "stripes", hue: 220, hueOffset: 72 }, + { id: "stripe-clay", label: "斜纹 · 陶棕", kind: "stripes", hue: 24, hueOffset: 56 }, + { id: "noise-fog", label: "噪点 · 雾紫", kind: "noise", hue: 268, hueOffset: 76 }, + { id: "noise-pine", label: "噪点 · 墨绿", kind: "noise", hue: 150, hueOffset: 60 }, + { id: "grid-iris", label: "网格 · 鸢尾", kind: "grid", hue: 278, hueOffset: 84 }, + { id: "grid-abyss", label: "网格 · 深海", kind: "grid", hue: 208, hueOffset: 46 }, + { id: "arcs-rose", label: "同心弧 · 玫红", kind: "arcs", hue: 344, hueOffset: 46 }, + { id: "arcs-ember", label: "同心弧 · 余烬", kind: "arcs", hue: 20, hueOffset: 56 }, + { id: "waves-lagoon", label: "波纹 · 礁湖", kind: "waves", hue: 188, hueOffset: 84 }, + { id: "waves-denim", label: "波纹 · 牛仔", kind: "waves", hue: 236, hueOffset: 46 }, + { id: "dots-plum", label: "点阵 · 梅紫", kind: "dots", hue: 296, hueOffset: 88 }, + { id: "dots-slate", label: "点阵 · 岩蓝", kind: "dots", hue: 246, hueOffset: 64 }, + { id: "horizon-dusk", label: "色带 · 暮蓝", kind: "horizon", hue: 256, hueOffset: 80 }, + { id: "horizon-umber", label: "色带 · 赭褐", kind: "horizon", hue: 14, hueOffset: 44 }, + { id: "glow-gold", label: "光斑 · 琥珀", kind: "glow", hue: 42, hueOffset: 88 }, + { id: "glow-teal", label: "光斑 · 青瓷", kind: "glow", hue: 168, hueOffset: 96 }, +] as const satisfies readonly GeometryPreset[]; + +const PRESET_BY_ID = new Map( + GEOMETRY_PRESETS.map((p) => [p.id, p]), +); + +/** + * 预设 → 决定一张底纹的全部参数。纯函数、确定性:同 id 同尺寸永远同图。 + * 与 legacy 不同,色相不来自种子哈希而来自预设表(人工挑过), + * 只有图案的结构微调参数(带距、波长、点阵相位…)用 id 哈希取。 + */ +export function derivePresetParams( + presetId: string, + width: number, + height: number, +): GeometryParams { + const preset = PRESET_BY_ID.get(presetId) ?? GEOMETRY_PRESETS[0]; + const hFull = hashText(`preset:${preset.id}`); + const rand = mulberry32(hFull); + const u: number[] = []; + for (let i = 0; i < 16; i++) u.push(rand()); + const off = preset.hueOffset; + // 饱和度/亮度取 legacy 分布的中间档;线性亮度钳制原样复用 → 白字对比度有保证。 + const base = clampLuminance(hsl2rgb(preset.hue, 0.17, 0.24), 0.1); + const toneA = clampLuminance(hsl2rgb(preset.hue + off, 0.3, 0.44), 0.165); + const toneB = clampLuminance(hsl2rgb(preset.hue + off * 0.5, 0.22, 0.34), 0.135); + return { kind: preset.kind, width, height, seed32: hFull >>> 0, u, base, toneA, toneB }; +} + /** 渲染成不透明 RGBA 像素缓冲。纯函数,不碰 DOM。 */ export function renderGeometry(p: GeometryParams): { width: number; @@ -123,6 +208,18 @@ export function renderGeometry(p: GeometryParams): { case "arcs": renderArcs(p, data); break; + case "waves": + renderWaves(p, data); + break; + case "dots": + renderDots(p, data); + break; + case "horizon": + renderHorizon(p, data); + break; + case "glow": + renderGlow(p, data); + break; } return { width: p.width, height: p.height, data }; } @@ -337,6 +434,217 @@ function renderArcs(p: GeometryParams, out: Uint8ClampedArray): void { } } +/* ---------------------------------------------------------------- 预设专用图案(T14 新增) */ + +/** + * 波纹:横向正弦起伏的带状条纹,隔带交替 toneA/toneB,条带边缘 1px 抗锯齿。 + * 混色语义 = 向颜色逼近(sRGB 插值),任意像素亮度不会超过所选带色 → + * 白字对比度有钳制兜底,selfcheck 可以对全图断言。 + */ +function renderWaves(p: GeometryParams, out: Uint8ClampedArray): void { + const { width: w, height: h, u } = p; + fill(out, w, h, p.base); + const amp = Math.max(4, h * (0.03 + u[8] * 0.05)); // 波幅 + // 波长:一个完整正弦周期跨 ~0.45~1.35 个屏宽,越“疏”越平静 + const k = (Math.PI * 2) / Math.max(60, w * (0.45 + u[9] * 0.9)); + const ph = u[10] * Math.PI * 2; // 相位 + const pitch = Math.max(10, h * (0.075 + u[11] * 0.05)); // 带距 + const bandHalf = Math.max(3, pitch * (0.2 + u[12] * 0.08)); // 色带半宽 + const [bR, bG, bB] = p.base; + const [aR, aG, aB] = p.toneA; + const [tR, tG, tB] = p.toneB; + let i = 0; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + // 波面抬升/下沉后的纵坐标:同一“带”沿 x 方向上下起伏 + const dy = y - Math.sin(x * k + ph) * amp; + const row = Math.floor(dy / pitch); + const local = dy - row * pitch; // 本带内位置 [0, pitch) + const d = Math.abs(local - pitch / 2); + const cov = bandHalf + 0.5 - d; // 带覆盖度,边缘 1px 抗锯齿 + const useA = (row & 1) === 1; + const cR = useA ? aR : tR; + const cG = useA ? aG : tG; + const cB = useA ? aB : tB; + if (cov >= 1) { + out[i] = cR; + out[i + 1] = cG; + out[i + 2] = cB; + } else if (cov > 0) { + out[i] = bR + (cR - bR) * cov; + out[i + 1] = bG + (cG - bG) * cov; + out[i + 2] = bB + (cB - bB) * cov; + } + i += 4; + } + } +} + +/** + * 点阵:主点隔格交替 toneA/toneB(棋盘染色),格心之间补一枚更小的副点(另一色)。 + * 每个像素最多被两次 mix 影响、且都向不高于 toneA 亮度的颜色逼近。 + */ +function renderDots(p: GeometryParams, out: Uint8ClampedArray): void { + const { width: w, height: h, u } = p; + fill(out, w, h, p.base); + const g = Math.max(12, w * 0.042 * (0.85 + u[8] * 0.5)); // 格距(1600 宽约 57~90px,缩略图也成阵) + const offX = (u[9] - 0.5) * g; // 整幅点阵的相位偏移,避免永远居中 + const offY = (u[10] - 0.5) * g; + const rMain = g * (0.19 + u[11] * 0.09); + const rSub = rMain * 0.55; + const [bR, bG, bB] = p.base; + const [aR, aG, aB] = p.toneA; + const [tR, tG, tB] = p.toneB; + let i = 0; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const xv = x + 0.5 - offX; + const yv = y + 0.5 - offY; + const kx = Math.round(xv / g); + const ky = Math.round(yv / g); + const dx = xv - kx * g; + const dy = yv - ky * g; + const aMain = lineCoverage(Math.sqrt(dx * dx + dy * dy), rMain); + const mainA = mod(kx + ky, 2) === 0; // 棋盘:相邻格子的主点异色 + // 副点错开半格,落在主点之间的空隙里 + const sx = xv - (kx + 0.5) * g; + const sy = yv - (ky + 0.5) * g; + const aSub = lineCoverage(Math.sqrt(sx * sx + sy * sy), rSub); + const mA = mainA ? aR : tR; + const mG = mainA ? aG : tG; + const mB = mainA ? aB : tB; + const sA = mainA ? tR : aR; + const sG = mainA ? tG : aG; + const sB = mainA ? tB : aB; + let r = bR; + let gg = bG; + let b = bB; + if (aMain > 0) { + r = bR + (mA - bR) * aMain; + gg = bG + (mG - bG) * aMain; + b = bB + (mB - bB) * aMain; + } + if (aSub > 0) { + r = r + (sA - r) * aSub; + gg = gg + (sG - gg) * aSub; + b = b + (sB - b) * aSub; + } + out[i++] = r | 0; + out[i++] = gg | 0; + out[i++] = b | 0; + out[i++] = 255; + } + } +} + +/** + * 分层色带:几段「扁平色带 + 平滑过渡」水平堆叠,像分层渐变的封面底色。 + * 自上而下 = base → toneB → base → toneA:最亮的一段贴底边、占比最小, + * 像地平线的余光;往上都是深色段,留给白字。过渡宽 W 固定 ~5% 高, + * 防止色带相接处出现硬边。 + */ +function renderHorizon(p: GeometryParams, out: Uint8ClampedArray): void { + const { width: w, height: h, u } = p; + fill(out, w, h, p.base); + // 三条分界,位置由盐决定但确定性。界与界之间留足空间,过渡带互不重叠。 + const b1 = h * (0.28 + u[8] * 0.1); + const b2 = h * (0.5 + u[9] * 0.12); + const b3 = h * (0.72 + u[10] * 0.1); + const W = Math.max(4, h * 0.05); + const zones: RGB[] = [p.base, p.toneB, p.base, p.toneA]; + const bound = [0, b1, b2, b3, h]; + const seed = p.seed32; + let i = 0; + for (let y = 0; y < h; y++) { + // 找出 y 落在哪一段:段色为主,向下一条段的边界靠近时向段色渐变。 + // 过渡只从下段往上段推进,跨过边界时权重已到 1,段与段之间无硬边。 + let zi = 3; + while (zi > 0 && y < bound[zi]) zi--; + let r = zones[zi][0]; + let gg = zones[zi][1]; + let b = zones[zi][2]; + const up = zi < 3 ? bound[zi + 1] : h + 1; + // 最顶段(zi=3)没有上邻,不能再往 zones[4] 过渡 + if (zi < 3 && y > up - 2 * W) { + const t = clamp01((y - (up - 2 * W)) / (2 * W)); + const e = t * t * (3 - 2 * t); // smoothstep,避免线性过渡的顿角 + const nz = zones[zi + 1]; + r = r + (nz[0] - r) * e; + gg = gg + (nz[1] - gg) * e; + b = b + (nz[2] - b) * e; + } + for (let x = 0; x < w; x++) { + // 细噪点压色带渐变的等高线(banding),幅度只有 1 档 + const n = (hash2d(x, y, seed) - 0.5) * 2 * 1.2; + out[i++] = clamp255(r + n); + out[i++] = clamp255(gg + n); + out[i++] = clamp255(b + n); + out[i++] = 255; + } + } +} + +/** + * 光斑:几枚柔边光斑(径向平方衰减)错落在深色底上。颜色只取 toneA/toneB, + * 同一像素取“最强光斑”混色而不是叠加,保证亮度不超过单枚光斑色 → 白字安全。 + */ +function renderGlow(p: GeometryParams, out: Uint8ClampedArray): void { + const { width: w, height: h, u } = p; + fill(out, w, h, p.base); + const seed = p.seed32; + const N = 3 + ((u[8] * 3) | 0); // 3~5 枚 + const spots: { + cx: number; + cy: number; + r: number; + c: RGB; + }[] = []; + const short = Math.min(w, h); + for (let n = 0; n < N; n++) { + spots.push({ + // 位置/半径用 hash2d(n, 盐) 取,确定性且互不相关 + cx: w * (0.16 + hash2d(n, 11, seed) * 0.68), + cy: h * (0.14 + hash2d(n, 13, seed) * 0.72), + r: short * (0.16 + hash2d(n, 17, seed) * 0.2), + c: (n & 1) === 0 ? p.toneA : p.toneB, + }); + } + const [bR, bG, bB] = p.base; + let i = 0; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + let best = 0; // 最强光斑的强度 + let cR = bR; + let cG = bG; + let cB = bB; + for (const s of spots) { + const dx = x - s.cx; + const dy = y - s.cy; + if (dx > s.r || dx < -s.r || dy > s.r || dy < -s.r) continue; // 包围盒裁剪 + const t = Math.sqrt(dx * dx + dy * dy) / s.r; + if (t >= 1) continue; + const inten = (1 - t) * (1 - t); // 平方衰减,边缘柔和 + if (inten > best) { + best = inten; + cR = s.c[0]; + cG = s.c[1]; + cB = s.c[2]; + } + } + if (best > 0) { + out[i] = (bR + (cR - bR) * best) | 0; + out[i + 1] = (bG + (cG - bG) * best) | 0; + out[i + 2] = (bB + (cB - bB) * best) | 0; + } + i += 4; + } + } +} + +function clamp01(v: number): number { + return v < 0 ? 0 : v > 1 ? 1 : v; +} + /* ---------------------------------------------------------------- 工具 */ function fill(out: Uint8ClampedArray, w: number, h: number, color: RGB): void { @@ -424,17 +732,22 @@ function toSrgb(lin: number): number { return v; } -/** WCAG 相对亮度(0-1)。 */ -function luminance(rgb: RGB): number { +/** WCAG 相对亮度(0-1)。导出给 selfcheck 与预设白字对比度校验用。 */ +export function relativeLuminance(rgb: RGB): number { return 0.2126 * linearize(rgb[0]) + 0.7152 * linearize(rgb[1]) + 0.0722 * linearize(rgb[2]); } +/** 该颜色对纯白 #fff 的 WCAG 对比度(纯白相对亮度 = 1)。1.4+ 才算普通文字可读。 */ +export function contrastToWhite(rgb: RGB): number { + return 1.05 / (relativeLuminance(rgb) + 0.05); +} + /** * 若颜色相对亮度超过上限,就在**线性空间**里整体缩小(保持色相与色度不变), * 让它与纯白的对比度达标。这保证任意种子/色相都不会产出太亮的底。 */ function clampLuminance(rgb: RGB, maxLum: number): RGB { - const l = luminance(rgb); + const l = relativeLuminance(rgb); if (l <= maxLum) return rgb; const k = maxLum / l; return [ @@ -469,3 +782,13 @@ export function createGeometryAsset( const params = deriveGeometryParams(seedText, kind, width, height); return { kind: "geometry", src: geometryToDataUrl(params), width, height }; } + +/** 预设版:点一下直接换上的背景图(无种子),取参走 `derivePresetParams`。 */ +export function createPresetAsset( + presetId: string, + width: number, + height: number, +): BackgroundAsset { + const params = derivePresetParams(presetId, width, height); + return { kind: "geometry", src: geometryToDataUrl(params), width, height }; +} diff --git a/src/sources/index.ts b/src/sources/index.ts index 8e93d91..e67d3ab 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -1,33 +1,59 @@ /** * 背景来源汇总出口。 * - * 三个来源各写一个模块,都返回/产出 `BackgroundAsset`(形状见 `@/types`): + * 各来源都写成一个自包含模块,统一产出 `BackgroundAsset`(形状见 `@/types`): * - upload.ts 本地文件 → object URL - * - geometry.ts 预设几何底纹:纯函数派生+渲染,DOM 只出现在薄薄一层 - * - unsplash.ts Unsplash 搜索。Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读 + * - geometry.ts 预设几何底纹:纯函数派生 + 渲染;`GEOMETRY_PRESETS` 是 + * 新背景菜单的卡片数据源(固定图案 + 人工配色,无种子), + * `GEOMETRY_KINDS` 是 legacy 的种子随机配色 4 种 + * - pexels.ts / unsplash.ts 图库搜索,都实现 `PhotoProvider` 接口, + * Pexels 在前(`PHOTO_PROVIDERS` 见 providers.ts) * - * SourcePanel 按需从子模块直接 import;这里只是把表面聚成一个稳定出口, - * 供面板以外的地方统一消费。 + * T4 时代的 legacy 函数面(`searchUnsplash` / `toAsset` 等)保留到旧 + * SourcePanel 被 t13 清掉为止;新界面一律走 `PHOTO_PROVIDERS`。 */ -export { fromUpload, releaseObjectUrl } from "./upload"; +export { fromUpload, releaseObjectUrl, pickImageFiles } from "./upload"; export { deriveGeometryParams, + derivePresetParams, renderGeometry, geometryToDataUrl, createGeometryAsset, + createPresetAsset, GEOMETRY_KINDS, + GEOMETRY_PRESETS, + contrastToWhite, + relativeLuminance, +} from "./geometry"; +export type { + GeometryParams, + GeometryKind, + RenderKind, + GeometryPreset, + RGB, } from "./geometry"; -export type { GeometryParams, GeometryKind, RGB } from "./geometry"; export { unsplashStatus, searchUnsplash, trackDownload, toAsset, - UnsplashError, + parseUnsplashResponse, + unsplashProvider, } from "./unsplash"; export type { UnsplashStatus, UnsplashPhoto } from "./unsplash"; -export type { BackgroundAsset, Attribution } from "../types"; +export { parsePexelsResponse, pexelsProvider } from "./pexels"; + +export { PHOTO_PROVIDERS, providerById, PhotoProviderError } from "./providers"; + +export type { + BackgroundAsset, + Attribution, + StockPhoto, + PhotoProvider, + PhotoProviderId, + PhotoProviderStatus, +} from "../types"; diff --git a/src/sources/pexels.ts b/src/sources/pexels.ts new file mode 100644 index 0000000..b81de7d --- /dev/null +++ b/src/sources/pexels.ts @@ -0,0 +1,147 @@ +import type { + Attribution, + BackgroundAsset, + PhotoProvider, + PhotoProviderStatus, + StockPhoto, +} from "../types"; +import { PhotoProviderError } from "./unsplash"; + +/** + * Pexels 搜索。与 unsplash.ts 一样归一到 `PhotoProvider` 接口。 + * + * - API Key 从 `import.meta.env.VITE_PEXELS_KEY` 读(请求头 `Authorization: `, + * 没有 "Bearer " 前缀;Unsplash 才是 `Client-ID` 前缀)。 + * - 共享错误类 `PhotoProviderError` 定义在 unsplash.ts 那个不依赖兄弟模块的叶子里, + * 从那里 import,避免和 providers.ts 的注册表互相引成环。 + * - **没配 key 时不崩**:静默处于 "missing" 态;`search()` 抛 `PhotoProviderError`。 + * - 速率配额比 Unsplash demo key 高得多(200/时 vs 50/时),dev 期主用这家, + * 所以 `PHOTO_PROVIDERS` 里它排在前面(providers.ts 组装)。 + * - Pexels 的署名是「建议」而非「强制」,也没有下载统计端点—— + * `notifyDownload` 不实现(接口里它是可选方法,调用方无条件调即可)。 + */ + +/** 纯函数解析:按官方文档形状构造的响应样本 → 归一化 StockPhoto[]。Node 侧可断言。 */ +export function parsePexelsResponse(json: unknown): StockPhoto[] { + const root = toRecord(json); + if (!root || !Array.isArray(root.photos)) return []; + const out: StockPhoto[] = []; + for (const item of root.photos) { + const photo = toStockPhoto(toRecord(item)); + if (photo) out.push(photo); + } + return out; +} + +export const pexelsProvider: PhotoProvider = { + id: "pexels", + label: "Pexels", + keyUrl: "https://www.pexels.com/api/new/", + status(): PhotoProviderStatus { + return readKey() ? "ready" : "missing"; + }, + search: async (query, page) => { + const key = readKey(); + if (!key) throw new PhotoProviderError("未配置 Pexels API Key(VITE_PEXELS_KEY)"); + const q = query.trim(); + if (!q) return []; + return fetchPexels(q, page, key); + }, + toAsset: toStockAsset, +}; + +/** 面板能直接用:一条归一化结果 → 一份可进画布的 BackgroundAsset。 */ +function toStockAsset(photo: StockPhoto): BackgroundAsset { + const attribution: Attribution = { + text: `Photo by ${photo.authorName} on Pexels`, + url: photo.pageUrl, + }; + return { + kind: "stock", + src: photo.fullUrl, + width: photo.width, + height: photo.height, + attribution, + }; +} + +/* ---------------------------------------------------------------- 内部 */ + +function readKey(): string { + // import.meta.env 只保证在 Vite 里存在。用可选链兜底,Node/Bun/静态产物都安全。 + const meta = import.meta as { env?: { VITE_PEXELS_KEY?: unknown } }; + const raw = meta.env?.VITE_PEXELS_KEY; + return typeof raw === "string" ? raw.trim() : ""; +} + +function toRecord(v: unknown): Record | null { + return v && typeof v === "object" && !Array.isArray(v) + ? (v as Record) + : null; +} + +// 字段照官方文档形状防御式取值;界面用不到的字段(photographer_url / avg_color / +// liked / src.portrait|landscape|tiny 等)一律不解析——不确定的字段不编造。 +/** 一张 Pexels 原始对象 → StockPhoto。缺 id / 图片地址的结果没法用,返回 null。 */ +function toStockPhoto(raw: Record | null): StockPhoto | null { + if (!raw) return null; + const id = typeof raw.id === "number" ? String(raw.id) : undefined; + const url = typeof raw.url === "string" ? raw.url : undefined; + const photographer = typeof raw.photographer === "string" ? raw.photographer : ""; + const width = typeof raw.width === "number" ? raw.width : 0; + const height = typeof raw.height === "number" ? raw.height : 0; + const alt = typeof raw.alt === "string" ? raw.alt.trim() || null : null; + const src = toRecord(raw.src); + const s = (k: string): string => { + const v = src?.[k]; + return typeof v === "string" ? v : ""; + }; + if (!id || !url) return null; + const original = s("original"); + const large2x = s("large2x"); + const large = s("large"); + const medium = s("medium"); + const small = s("small"); + if (!large2x && !large && !original) return null; // 一张图连大图档都没有,没法装进画布 + return { + id, + provider: "pexels", + width, + height, + thumbUrl: medium || small || large2x || large, + // 装进画布用约 1600px 的档,不要原图(原图动辄 6000px)。 + // Pexels 的档位是宽档:large2x ≈ 1880px,large ≈ 940px,按 brief 优先 large2x。 + fullUrl: large2x || large || original, + pageUrl: url, + authorName: photographer || "Pexels 用户", + alt, + // Pexels 没有下载统计端点,downloadLocation 不填。 + }; +} + +/** 实际请求。错误路径统一抛 PhotoProviderError。 */ +async function fetchPexels(query: string, page: number, key: string): Promise { + const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&page=${page}&per_page=15`; + let res: Response; + try { + res = await fetch(url, { headers: { Authorization: key } }); + } catch { + throw new PhotoProviderError("连不上 Pexels(网络或跨域限制)"); + } + if (!res.ok) { + if (res.status === 401) throw new PhotoProviderError("Pexels API Key 无效,检查 .env.local"); + if (res.status === 403 || res.status === 429) + throw new PhotoProviderError("Pexels 请求被限流,等一会儿再试"); + // 非预期状态:响应体里可能有官方 error 字段,能读就读出来给用户看。 + let detail = ""; + try { + const body = toRecord((await res.json()) as unknown); + detail = typeof body?.error === "string" ? `:${body.error}` : ""; + } catch { + /* 读不出就算了,用状态码兜底 */ + } + throw new PhotoProviderError(`Pexels 返回 ${res.status}${detail}`); + } + const data = (await res.json()) as unknown; + return parsePexelsResponse(data); +} diff --git a/src/sources/providers.ts b/src/sources/providers.ts index 753f8fe..744647b 100644 --- a/src/sources/providers.ts +++ b/src/sources/providers.ts @@ -1,20 +1,22 @@ import type { PhotoProvider, PhotoProviderId } from "@/types"; +import { pexelsProvider } from "./pexels"; +import { unsplashProvider } from "./unsplash"; -/** 图库调用失败(没配 key、网络错误、被限流)。界面据此显示一句话,不让路径崩掉。 */ -export class PhotoProviderError extends Error { - constructor(message: string) { - super(message); - this.name = "PhotoProviderError"; - } -} +// 共享错误类定义在 unsplash.ts(依赖最少的叶子模块),这里 re-export 保持桩文件 +// 原本的出口路径;pexels.ts 也直接 import 它,避免 providers ↔ 各家模块引成环。 +export { PhotoProviderError } from "./unsplash"; /** - * 【T14 待实现】图库注册表。 + * 图库注册表。 * * 顺序即界面上标签页的顺序。**Pexels 在前**:Unsplash demo key 每小时只有 50 次 * 请求,开发期点几下就没了;Pexels 是每小时 200 次、每月 2 万次。 + * 界面只认 `PhotoProvider` 接口遍历这里,不写 `if (provider === "pexels")`。 */ -export const PHOTO_PROVIDERS: PhotoProvider[] = []; +export const PHOTO_PROVIDERS: readonly PhotoProvider[] = [ + pexelsProvider, + unsplashProvider, +]; export function providerById(id: PhotoProviderId): PhotoProvider | undefined { return PHOTO_PROVIDERS.find((p) => p.id === id); diff --git a/src/sources/selfcheck.ts b/src/sources/selfcheck.ts index 3920588..7e0cb03 100644 --- a/src/sources/selfcheck.ts +++ b/src/sources/selfcheck.ts @@ -1,13 +1,39 @@ /** - * 【T10】Node 侧自验:多文件拖放的取值逻辑。运行:`bun src/sources/selfcheck.ts` + * Node 侧自验。运行:`bun src/sources/selfcheck.ts` * - * 拖放一次可能给一批混合文件(图片、文档、压缩包…),这条线把「从里面 - * 挑出图片、保持相对顺序」抽成纯函数 `pickImageFiles`(src/sources/upload.ts), - * 这里断言它:只挑图片、顺序不变、空 type / 非图片全部排除。取值逻辑不 - * 依赖浏览器,Node 里就能验;真正把图拖进窗口的行为由主控用 Chrome 验。 + * 【T10】多文件拖放的取值逻辑:拖放一次可能给一批混合文件(图片、文档、压缩包…), + * 这条线把「从里面挑出图片、保持相对顺序」抽成纯函数 `pickImageFiles` + * (src/sources/upload.ts),这里断言它。取值不依赖浏览器,Node 里就能验; + * 真正把图拖进窗口的行为由主控用 Chrome 验。 + * + * 【T14】图库接入(Pexels / Unsplash)与几何预设: + * - 两家 API 响应 → StockPhoto 的解析是纯函数,这里喂**手写的、按官方文档形状 + * 构造的**响应样本断言(主控还没拿到 key,跑不了真实请求)。 + * - 没配 key 时两家 provider 的 `status()` 必须是 "missing"、`search()` 抛的 + * 必须是共享的 `PhotoProviderError`(模块静默缺失,绝不顶层抛异常白屏)。 + * - `GEOMETRY_PRESETS` ≥ 12 条、id 唯一;每条底色对白字 ≥ ~7:1、 + * 强调色也压得住;新增的 4 个图案家族(波纹/点阵/色带/光斑)逐像素断言 ≥ 4.5:1。 + * 注意:跑这段时若 `.env.local` 里真配了 key,status/search 两组断言会翻—— + * 它测的就是「没 key」这条路。 */ import assert from "node:assert/strict"; import { pickImageFiles } from "./upload"; +import { parsePexelsResponse, pexelsProvider } from "./pexels"; +import { + parseUnsplashResponse, + searchUnsplash, + unsplashProvider, + unsplashStatus, +} from "./unsplash"; +import { PHOTO_PROVIDERS, PhotoProviderError, providerById } from "./providers"; +import { + GEOMETRY_PRESETS, + derivePresetParams, + renderGeometry, + contrastToWhite, +} from "./geometry"; + +/* ---------------- 0. T10:拖放取值(原保留) ---------------- */ /** 模拟 File 的最小形状:拖放取值只用到 mime type。 */ interface FakeFile { @@ -15,8 +41,6 @@ interface FakeFile { type: string; } -/* ---------------- 1. 混合文件:只挑图片,顺序不变 ---------------- */ - const mixed: FakeFile[] = [ { name: "封面主图.png", type: "image/png" }, { name: "说明.txt", type: "text/plain" }, @@ -34,15 +58,11 @@ assert.deepEqual( "只挑出图片,且相对顺序与拖入时一致", ); -console.log("✓ 混合文件:4 张图片被挑出,txt/zip/json 被剔除,顺序不变"); - -/* ---------------- 2. 边界:没有图片 / 空 type / 空数组 ---------------- */ - assert.equal(pickImageFiles([]).length, 0, "空拖放 → 空结果"); const noImage: FakeFile[] = [ { name: "笔记.txt", type: "text/plain" }, - { name: "无扩展名", type: "" }, // 系统认不出 mime 的也当非图片 + { name: "无扩展名", type: "" }, { name: "包.zip", type: "application/zip" }, ]; assert.equal(pickImageFiles(noImage).length, 0, "不含图片时返回空数组,不报错"); @@ -54,8 +74,6 @@ assert.deepEqual( "type 为空的文件不进(不猜后缀)", ); -/* ---------------- 3. 全是图片:原样全收,顺序不变 ---------------- */ - const allImage: FakeFile[] = [ { name: "1.webp", type: "image/webp" }, { name: "2.png", type: "image/png" }, @@ -63,6 +81,233 @@ const allImage: FakeFile[] = [ ]; assert.deepEqual(pickImageFiles(allImage), allImage, "全是图片时顺序原样返回"); -console.log("✓ 边界:空 / 全非图片 / 空 type / 全图片,四种情况取值都正确"); +console.log("✓ T10 拖放取值:混合/空/非图片/全图片四组断言通过"); + +/* ---------------- 1. T14:Pexels 响应解析(手写官方形状样本) ---------------- */ + +interface PexelsSamplePhoto { + id: number; + width?: number; + height?: number; + url?: string; + photographer?: string; + src?: { + original?: string; + large2x?: string; + large?: string; + medium?: string; + small?: string; + }; + /** 老照片可能没有 alt(Pexels 2021 年后才稳定返回)。 */ + alt?: string | null; +} + +const PEXELS_SAMPLE: { total_results: number; photos: PexelsSamplePhoto[] } = { + total_results: 2, + photos: [ + { + id: 2014422, + width: 3000, + height: 2000, + url: "https://www.pexels.com/photo/brown-and-gray-mountains-2014422/", + photographer: "Lukas", + src: { + original: "https://images.pexels.com/photos/2014422/pexels-photo-2014422.jpeg", + large2x: + "https://images.pexels.com/photos/2014422/pexels-photo-2014422.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940", + large: + "https://images.pexels.com/photos/2014422/pexels-photo-2014422.jpeg?auto=compress&cs=tinysrgb&h=650&w=940", + medium: + "https://images.pexels.com/photos/2014422/pexels-photo-2014422.jpeg?auto=compress&cs=tinysrgb&h=350&w=350", + small: + "https://images.pexels.com/photos/2014422/pexels-photo-2014422.jpeg?auto=compress&cs=tinysrgb&h=130&w=130", + }, + alt: "Brown and gray mountains at daytime", + }, + { + // 老照片:没有 alt、photographer 空串、缺 medium/small 档——都要能兜住 + id: 3584578, + width: 5000, + height: 3333, + url: "https://www.pexels.com/photo/3584578/", + photographer: "", + src: { + large2x: + "https://images.pexels.com/photos/3584578/pexels-photo-3584578.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940", + large: + "https://images.pexels.com/photos/3584578/pexels-photo-3584578.jpeg?auto=compress&cs=tinysrgb&h=650&w=940", + }, + }, + // 结构烂到没法用的条目(没 url / 没 src)应被跳过而不是抛异常 + { id: 999, width: 10, height: 10 }, + { id: 888, photographer: "Ghost" }, + ], +}; + +const pexelsParsed = parsePexelsResponse(PEXELS_SAMPLE); +assert.equal(pexelsParsed.length, 2, "能用条目全解析,烂条目被跳过不抛"); +const px0 = pexelsParsed[0]; +assert.equal(px0.provider, "pexels"); +assert.equal(px0.id, "2014422", "数字 id 归一成字符串"); +assert.equal(px0.authorName, "Lukas"); +assert.equal(px0.alt, "Brown and gray mountains at daytime"); +assert.equal( + px0.fullUrl, + PEXELS_SAMPLE.photos[0].src!.large2x, + "取大图档 large2x(≈1880px),不取原图", +); +assert.equal(px0.thumbUrl, PEXELS_SAMPLE.photos[0].src!.medium); +assert.equal(px0.pageUrl, PEXELS_SAMPLE.photos[0].url); +assert.equal(px0.width, 3000); +assert.equal(px0.height, 2000); +assert.equal(px0.downloadLocation, undefined, "Pexels 没有下载统计端点,不填"); + +const px1 = pexelsParsed[1]; +assert.equal(px1.alt, null, "老图没有 alt → null 而不是 undefined"); +assert.equal(px1.authorName, "Pexels 用户", "photographer 空串要兜底"); +assert.ok(px1.fullUrl.endsWith("w=940"), "缺 medium 档不影响大图档选择"); + +console.log("✓ Pexels 解析:官方形状样本 → StockPhoto,烂条目跳过、缺字段兜底"); + +/* ---------------- 2. T14:Unsplash 响应解析 ---------------- */ + +interface UnsplashSamplePhoto { + id: string; + width?: number; + height?: number; + description?: string; + alt_description?: string | null; + urls?: { raw?: string; regular?: string; small?: string; thumb?: string }; + links?: { html?: string; download_location?: string }; + user?: { name?: string }; +} + +const UNSPLASH_SAMPLE: { results: UnsplashSamplePhoto[] } = { + results: [ + { + id: "abc123", + width: 4000, + height: 6000, + description: "A misty forest at dawn", + alt_description: "pine trees near lake", + urls: { + raw: "https://images.unsplash.com/photo-1?ixid=Mnwx&ixlib=rb-4.0.3", + regular: "https://images.unsplash.com/photo-1?ixid=Mnwx&ixlib=rb-4.0.3&w=1080", + small: "https://images.unsplash.com/photo-1?ixid=Mnwx&ixlib=rb-4.0.3&w=400", + thumb: "https://images.unsplash.com/photo-1?ixid=Mnwx&ixlib=rb-4.0.3&w=200", + }, + links: { + html: "https://unsplash.com/photos/abc123", + download_location: "https://api.unsplash.com/photos/abc123/download?ixid=Mnwx", + }, + user: { name: "Jane Doe" }, + }, + { + // 只有 description、没有 raw/作者——退到 regular + 兜底作者 + id: "def456", + width: 2000, + height: 1000, + description: "coast line", + alt_description: "", + urls: { + regular: "https://images.unsplash.com/photo-2?ixid=Mnwy&w=1080", + }, + links: { html: "https://unsplash.com/photos/def456" }, + user: {}, + }, + ], +}; + +const unsplashParsed = parseUnsplashResponse(UNSPLASH_SAMPLE); +assert.equal(unsplashParsed.length, 2); +const us0 = unsplashParsed[0]; +assert.equal(us0.provider, "unsplash"); +assert.equal(us0.id, "abc123"); +assert.equal(us0.alt, "pine trees near lake", "alt_description 优先于 description"); +assert.equal(us0.authorName, "Jane Doe"); +assert.ok(us0.fullUrl.includes("w=1600"), "raw 追加 &w=1600(≈1600px 长边),不取原图"); +assert.ok(!us0.fullUrl.includes("w=1080"), "raw 存在时用 raw+w1600 而不是 regular"); +assert.equal( + us0.pageUrl, + "https://unsplash.com/photos/abc123?utm_source=cover-studio&utm_medium=referral", + "署名链接带 utm_source", +); +assert.equal(us0.downloadLocation, "https://api.unsplash.com/photos/abc123/download?ixid=Mnwx"); +assert.equal(us0.thumbUrl, UNSPLASH_SAMPLE.results[0].urls!.small, "网格缩略图取 small"); + +const us1 = unsplashParsed[1]; +assert.equal( + us1.fullUrl, + "https://images.unsplash.com/photo-2?ixid=Mnwy&w=1080", + "没有 raw 时退到 regular", +); +assert.equal(us1.alt, "coast line", "alt 为空串时退到 description"); +assert.equal(us1.authorName, "Unsplash 用户", "user.name 缺失要兜底"); + +console.log("✓ Unsplash 解析:官方形状样本 → StockPhoto,raw/regular 档位选择正确"); + +/* ---------------- 3. T14:注册表顺序 + 没配 key 的缺失态 ---------------- */ + +assert.deepEqual( + PHOTO_PROVIDERS.map((p) => p.id), + ["pexels", "unsplash"], + "PHOTO_PROVIDERS 顺序 = 界面标签页顺序,Pexels 在前(dev 速率高)", +); +assert.equal(providerById("pexels"), pexelsProvider); +assert.equal(providerById("unsplash"), unsplashProvider); + +for (const p of PHOTO_PROVIDERS) { + assert.equal(p.status(), "missing", `${p.id} 没配 key 时应是 missing(模块静默,不顶层抛)`); + await assert.rejects( + p.search("forest", 1), + (err) => err instanceof PhotoProviderError, + `${p.id}.search() 没配 key 时抛 PhotoProviderError,而不是别的`, + ); +} +assert.equal(unsplashStatus(), "missing", "legacy unsplashStatus 同步读同一个 key"); +await assert.rejects( + searchUnsplash("forest"), + (err) => err instanceof PhotoProviderError, + "legacy searchUnsplash 也走同一错误类(老面板显示 message 用)", +); + +console.log("✓ 注册表与缺失态:Pexels 在前,没 key 时两家 status=missing、search 抛 PhotoProviderError"); + +/* ---------------- 4. T14:几何预设的数量与白字对比度 ---------------- */ + +assert.ok( + GEOMETRY_PRESETS.length >= 12, + `预设要 ≥ 12 条(现在是 ${GEOMETRY_PRESETS.length})`, +); +const ids = new Set(GEOMETRY_PRESETS.map((p) => p.id)); +assert.equal(ids.size, GEOMETRY_PRESETS.length, "预设 id 不能重复"); + +// 新加的 4 个图案家族渲染语义是「向 ≤toneA 亮度的颜色混」,可对全图逐像素断言; +// legacy 4 种(斜纹/噪点/网格/同心弧)的渲染器没动,其中网格线是 legacy 的加色 +// 语义(细线会亮过单色),只断言三档色板本身——那几档决定大面积底色与白字对比。 +const NEW_FAMILIES = new Set(["waves", "dots", "horizon", "glow"]); + +for (const preset of GEOMETRY_PRESETS) { + const p = derivePresetParams(preset.id, 64, 36); + const name = `${preset.id}(${preset.kind})`; + const baseC = contrastToWhite(p.base); + const toneAC = contrastToWhite(p.toneA); + const toneBC = contrastToWhite(p.toneB); + assert.ok(baseC >= 6.8, `${name} 底色对白字对比度 ${baseC.toFixed(2)} 应 ≥ ~7:1`); + assert.ok(toneAC >= 4.7, `${name} 主强调色 ${toneAC.toFixed(2)} 应 ≥ ~4.9:1`); + assert.ok(toneBC >= 5.5, `${name} 副强调色 ${toneBC.toFixed(2)} 应 ≥ ~5.7:1`); + + if (NEW_FAMILIES.has(preset.kind)) { + const { data } = renderGeometry(p); + let min = Infinity; + for (let i = 0; i < data.length; i += 4) { + const c = contrastToWhite([data[i], data[i + 1], data[i + 2]]); + if (c < min) min = c; + } + assert.ok(min >= 4.5, `${name} 渲染全图最亮像素对白字 ${min.toFixed(2)}:1 也应 ≥ 4.5`); + } +} + +console.log(`✓ 几何预设:${GEOMETRY_PRESETS.length} 条,色板白字压得住(新家族逐像素 ≥4.5:1)`); console.log("\n全部 Node 侧自验通过 ✅"); diff --git a/src/sources/unsplash.ts b/src/sources/unsplash.ts index 28ed0ab..360c9e2 100644 --- a/src/sources/unsplash.ts +++ b/src/sources/unsplash.ts @@ -1,27 +1,48 @@ -import type { Attribution, BackgroundAsset } from "../types"; +import type { + Attribution, + BackgroundAsset, + PhotoProvider, + PhotoProviderStatus, + StockPhoto, +} from "../types"; /** - * Unsplash 搜索。 + * Unsplash 搜索。T14 起归一到 `PhotoProvider` 接口(界面只认接口,不写 + * `if (provider === "unsplash")`),同时**保留 T4 时代的 legacy 函数面** + * (`searchUnsplash` / `unsplashStatus` / `toAsset` / `trackDownload` / + * `UnsplashPhoto`):基座里的旧 SourcePanel 还直接 import 它们,删了会砸掉 + * 这一轮的 typecheck;等 t13 把旧面板清掉后这些只是没人用的历史出口。 * - * - Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读,用户放在 `.env.local`(已 gitignore)。 + * - Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读,用户放在 `.env.local`。 * - **没配 key 时不崩**:模块静默处于 "missing" 态,面板据状态只显示提示, - * 上传与预设几何不受影响。所有对外函数都先查 `unsplashStatus()`。 - * - 署名按用户要求**不画进图里**:`toAsset()` 把 attribution 填进 + * 上传与预设几何不受影响。所有对外搜索先查 key,缺了就抛 `PhotoProviderError`。 + * - 署名按用户要求**不画进图里**:`toAsset` 把 attribution 填进 * `BackgroundAsset.attribution`,由面板显示成可复制文本,用户贴进文章正文。 * - 用户选定一张图时按 API 条款触发 download 统计(fire-and-forget)。 */ -export type UnsplashStatus = "missing" | "ready"; +/* ------------------------------------------------------------------ 共享错误 */ -/** 网络/参数层面的可预期错误,面板据此显示一句话,不让整条路径崩掉。 */ -export class UnsplashError extends Error { +/** + * 图库调用失败(没配 key、网络错误、被限流)。界面据此显示一句话。 + * + * 故意定义在这个**不依赖任何兄弟模块**的叶子里:`pexels.ts` 从这 import、 + * `providers.ts` 从这里 re-export。若定义在 providers.ts 而让两家 provider + * 反过去 import 它,就会和 providers 的注册表互相 import 成环——ESM 环里 + * 谁先被加载谁就踩 TDZ(旧 SourcePanel 只 import unsplash 时会先踩到)。 + */ +export class PhotoProviderError extends Error { constructor(message: string) { super(message); - this.name = "UnsplashError"; + this.name = "PhotoProviderError"; } } -/** 一张搜索结果,带网格展示与装进画布所需的全部信息。 */ +/* ------------------------------------------------------------------ legacy 类型 */ + +export type UnsplashStatus = PhotoProviderStatus; + +/** 一张搜索结果,带网格展示与装进画布所需的全部信息(T4 时代的形状)。 */ export interface UnsplashPhoto { id: string; width: number; @@ -38,12 +59,16 @@ export interface UnsplashPhoto { downloadLocation: string; } +/* ------------------------------------------------------------------ 状态 */ + /** 当前是否配了 key。env 在 Vite 里必然有;Node/Bun/构建产物里可能没有,一律当缺失。 */ export function unsplashStatus(): UnsplashStatus { return readKey() ? "ready" : "missing"; } -/** 面板能直接用:一条 Unsplash 结果 → 一份可进画布的 BackgroundAsset。 */ +/* ------------------------------------------------------------------ legacy 导出(T4 SourcePanel 还在用) */ + +/** legacy:一条 Unsplash 结果 → 一份可进画布的 BackgroundAsset。 */ export function toAsset(photo: UnsplashPhoto): BackgroundAsset { const attribution: Attribution = { text: `Photo by ${photo.authorName} on Unsplash`, @@ -58,33 +83,13 @@ export function toAsset(photo: UnsplashPhoto): BackgroundAsset { }; } +/** legacy:单页搜索(不带分页参数),结果仍是 UnsplashPhoto。新界面走 provider。 */ export async function searchUnsplash(query: string): Promise { - const key = readKey(); - if (!key) throw new UnsplashError("未配置 Unsplash Access Key(VITE_UNSPLASH_KEY)"); - const q = query.trim(); - if (!q) return []; - - const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(q)}&per_page=12&client_id=${encodeURIComponent(key)}`; - let res: Response; - try { - res = await fetch(url, { headers: { "Accept-Version": "v1" } }); - } catch { - throw new UnsplashError("连不上 Unsplash(网络或跨域限制)"); - } - if (!res.ok) { - if (res.status === 401) throw new UnsplashError("Unsplash Access Key 无效,检查 .env.local"); - if (res.status === 403 || res.status === 429) - throw new UnsplashError("Unsplash 请求被限流,等一会儿再试"); - throw new UnsplashError(`Unsplash 返回 ${res.status}`); - } - const data = (await res.json()) as UnsplashSearchResponse; - if (Array.isArray(data.errors) && data.errors.length) { - throw new UnsplashError(data.errors[0]); - } - return (data.results ?? []).map(toPhoto); + const raws = await fetchRawUnsplash(query, 1, 12); + return raws.map(toLegacyPhoto); } -/** 用户选定一张图时调用:触发 download 统计,条款义务。失败静默,不影响使用。 */ +/** legacy:用户选定一张图时触发 download 统计,条款义务。失败静默。 */ export function trackDownload(photo: UnsplashPhoto): void { const key = readKey(); if (!key || !photo.downloadLocation) return; @@ -94,6 +99,50 @@ export function trackDownload(photo: UnsplashPhoto): void { }); } +/* ------------------------------------------------------------------ PhotoProvider */ + +/** 纯函数解析:官方响应样本 → 归一化 StockPhoto[]。selfcheck 在 Node 里喂样本断言。 */ +export function parseUnsplashResponse(json: unknown): StockPhoto[] { + const root = toRecord(json); + if (!root || !Array.isArray(root.results)) return []; + // 与 provider.search 共用同一转换(单个元素缺 id/图片时 toStockPhoto 会跳过)。 + return stocksFromRaws(root.results as UnsplashRawPhoto[]); +} + +export const unsplashProvider: PhotoProvider = { + id: "unsplash", + label: "Unsplash", + // 申请 key 的页面,status 为 missing 时界面把它显示出来。 + keyUrl: "https://unsplash.com/oauth/applications", + status: unsplashStatus, + search: async (query, page) => stocksFromRaws(await fetchRawUnsplash(query, page, 15)), + toAsset: toStockAsset, + /** 条款义务:用户选定一张图时打一次 download 统计。只有 Unsplash 有。 */ + notifyDownload(photo: StockPhoto): void { + const key = readKey(); + if (!key || !photo.downloadLocation) return; + const sep = photo.downloadLocation.includes("?") ? "&" : "?"; + fetch(`${photo.downloadLocation}${sep}client_id=${encodeURIComponent(key)}`).catch(() => { + /* download 统计失败不影响用户操作 */ + }); + }, +}; + +/** 面板能直接用:一条归一化结果 → 一份可进画布的 BackgroundAsset。 */ +function toStockAsset(photo: StockPhoto): BackgroundAsset { + const attribution: Attribution = { + text: `Photo by ${photo.authorName} on Unsplash`, + url: photo.pageUrl, + }; + return { + kind: "stock", + src: photo.fullUrl, + width: photo.width, + height: photo.height, + attribution, + }; +} + /* ---------------------------------------------------------------- 内部 */ function readKey(): string { @@ -103,13 +152,14 @@ function readKey(): string { return typeof raw === "string" ? raw.trim() : ""; } -type UnsplashSearchResponse = { - results?: UnsplashRawPhoto[]; - errors?: string[]; -}; +function appendParam(url: string, param: string): string { + const sep = url.includes("?") ? "&" : "?"; + return `${url}${sep}${param}`; +} +/** 原始 JSON 只取我们用得到的字段;没见过的字段一律不碰(不编造)。 */ type UnsplashRawPhoto = { - id: string; + id?: string; width?: number; height?: number; alt_description?: string | null; @@ -126,15 +176,51 @@ type UnsplashRawPhoto = { const UTM = "utm_source=cover-studio&utm_medium=referral"; -function toPhoto(raw: UnsplashRawPhoto): UnsplashPhoto { - const authorName = raw.user?.name?.trim() || "Unsplash 用户"; - const pageUrl = raw.links?.html ? `${raw.links.html}?${UTM}` : `https://unsplash.com/?${UTM}`; - const description = raw.description ?? null; +function authorNameOf(raw: UnsplashRawPhoto): string { + return raw.user?.name?.trim() || "Unsplash 用户"; +} + +function pageUrlOf(raw: UnsplashRawPhoto): string { + return raw.links?.html ? `${raw.links.html}?${UTM}` : `https://unsplash.com/?${UTM}`; +} + +/** + * raw → StockPhoto(provider / parseUnsplashResponse 共用)。 + * 空字段太多、连缩略图和原图页都没有的结果没法用,返回 null 让上层跳过。 + */ +function toStockPhoto(raw: UnsplashRawPhoto): StockPhoto | null { + const id = raw.id; + const urls = raw.urls; + const pageUrl = raw.links?.html; + if (!id || !urls || !pageUrl) return null; + const rawUrl = urls.raw ?? ""; + const regularUrl = urls.regular ?? ""; return { - id: raw.id, + id, + provider: "unsplash", width: raw.width ?? 0, height: raw.height ?? 0, - description, + thumbUrl: urls.small || urls.thumb || regularUrl, + // 装进画布用约 1600px 的档,不要原图(原图动辄 6000px,又慢又会撞滤镜纹理上限)。 + // imgix 的 raw 本身就是带参数的 URL,追加 &w=1600 等比缩到宽 1600。 + fullUrl: rawUrl ? appendParam(rawUrl, "w=1600") : regularUrl, + pageUrl: `${pageUrl}?${UTM}`, + authorName: authorNameOf(raw), + // alt 优先,退到 description;Unsplash 两种都可能给空串,trim 后当没有。 + alt: raw.alt_description?.trim() || raw.description?.trim() || null, + downloadLocation: raw.links?.download_location ?? undefined, + }; +} + +/** raw → legacy UnsplashPhoto(保持 T4 语义原样,别和 StockPhoto 解析互相牵连)。 */ +function toLegacyPhoto(raw: UnsplashRawPhoto): UnsplashPhoto { + const authorName = authorNameOf(raw); + const pageUrl = pageUrlOf(raw); + return { + id: raw.id ?? "", + width: raw.width ?? 0, + height: raw.height ?? 0, + description: raw.description ?? null, altDescription: raw.alt_description ?? null, authorName, thumbUrl: raw.urls?.small || raw.urls?.thumb || raw.urls?.regular || "", @@ -143,3 +229,51 @@ function toPhoto(raw: UnsplashRawPhoto): UnsplashPhoto { downloadLocation: raw.links?.download_location ?? "", }; } + +/** raw 数组 → StockPhoto[](provider.search 用;parse 系列只认整棵官方响应)。 */ +function stocksFromRaws(raws: readonly UnsplashRawPhoto[]): StockPhoto[] { + const out: StockPhoto[] = []; + for (const raw of raws) { + const s = toStockPhoto(raw); + if (s) out.push(s); + } + return out; +} + +function toRecord(v: unknown): Record | null { + return v && typeof v === "object" && !Array.isArray(v) + ? (v as Record) + : null; +} + +/** 拉一次搜索原始结果;错误路径统一抛 PhotoProviderError。 */ +async function fetchRawUnsplash( + query: string, + page: number, + perPage: number, +): Promise { + const key = readKey(); + if (!key) throw new PhotoProviderError("未配置 Unsplash Access Key(VITE_UNSPLASH_KEY)"); + const q = query.trim(); + if (!q) return []; + + const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(q)}&page=${page}&per_page=${perPage}&client_id=${encodeURIComponent(key)}`; + let res: Response; + try { + res = await fetch(url, { headers: { "Accept-Version": "v1" } }); + } catch { + throw new PhotoProviderError("连不上 Unsplash(网络或跨域限制)"); + } + if (!res.ok) { + if (res.status === 401) throw new PhotoProviderError("Unsplash Access Key 无效,检查 .env.local"); + if (res.status === 403 || res.status === 429) + throw new PhotoProviderError("Unsplash 请求被限流,等一会儿再试"); + throw new PhotoProviderError(`Unsplash 返回 ${res.status}`); + } + const data = (await res.json()) as { results?: unknown[]; errors?: string[] }; + if (Array.isArray(data.errors) && data.errors.length) { + throw new PhotoProviderError(data.errors[0]); + } + // 让 provider 与纯解析走同一条转换,解析逻辑只有一份。 + return Array.isArray(data.results) ? (data.results as UnsplashRawPhoto[]) : []; +}