diff --git a/src/components/panels/SourcePanel.vue b/src/components/panels/SourcePanel.vue index 99f78fd..0abe2dc 100644 --- a/src/components/panels/SourcePanel.vue +++ b/src/components/panels/SourcePanel.vue @@ -1,22 +1,541 @@ diff --git a/src/sources/geometry.ts b/src/sources/geometry.ts new file mode 100644 index 0000000..674ae78 --- /dev/null +++ b/src/sources/geometry.ts @@ -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 }; +} diff --git a/src/sources/index.ts b/src/sources/index.ts index ea45b9e..8e93d91 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -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 { - throw new Error("T4 未实现:fromUpload"); -} -export async function searchUnsplash(_query: string): Promise { - 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"; diff --git a/src/sources/unsplash.ts b/src/sources/unsplash.ts new file mode 100644 index 0000000..ea9339c --- /dev/null +++ b/src/sources/unsplash.ts @@ -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 { + 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 ?? "", + }; +} diff --git a/src/sources/upload.ts b/src/sources/upload.ts new file mode 100644 index 0000000..cbe827d --- /dev/null +++ b/src/sources/upload.ts @@ -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 { + 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; + }); +}