feat: 背景来源——本地上传 / 预设几何 / Unsplash 搜索
SourcePanel 常驻在右栏「背景」一节,三个来源任选: - 本地上传:文件选择 + 全窗口拖放,转 object URL 装进画布, 换图与卸载时释放旧的 blob 地址 - 预设几何:四种底纹(斜向圆角条纹 / 噪点渐变 / 网格线 / 同心弧), 用标题字符串作种子派生配色,同种子像素级确定——纯函数派生+渲染, DOM 只留在薄薄一层出口,Node 侧可直接验确定性;色相按前缀分桶, 同系列标题自动落在相邻色区 - Unsplash 搜索:Access Key 读 VITE_UNSPLASH_KEY,未配置只显示提示、 另两个来源照常可用;缩略图网格点选装 regular 尺寸;选定即触发 download 统计(条款义务);署名不画进图,面板上给复制按钮
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
import type { BackgroundAsset } from "../types";
|
||||
|
||||
/**
|
||||
* 预设几何底纹。
|
||||
*
|
||||
* 动机:用户有 13 篇文章至今没有封面,其中「单元测试」系列 7 篇全缺——
|
||||
* 大概率就是「这篇没有能当封面的图」。几何底纹兜住这种情况:
|
||||
* 没有合适的照片也能生成一张能压白字的背景。
|
||||
*
|
||||
* 结构:`deriveGeometryParams` + `renderGeometry` 都是**不依赖 DOM 的纯函数**,
|
||||
* 直接往 RGBA 像素缓冲里写;DOM 只出现在最薄的出口层
|
||||
* (`geometryToDataUrl` / `createGeometryAsset`,浏览器里才用得到)。
|
||||
* 因此同一份几何逻辑能在 Node 侧直接验确定性:同一种子跑两次像素完全一致。
|
||||
*
|
||||
* 确定性靠两个约定:
|
||||
* - 随机序列来自种子字符串的 FNV hash + mulberry32,全程不用 `Math.random`;
|
||||
* - 同一套参数只产同一份缓冲,`toDataURL` 只是它的无损包装。
|
||||
*
|
||||
* 种子建议填标题:同一系列标题前缀相同 → 落进同一个 45° 色区,
|
||||
* 系列内不同篇再在色区内按全文 hash 微差几度,列表里自然成组又不至于一模一样。
|
||||
*/
|
||||
|
||||
export type GeometryKind = "stripes" | "noise" | "grid" | "arcs";
|
||||
|
||||
export const GEOMETRY_KINDS = [
|
||||
{ id: "stripes", label: "斜向条纹" },
|
||||
{ id: "noise", label: "噪点渐变" },
|
||||
{ id: "grid", label: "网格线" },
|
||||
{ id: "arcs", label: "同心弧" },
|
||||
] as const satisfies readonly { id: GeometryKind; label: string }[];
|
||||
|
||||
export type RGB = readonly [number, number, number];
|
||||
|
||||
/**
|
||||
* 一张底纹的全部决定因素。只含派生结果,不含输入——
|
||||
* 拿到这个结构就能渲染,也就能在 Node 里对它断言确定性。
|
||||
*/
|
||||
export interface GeometryParams {
|
||||
kind: GeometryKind;
|
||||
width: number;
|
||||
height: number;
|
||||
/** 种子全文的 FNV hash(u32),像素级噪声用它做盐。 */
|
||||
seed32: number;
|
||||
/** 由种子确定性产出的 [0,1) 随机流,各底纹各取所需。 */
|
||||
u: readonly number[];
|
||||
/** 底色,最深,白色文字主要压在这上面。 */
|
||||
base: RGB;
|
||||
/** 主强调色,中明度低饱和。 */
|
||||
toneA: RGB;
|
||||
/** 副强调色,比 toneA 沉一点,用来做交替/层次。 */
|
||||
toneB: RGB;
|
||||
}
|
||||
|
||||
/** 由种子文本派生一组确定性参数。纯函数。 */
|
||||
export function deriveGeometryParams(
|
||||
seedText: string,
|
||||
kind: GeometryKind,
|
||||
width: number,
|
||||
height: number,
|
||||
): GeometryParams {
|
||||
const text = (seedText ?? "").trim() || "cover";
|
||||
const hFull = hashText(text);
|
||||
const hPrefix = hashText(text.slice(0, 4));
|
||||
const rand = mulberry32(hFull);
|
||||
const u: number[] = [];
|
||||
for (let i = 0; i < 16; i++) u.push(rand());
|
||||
|
||||
// 色相分桶:前 4 个字符决定 45° 色区 → 同前缀同色区;
|
||||
// 桶内再按全文 hash 偏 0~30°,同系列不同篇相邻成组。
|
||||
const hue = ((hPrefix >>> 0) % 8) * 45 + (((hFull >>> 0) % 3000) / 100);
|
||||
const off = 46 + u[1] * 54; // 主色到强调色的色相偏移(46~100°,类比色相,不花)
|
||||
|
||||
const s = Math.min(0.44, 0.28 + u[0] * 0.16); // 低饱和:底是要被压白字的
|
||||
const base = hsl2rgb(hue, s, 0.27 + u[2] * 0.06);
|
||||
const toneA = hsl2rgb(hue + off, Math.min(s + 0.06, 0.5), 0.52 + u[3] * 0.1);
|
||||
const toneB = hsl2rgb(hue + off * 0.5, s * 0.92, 0.44 + u[4] * 0.06);
|
||||
|
||||
return { kind, width, height, seed32: hFull >>> 0, u, base, toneA, toneB };
|
||||
}
|
||||
|
||||
/** 渲染成不透明 RGBA 像素缓冲。纯函数,不碰 DOM。 */
|
||||
export function renderGeometry(p: GeometryParams): {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8ClampedArray;
|
||||
} {
|
||||
const data = new Uint8ClampedArray(p.width * p.height * 4);
|
||||
switch (p.kind) {
|
||||
case "stripes":
|
||||
renderStripes(p, data);
|
||||
break;
|
||||
case "noise":
|
||||
renderNoiseGradient(p, data);
|
||||
break;
|
||||
case "grid":
|
||||
renderGrid(p, data);
|
||||
break;
|
||||
case "arcs":
|
||||
renderArcs(p, data);
|
||||
break;
|
||||
}
|
||||
return { width: p.width, height: p.height, data };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 各底纹 */
|
||||
|
||||
/**
|
||||
* 斜向粗圆角条纹:沿对角方向的平行圆角粗条(胶囊),隔行交错配色铺满整幅。
|
||||
* 旋转坐标系里 u 沿条纹方向、v 垂直;v 方向每 rowPitch 一根条(条+空隙),
|
||||
* 每根条由多个圆头段(capsule)排成虚线状,两端是圆角。
|
||||
*/
|
||||
function renderStripes(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||
const { width: w, height: h, u } = p;
|
||||
const inv = Math.SQRT1_2;
|
||||
const slope = u[8] < 0.5 ? 1 : -1; // 取两个斜向之一
|
||||
const halfT = Math.max(5, w * 0.02); // 条宽的一半(粗)
|
||||
const len = Math.max(halfT * 3.2, w * 0.16); // 每段圆头条长度
|
||||
const period = len * 1.75; // 段长 + 段间空隙
|
||||
const rowPitch = halfT * 4; // 条宽 + 行间空隙
|
||||
const phase = u[9] * period;
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
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++) {
|
||||
const yy = y - cy;
|
||||
const sy = slope * yy;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const xx = x - cx;
|
||||
// 投影到「沿条方向 t × 垂直方向 v」(都以画布中心为原点,两向都穿全幅)
|
||||
const t = (xx + sy) * inv;
|
||||
const v = (xx - sy) * inv;
|
||||
const row = Math.round(v / rowPitch);
|
||||
const vc = v - row * rowPitch; // 相对本行条中心线的垂直偏移
|
||||
const tShift = t - phase;
|
||||
const col = Math.floor(tShift / period);
|
||||
const tt = tShift - col * period; // 本段位置 [0, period),条本体 [0, len]
|
||||
const du = tt > len ? tt - len : 0;
|
||||
const d = Math.sqrt(du * du + vc * vc);
|
||||
const a = halfT + 0.5 - d; // capsule:圆头圆角,边缘 1px 抗锯齿
|
||||
const useA = (row & 1) === 1;
|
||||
let r = bR;
|
||||
let g = bG;
|
||||
let b = bB;
|
||||
if (a >= 1) {
|
||||
r = useA ? aR : tR;
|
||||
g = useA ? aG : tG;
|
||||
b = useA ? aB : tB;
|
||||
} else if (a > 0) {
|
||||
r = useA ? bR + (aR - bR) * a : bR + (tR - bR) * a;
|
||||
g = useA ? bG + (aG - bG) * a : bG + (tG - bG) * a;
|
||||
b = useA ? bB + (aB - bB) * a : bB + (tB - bB) * a;
|
||||
}
|
||||
out[i++] = r | 0;
|
||||
out[i++] = g | 0;
|
||||
out[i++] = b | 0;
|
||||
out[i++] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 噪点渐变:对角渐变(toneB → base,明度从中到低)+ 每像素确定性细噪点。 */
|
||||
function renderNoiseGradient(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||
const { width: w, height: h, u } = p;
|
||||
const sx = u[8] < 0.5 ? 1 : -1;
|
||||
const sy = u[9] < 0.5 ? 1 : -1;
|
||||
const amp = 4 + u[10] * 5; // 噪点幅度
|
||||
// 对角投影 t 的取值区间(四个角的极值)
|
||||
let lo = 0;
|
||||
let hi = 0;
|
||||
for (const [cx, cy] of [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[0, h],
|
||||
[w, h],
|
||||
] as const) {
|
||||
const v = sx * (cx / w) + sy * (cy / h);
|
||||
if (v < lo) lo = v;
|
||||
if (v > hi) hi = v;
|
||||
}
|
||||
const span = hi - lo || 1;
|
||||
const [fR, fG, fB] = p.toneB; // 浅端
|
||||
const [tR, tG, tB] = p.base; // 深端
|
||||
const seed = p.seed32;
|
||||
|
||||
let i = 0;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const yy = sy * (y / h);
|
||||
for (let x = 0; x < w; x++) {
|
||||
const t = (sx * (x / w) + yy - lo) / span;
|
||||
const n = (hash2d(x, y, seed) - 0.5) * 2 * amp;
|
||||
out[i++] = clamp255(fR + (tR - fR) * t + n);
|
||||
out[i++] = clamp255(fG + (tG - fG) * t + n);
|
||||
out[i++] = clamp255(fB + (tB - fB) * t + n);
|
||||
out[i++] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 网格线:细网格,每隔几格一条主格线更亮一点。 */
|
||||
function renderGrid(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||
const { width: w, height: h, u } = p;
|
||||
fill(out, w, h, p.base);
|
||||
const g = Math.max(16, w * 0.048); // 格距
|
||||
const offX = (u[8] - 0.5) * g;
|
||||
const offY = (u[9] - 0.5) * g;
|
||||
const hwMinor = Math.max(1, w * 0.0017); // 次格线半宽
|
||||
const hwMajor = hwMinor * 1.7;
|
||||
const majorEvery = 5;
|
||||
const [mA, mG, mB] = p.toneA; // 主格线
|
||||
const [nR, nG, nB] = p.toneB; // 次格线
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
const yv = y + 0.5 - offY;
|
||||
const ky = Math.round(yv / g);
|
||||
const dy = Math.abs(yv - ky * g);
|
||||
const majorY = mod(ky, majorEvery) === 0;
|
||||
const aHorz = lineCoverage(dy, majorY ? hwMajor : hwMinor);
|
||||
const hR = majorY ? mA : nR;
|
||||
const hG = majorY ? mG : nG;
|
||||
const hB = majorY ? mB : nB;
|
||||
const rowBase = y * w * 4;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const xv = x + 0.5 - offX;
|
||||
const kx = Math.round(xv / g);
|
||||
const dx = Math.abs(xv - kx * g);
|
||||
const majorX = mod(kx, majorEvery) === 0;
|
||||
const aVert = lineCoverage(dx, majorX ? hwMajor : hwMinor);
|
||||
// 先叠竖线再叠横线,交叉点像真实网格那样被后画的盖住
|
||||
let r = aVert > 0 ? (majorX ? mA : nR) * aVert : 0;
|
||||
let gg = aVert > 0 ? (majorX ? mG : nG) * aVert : 0;
|
||||
let b = aVert > 0 ? (majorX ? mB : nB) * aVert : 0;
|
||||
if (aHorz > 0) {
|
||||
r += hR * aHorz;
|
||||
gg += hG * aHorz;
|
||||
b += hB * aHorz;
|
||||
}
|
||||
const idx = rowBase + x * 4;
|
||||
out[idx] = (p.base[0] + r) | 0;
|
||||
out[idx + 1] = (p.base[1] + gg) | 0;
|
||||
out[idx + 2] = (p.base[2] + b) | 0;
|
||||
out[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mod(n: number, m: number): number {
|
||||
return ((n % m) + m) % m;
|
||||
}
|
||||
|
||||
/** 网格像素的线覆盖度:距线心半宽以内满覆盖,边缘 1px 抗锯齿。 */
|
||||
function lineCoverage(dist: number, halfWidth: number): number {
|
||||
const a = halfWidth + 0.5 - dist;
|
||||
return a <= 0 ? 0 : a >= 1 ? 1 : a;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同心弧:圆心悬在画布上方(横向随种子偏移),可见部分是从上方发散下来的
|
||||
* 一圈圈圆弧;每圈随机留一道缺口,颜色交替,像声呐/涟漪。
|
||||
*/
|
||||
function renderArcs(p: GeometryParams, out: Uint8ClampedArray): void {
|
||||
const { width: w, height: h, u } = p;
|
||||
fill(out, w, h, p.base);
|
||||
const cx = w * (0.3 + u[8] * 0.4);
|
||||
const cy = -h * (0.6 + u[9] * 0.6); // 圆心在画布上方
|
||||
const pitch = Math.max(18, w * 0.058); // 环间距
|
||||
const half = Math.max(4, pitch * 0.17); // 环带半宽
|
||||
const twoHalf = half * 2;
|
||||
const aa = Math.max(1, half * 0.5); // 环带边缘过渡宽
|
||||
const angAA = 0.05; // 缺口边缘(弧度)过渡宽
|
||||
const seed = p.seed32;
|
||||
const [bR, bG, bB] = p.base;
|
||||
const [aR, aG, aB] = p.toneA;
|
||||
const [tR, tG, tB] = p.toneB;
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
const dy = y - cy;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const dx = x - cx;
|
||||
const r = Math.sqrt(dx * dx + dy * dy);
|
||||
const k = Math.floor(r / pitch);
|
||||
const pos = r - k * pitch; // 在环带内的位置 [0, pitch)
|
||||
if (pos >= twoHalf) continue;
|
||||
// 环带覆盖度:两侧边缘过渡
|
||||
const cov = Math.min(pos / aa, (twoHalf - pos) / aa, 1);
|
||||
if (cov <= 0) continue;
|
||||
// 每圈的缺口(确定性)
|
||||
const nc = Math.PI * (0.15 + hash2d(k, 7, seed) * 0.7);
|
||||
const nw = Math.PI * (0.12 + hash2d(k, 13, seed) * 0.24);
|
||||
const ang = Math.atan2(dy, dx); // 画布都在圆心下方,ang ∈ (0, π)
|
||||
const lo = nc - nw / 2;
|
||||
const hi = nc + nw / 2;
|
||||
let angCov = 1;
|
||||
if (ang > lo && ang < hi) angCov = 0;
|
||||
else if (ang <= lo) angCov = Math.min((lo - ang) / angAA, 1);
|
||||
else angCov = Math.min((ang - hi) / angAA, 1);
|
||||
if (angCov <= 0) continue;
|
||||
const a = cov * angCov;
|
||||
const useA = (k & 1) === 1;
|
||||
const idx = (y * w + x) * 4;
|
||||
const lR = useA ? aR : tR;
|
||||
const lG = useA ? aG : tG;
|
||||
const lB = useA ? aB : tB;
|
||||
out[idx] = (bR + (lR - bR) * a) | 0;
|
||||
out[idx + 1] = (bG + (lG - bG) * a) | 0;
|
||||
out[idx + 2] = (bB + (lB - bB) * a) | 0;
|
||||
out[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 工具 */
|
||||
|
||||
function fill(out: Uint8ClampedArray, w: number, h: number, color: RGB): void {
|
||||
const [r, g, b] = color;
|
||||
let i = 0;
|
||||
for (let n = 0; n < w * h; n++) {
|
||||
out[i++] = r;
|
||||
out[i++] = g;
|
||||
out[i++] = b;
|
||||
out[i++] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp255(v: number): number {
|
||||
return v < 0 ? 0 : v > 255 ? 255 : v | 0;
|
||||
}
|
||||
|
||||
/** FNV-1a 32 位。确定性字符串 hash。 */
|
||||
function hashText(s: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** mulberry32:32 位种子 → 确定性 [0,1) 序列。 */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** (x, y, seed) → 确定性 [0,1),网格噪声与缺口位置用它。 */
|
||||
function hash2d(x: number, y: number, seed: number): number {
|
||||
let h =
|
||||
(Math.imul(x | 0, 0x9e3779b1) ^ Math.imul(y | 0, 0x85ebca77) ^ (seed >>> 0)) >>> 0;
|
||||
h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d) >>> 0;
|
||||
h = Math.imul(h ^ (h >>> 12), 0x297a2d39) >>> 0;
|
||||
h = (h ^ (h >>> 15)) >>> 0;
|
||||
return h / 4294967296;
|
||||
}
|
||||
|
||||
/** HSL → RGB(0-255)。s、l 均 [0,1]。 */
|
||||
function hsl2rgb(hueDeg: number, s: number, l: number): RGB {
|
||||
const h = ((hueDeg % 360) + 360) % 360;
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
if (h < 60) {
|
||||
r = c; g = x;
|
||||
} else if (h < 120) {
|
||||
r = x; g = c;
|
||||
} else if (h < 180) {
|
||||
g = c; b = x;
|
||||
} else if (h < 240) {
|
||||
g = x; b = c;
|
||||
} else if (h < 300) {
|
||||
r = x; b = c;
|
||||
} else {
|
||||
r = c; b = x;
|
||||
}
|
||||
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- DOM 出口层 */
|
||||
|
||||
/** 像素缓冲 → dataURL。唯一用到 DOM 的层,浏览器里才调用。 */
|
||||
export function geometryToDataUrl(p: GeometryParams): string {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = p.width;
|
||||
canvas.height = p.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("拿不到 canvas 2d context");
|
||||
const { data } = renderGeometry(p);
|
||||
ctx.putImageData(new ImageData(new Uint8ClampedArray(data), p.width, p.height), 0, 0);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
/** 生成一张能直接交给 useEditor().addImage() 的背景图。 */
|
||||
export function createGeometryAsset(
|
||||
seedText: string,
|
||||
kind: GeometryKind,
|
||||
width: number,
|
||||
height: number,
|
||||
): BackgroundAsset {
|
||||
const params = deriveGeometryParams(seedText, kind, width, height);
|
||||
return { kind: "geometry", src: geometryToDataUrl(params), width, height };
|
||||
}
|
||||
+29
-14
@@ -1,18 +1,33 @@
|
||||
import type { BackgroundAsset } from "@/types";
|
||||
|
||||
/**
|
||||
* 【T4 待实现 — 背景来源】
|
||||
* 背景来源汇总出口。
|
||||
*
|
||||
* 三个来源各写一个模块,都返回 BackgroundAsset:
|
||||
* - upload.ts 本地文件 -> object URL
|
||||
* - geometry.ts 预设几何底纹,程序生成(画到离屏 canvas 再 toDataURL)
|
||||
* - unsplash.ts Unsplash 搜索。Access Key 从 import.meta.env.VITE_UNSPLASH_KEY 读,
|
||||
* 放 .env.local(已 gitignore)。条款强制署名,返回时填 attribution。
|
||||
* 三个来源各写一个模块,都返回/产出 `BackgroundAsset`(形状见 `@/types`):
|
||||
* - upload.ts 本地文件 → object URL
|
||||
* - geometry.ts 预设几何底纹:纯函数派生+渲染,DOM 只出现在薄薄一层
|
||||
* - unsplash.ts Unsplash 搜索。Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读
|
||||
*
|
||||
* SourcePanel 按需从子模块直接 import;这里只是把表面聚成一个稳定出口,
|
||||
* 供面板以外的地方统一消费。
|
||||
*/
|
||||
export async function fromUpload(_file: File): Promise<BackgroundAsset> {
|
||||
throw new Error("T4 未实现:fromUpload");
|
||||
}
|
||||
|
||||
export async function searchUnsplash(_query: string): Promise<BackgroundAsset[]> {
|
||||
throw new Error("T4 未实现:searchUnsplash");
|
||||
}
|
||||
export { fromUpload, releaseObjectUrl } from "./upload";
|
||||
|
||||
export {
|
||||
deriveGeometryParams,
|
||||
renderGeometry,
|
||||
geometryToDataUrl,
|
||||
createGeometryAsset,
|
||||
GEOMETRY_KINDS,
|
||||
} from "./geometry";
|
||||
export type { GeometryParams, GeometryKind, RGB } from "./geometry";
|
||||
|
||||
export {
|
||||
unsplashStatus,
|
||||
searchUnsplash,
|
||||
trackDownload,
|
||||
toAsset,
|
||||
UnsplashError,
|
||||
} from "./unsplash";
|
||||
export type { UnsplashStatus, UnsplashPhoto } from "./unsplash";
|
||||
|
||||
export type { BackgroundAsset, Attribution } from "../types";
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Attribution, BackgroundAsset } from "../types";
|
||||
|
||||
/**
|
||||
* Unsplash 搜索。
|
||||
*
|
||||
* - Access Key 从 `import.meta.env.VITE_UNSPLASH_KEY` 读,用户放在 `.env.local`(已 gitignore)。
|
||||
* - **没配 key 时不崩**:模块静默处于 "missing" 态,面板据状态只显示提示,
|
||||
* 上传与预设几何不受影响。所有对外函数都先查 `unsplashStatus()`。
|
||||
* - 署名按用户要求**不画进图里**:`toAsset()` 把 attribution 填进
|
||||
* `BackgroundAsset.attribution`,由面板显示成可复制文本,用户贴进文章正文。
|
||||
* - 用户选定一张图时按 API 条款触发 download 统计(fire-and-forget)。
|
||||
*/
|
||||
|
||||
export type UnsplashStatus = "missing" | "ready";
|
||||
|
||||
/** 网络/参数层面的可预期错误,面板据此显示一句话,不让整条路径崩掉。 */
|
||||
export class UnsplashError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "UnsplashError";
|
||||
}
|
||||
}
|
||||
|
||||
/** 一张搜索结果,带网格展示与装进画布所需的全部信息。 */
|
||||
export interface UnsplashPhoto {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
description: string | null;
|
||||
altDescription: string | null;
|
||||
authorName: string;
|
||||
thumbUrl: string;
|
||||
/** 装进画布用的尺寸(regular,约 1080px)。 */
|
||||
regularUrl: string;
|
||||
/** 原图页(带 utm_source,署名链接用)。 */
|
||||
pageUrl: string;
|
||||
/** API 要求的 download_location,选定该图时触发。 */
|
||||
downloadLocation: string;
|
||||
}
|
||||
|
||||
/** 当前是否配了 key。env 在 Vite 里必然有;Node/Bun/构建产物里可能没有,一律当缺失。 */
|
||||
export function unsplashStatus(): UnsplashStatus {
|
||||
return readKey() ? "ready" : "missing";
|
||||
}
|
||||
|
||||
/** 面板能直接用:一条 Unsplash 结果 → 一份可进画布的 BackgroundAsset。 */
|
||||
export function toAsset(photo: UnsplashPhoto): BackgroundAsset {
|
||||
const attribution: Attribution = {
|
||||
text: `Photo by ${photo.authorName} on Unsplash`,
|
||||
url: photo.pageUrl,
|
||||
};
|
||||
return {
|
||||
kind: "unsplash",
|
||||
src: photo.regularUrl,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
attribution,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** 用户选定一张图时调用:触发 download 统计,条款义务。失败静默,不影响使用。 */
|
||||
export function trackDownload(photo: UnsplashPhoto): void {
|
||||
const key = readKey();
|
||||
if (!key || !photo.downloadLocation) return;
|
||||
const sep = photo.downloadLocation.includes("?") ? "&" : "?";
|
||||
fetch(`${photo.downloadLocation}${sep}client_id=${encodeURIComponent(key)}`).catch(() => {
|
||||
/* download 统计失败不影响用户操作 */
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 内部 */
|
||||
|
||||
function readKey(): string {
|
||||
// import.meta.env 只保证在 Vite 里存在。用可选链兜底,Node/Bun/静态产物都安全。
|
||||
const meta = import.meta as { env?: { VITE_UNSPLASH_KEY?: unknown } };
|
||||
const raw = meta.env?.VITE_UNSPLASH_KEY;
|
||||
return typeof raw === "string" ? raw.trim() : "";
|
||||
}
|
||||
|
||||
type UnsplashSearchResponse = {
|
||||
results?: UnsplashRawPhoto[];
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
type UnsplashRawPhoto = {
|
||||
id: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt_description?: string | null;
|
||||
description?: string | null;
|
||||
user?: { name?: string };
|
||||
urls?: {
|
||||
thumb?: string;
|
||||
small?: string;
|
||||
regular?: string;
|
||||
raw?: string;
|
||||
};
|
||||
links?: { html?: string; download_location?: string };
|
||||
};
|
||||
|
||||
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;
|
||||
return {
|
||||
id: raw.id,
|
||||
width: raw.width ?? 0,
|
||||
height: raw.height ?? 0,
|
||||
description,
|
||||
altDescription: raw.alt_description ?? null,
|
||||
authorName,
|
||||
thumbUrl: raw.urls?.small || raw.urls?.thumb || raw.urls?.regular || "",
|
||||
regularUrl: raw.urls?.regular || raw.urls?.raw || "",
|
||||
pageUrl,
|
||||
downloadLocation: raw.links?.download_location ?? "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { BackgroundAsset } from "../types";
|
||||
|
||||
/**
|
||||
* 本地上传:本地文件 → object URL。
|
||||
*
|
||||
* object URL 的生命周期由调用方(SourcePanel)管理:换新背景时对旧的
|
||||
* `releaseObjectUrl`,组件卸载时兜底释放一次,避免每次换图都漏一个 blob。
|
||||
*/
|
||||
|
||||
/** 把本地图片文件变成 BackgroundAsset。非图片/解不出尺寸会抛错并释放 URL。 */
|
||||
export async function fromUpload(file: File): Promise<BackgroundAsset> {
|
||||
const src = URL.createObjectURL(file);
|
||||
try {
|
||||
const size = await readImageSize(src);
|
||||
return { kind: "upload", src, width: size.width, height: size.height };
|
||||
} catch (err) {
|
||||
URL.revokeObjectURL(src);
|
||||
throw err instanceof Error ? err : new Error("不是可识别的图片文件");
|
||||
}
|
||||
}
|
||||
|
||||
/** 只有 blob: 前缀的 src 才可能是我们创建的 object URL,按需释放。 */
|
||||
export function releaseObjectUrl(src: string): void {
|
||||
if (src.startsWith("blob:")) URL.revokeObjectURL(src);
|
||||
}
|
||||
|
||||
function readImageSize(src: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
if (width > 0 && height > 0) resolve({ width, height });
|
||||
else reject(new Error("图片尺寸读不出来"));
|
||||
};
|
||||
img.onerror = () => reject(new Error("图片解码失败"));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user