feat: 图库双源接入(Pexels+Unsplash,统一 PhotoProvider)与几何预设扩充

- 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: 手写官方形状样本断言两家解析、缺失态/错误类、预设数量与对比度
This commit is contained in:
2026-09-05 18:24:11 +08:00
parent 19c8c604cb
commit 83f27d9e90
6 changed files with 960 additions and 83 deletions
+327 -4
View File
@@ -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<GeometryKind, string>`)按那 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<string, GeometryPreset>(
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 };
}
+35 -9
View File
@@ -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";
+147
View File
@@ -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: <key>`,
* 没有 "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<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
}
// 字段照官方文档形状防御式取值;界面用不到的字段(photographer_url / avg_color /
// liked / src.portrait|landscape|tiny 等)一律不解析——不确定的字段不编造。
/** 一张 Pexels 原始对象 → StockPhoto。缺 id / 图片地址的结果没法用,返回 null。 */
function toStockPhoto(raw: Record<string, unknown> | 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<StockPhoto[]> {
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);
}
+11 -9
View File
@@ -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);
+260 -15
View File
@@ -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 侧自验通过 ✅");
+180 -46
View File
@@ -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<UnsplashPhoto[]> {
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<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
}
/** 拉一次搜索原始结果;错误路径统一抛 PhotoProviderError。 */
async function fetchRawUnsplash(
query: string,
page: number,
perPage: number,
): Promise<UnsplashRawPhoto[]> {
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[]) : [];
}