Merge branch 't14-photos'
This commit is contained in:
+327
-4
@@ -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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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[]) : [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user