feat: 背景来源——本地上传 / 预设几何 / Unsplash 搜索

SourcePanel 常驻在右栏「背景」一节,三个来源任选:

- 本地上传:文件选择 + 全窗口拖放,转 object URL 装进画布,
  换图与卸载时释放旧的 blob 地址
- 预设几何:四种底纹(斜向圆角条纹 / 噪点渐变 / 网格线 / 同心弧),
  用标题字符串作种子派生配色,同种子像素级确定——纯函数派生+渲染,
  DOM 只留在薄薄一层出口,Node 侧可直接验确定性;色相按前缀分桶,
  同系列标题自动落在相邻色区
- Unsplash 搜索:Access Key 读 VITE_UNSPLASH_KEY,未配置只显示提示、
  另两个来源照常可用;缩略图网格点选装 regular 尺寸;选定即触发
  download 统计(条款义务);署名不画进图,面板上给复制按钮
This commit is contained in:
2026-09-04 16:10:37 +08:00
parent 95eeb69ff8
commit d072067b00
5 changed files with 1152 additions and 22 deletions
+412
View File
@@ -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
View File
@@ -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";
+145
View File
@@ -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 ?? "",
};
}
+39
View File
@@ -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;
});
}