feat(publish): add shared media-image asset normalizer

Introduce a reusable helper that produces upload-ready blobs for
adapters that share the same image-asset semantics (dongchedi,
sohuhao, wangyihao, weixin-gzh, zol). Centralizes URL candidate
expansion and passthrough mime detection so each adapter focuses on
its own publish protocol.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 00:57:13 +08:00
parent 7b05974ca4
commit 34e54b9f78
2 changed files with 182 additions and 0 deletions
@@ -0,0 +1,44 @@
import { Buffer } from "node:buffer";
import { describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
nativeImage: {
createFromBuffer: () => ({
isEmpty: () => false,
getSize: () => ({ width: 1, height: 1 }),
crop: () => ({
isEmpty: () => false,
getSize: () => ({ width: 1, height: 1 }),
toPNG: () => Buffer.from([]),
}),
toPNG: () => Buffer.from([]),
}),
},
}));
vi.mock("../transport/api-client", () => ({
resolveDesktopApiURL: (path: string) => `http://127.0.0.1:8080${path}`,
}));
import {
buildAssetURLCandidates,
getCenteredCropRect,
} from "./media-image";
describe("media image helpers", () => {
it("resolves API asset URLs through the desktop API base and requests png output", () => {
expect(buildAssetURLCandidates("/api/public/assets/12").at(0)).toBe(
"http://127.0.0.1:8080/api/public/assets/12?format=png",
);
});
it("centers crop rectangles at the requested ratio", () => {
const rect = getCenteredCropRect(1600, 1000, 4 / 3);
expect(rect.x).toBeCloseTo(133.333, 3);
expect(rect.y).toBeCloseTo(0);
expect(rect.width).toBeCloseTo(1333.333, 3);
expect(rect.height).toBeCloseTo(1000);
});
});
@@ -0,0 +1,138 @@
import { Buffer } from "node:buffer";
import { nativeImage } from "electron";
import { resolveDesktopApiURL } from "../transport/api-client";
export interface ImageAssetBlob {
blob: Blob;
width: number;
height: number;
fileName: string;
}
export interface CropRect {
x: number;
y: number;
width: number;
height: number;
}
const passthroughImageTypes = new Set(["image/png", "image/jpeg", "image/jpg", "image/gif"]);
export function buildAssetURLCandidates(sourceUrl: string): string[] {
const trimmed = sourceUrl.trim();
if (!trimmed) {
return [];
}
if (/^(data|blob):/i.test(trimmed)) {
return [trimmed];
}
const candidates: string[] = [];
const pushCandidate = (value: string) => {
if (!candidates.includes(value)) {
candidates.push(value);
}
};
try {
const parsed = new URL(trimmed, resolveDesktopApiURL("/"));
if (parsed.pathname.startsWith("/api/")) {
const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`));
if (apiAssetUrl.pathname.startsWith("/api/public/assets/")) {
apiAssetUrl.searchParams.set("format", "png");
}
pushCandidate(apiAssetUrl.toString());
}
pushCandidate(parsed.toString());
} catch {
pushCandidate(trimmed);
}
return candidates;
}
export async function fetchImageAssetBlob(sourceUrl: string): Promise<ImageAssetBlob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const response = await fetch(candidate).catch(() => null);
if (!response?.ok) {
continue;
}
const sourceBlob = await response.blob().catch(() => null);
if (!sourceBlob || !sourceBlob.type.startsWith("image/")) {
continue;
}
const image = nativeImage.createFromBuffer(Buffer.from(await sourceBlob.arrayBuffer()));
if (image.isEmpty()) {
continue;
}
const size = image.getSize();
const sourceType = sourceBlob.type.toLowerCase();
if (passthroughImageTypes.has(sourceType)) {
return {
blob: sourceBlob,
width: size.width,
height: size.height,
fileName: sourceType === "image/gif" ? "image.gif" : sourceType === "image/jpeg" || sourceType === "image/jpg" ? "image.jpg" : "image.png",
};
}
return {
blob: new Blob([new Uint8Array(image.toPNG())], { type: "image/png" }),
width: size.width,
height: size.height,
fileName: "image.png",
};
}
return null;
}
export function getCenteredCropRect(width: number, height: number, ratio: number): CropRect {
let cropWidth = width;
let cropHeight = cropWidth / ratio;
if (cropHeight > height) {
cropHeight = height;
cropWidth = cropHeight * ratio;
}
return {
x: (width - cropWidth) / 2,
y: (height - cropHeight) / 2,
width: cropWidth,
height: cropHeight,
};
}
export async function cropImageAssetBlob(source: ImageAssetBlob, ratio: number): Promise<ImageAssetBlob | null> {
const image = nativeImage.createFromBuffer(Buffer.from(await source.blob.arrayBuffer()));
if (image.isEmpty()) {
return null;
}
const crop = getCenteredCropRect(source.width, source.height, ratio);
const cropped = image.crop({
x: Math.max(0, Math.round(crop.x)),
y: Math.max(0, Math.round(crop.y)),
width: Math.max(1, Math.round(crop.width)),
height: Math.max(1, Math.round(crop.height)),
});
if (cropped.isEmpty()) {
return null;
}
const size = cropped.getSize();
return {
blob: new Blob([new Uint8Array(cropped.toPNG())], { type: "image/png" }),
width: size.width,
height: size.height,
fileName: "image.png",
};
}