Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Generate images via OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream, and Replicate APIs with batch support.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/providers/dashscope.ts
1import path from "node:path";2import { readFile } from "node:fs/promises";3import type { CliArgs, Quality } from "../types";45type DashScopeModelFamily = "qwen2" | "qwenFixed" | "wan27" | "legacy";67type DashScopeModelSpec = {8family: DashScopeModelFamily;9defaultSize: string;10};1112const DEFAULT_MODEL = "qwen-image-2.0-pro";13const MIN_QWEN_2_TOTAL_PIXELS = 512 * 512;14const MAX_QWEN_2_TOTAL_PIXELS = 2048 * 2048;15const SIZE_STEP = 16;16const QWEN_NEGATIVE_PROMPT =17"低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感,构图混乱,文字模糊,扭曲";1819const QWEN_2_TARGET_PIXELS: Record<Quality, number> = {20normal: 1024 * 1024,21"2k": 1536 * 1536,22};2324const MIN_WAN27_TOTAL_PIXELS = 768 * 768;25const MAX_WAN27_PRO_T2I_PIXELS = 4096 * 4096;26const MAX_WAN27_GENERAL_PIXELS = 2048 * 2048;27const WAN27_MAX_REFERENCE_IMAGES = 9;2829const WAN27_TARGET_PIXELS: Record<Quality, number> = {30normal: 1024 * 1024,31"2k": 2048 * 2048,32};3334const QWEN_2_RECOMMENDED: Record<string, Record<Quality, string>> = {35"1:1": { normal: "1024*1024", "2k": "1536*1536" },36"2:3": { normal: "768*1152", "2k": "1024*1536" },37"3:2": { normal: "1152*768", "2k": "1536*1024" },38"3:4": { normal: "960*1280", "2k": "1080*1440" },39"4:3": { normal: "1280*960", "2k": "1440*1080" },40"9:16": { normal: "720*1280", "2k": "1080*1920" },41"16:9": { normal: "1280*720", "2k": "1920*1080" },42"21:9": { normal: "1344*576", "2k": "2048*872" },43};4445const QWEN_FIXED_SIZES_BY_RATIO: Record<string, string> = {46"16:9": "1664*928",47"4:3": "1472*1104",48"1:1": "1328*1328",49"3:4": "1104*1472",50"9:16": "928*1664",51};5253const QWEN_FIXED_SIZES = Object.values(QWEN_FIXED_SIZES_BY_RATIO);5455const LEGACY_STANDARD_SIZES: [number, number][] = [56[1024, 1024],57[1280, 720],58[720, 1280],59[1024, 768],60[768, 1024],61[1536, 1024],62[1024, 1536],63[1536, 864],64[864, 1536],65];6667const LEGACY_STANDARD_SIZES_2K: [number, number][] = [68[1536, 1536],69[2048, 1152],70[1152, 2048],71[1536, 1024],72[1024, 1536],73[1536, 864],74[864, 1536],75[2048, 2048],76];7778const QWEN_2_SPEC: DashScopeModelSpec = {79family: "qwen2",80defaultSize: "1024*1024",81};8283const QWEN_FIXED_SPEC: DashScopeModelSpec = {84family: "qwenFixed",85defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"],86};8788const WAN27_SPEC: DashScopeModelSpec = {89family: "wan27",90defaultSize: "2048*2048",91};9293const LEGACY_SPEC: DashScopeModelSpec = {94family: "legacy",95defaultSize: "1536*1536",96};9798const MODEL_SPEC_ALIASES: Record<string, DashScopeModelSpec> = {99"qwen-image-2.0-pro": QWEN_2_SPEC,100"qwen-image-2.0-pro-2026-03-03": QWEN_2_SPEC,101"qwen-image-2.0": QWEN_2_SPEC,102"qwen-image-2.0-2026-03-03": QWEN_2_SPEC,103"qwen-image-max": QWEN_FIXED_SPEC,104"qwen-image-max-2025-12-30": QWEN_FIXED_SPEC,105"qwen-image-plus": QWEN_FIXED_SPEC,106"qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC,107"qwen-image": QWEN_FIXED_SPEC,108"wan2.7-image-pro": WAN27_SPEC,109"wan2.7-image": WAN27_SPEC,110};111112export function getDefaultModel(): string {113return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL;114}115116function getReferenceImageMime(filePath: string): string {117const ext = path.extname(filePath).toLowerCase();118if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";119if (ext === ".webp") return "image/webp";120if (ext === ".bmp") return "image/bmp";121return "image/png";122}123124async function loadReferenceImage(refPath: string): Promise<string> {125if (/^https?:\/\//i.test(refPath)) {126return refPath;127}128const fullPath = path.resolve(refPath);129const bytes = await readFile(fullPath);130return `data:${getReferenceImageMime(fullPath)};base64,${bytes.toString("base64")}`;131}132133function getApiKey(): string | null {134return process.env.DASHSCOPE_API_KEY || null;135}136137function getBaseUrl(): string {138const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";139return base.replace(/\/+$/g, "");140}141142function getModelSpec(model: string): DashScopeModelSpec {143return MODEL_SPEC_ALIASES[model.trim().toLowerCase()] || LEGACY_SPEC;144}145146export function getModelFamily(model: string): DashScopeModelFamily {147return getModelSpec(model).family;148}149150function normalizeQuality(quality: CliArgs["quality"]): Quality {151return quality === "normal" ? "normal" : "2k";152}153154export function parseAspectRatio(ar: string): { width: number; height: number } | null {155const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);156if (!match) return null;157const w = parseFloat(match[1]!);158const h = parseFloat(match[2]!);159if (w <= 0 || h <= 0) return null;160return { width: w, height: h };161}162163export function normalizeSize(size: string): string {164return size.replace("x", "*");165}166167export function parseSize(size: string): { width: number; height: number } | null {168const match = normalizeSize(size).match(/^(\d+)\*(\d+)$/);169if (!match) return null;170const width = Number(match[1]);171const height = Number(match[2]);172if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {173return null;174}175return { width, height };176}177178function formatSize(width: number, height: number): string {179return `${width}*${height}`;180}181182function getRatioValue(ar: string): number | null {183const parsed = parseAspectRatio(ar);184if (!parsed) return null;185return parsed.width / parsed.height;186}187188function findKnownRatioKey(ar: string, candidates: string[], tolerance = 0.02): string | null {189const targetRatio = getRatioValue(ar);190if (targetRatio == null) return null;191192let bestKey: string | null = null;193let bestDiff = Infinity;194195for (const candidate of candidates) {196const candidateRatio = getRatioValue(candidate);197if (candidateRatio == null) continue;198const diff = Math.abs(candidateRatio - targetRatio);199if (diff < bestDiff) {200bestDiff = diff;201bestKey = candidate;202}203}204205return bestDiff <= tolerance ? bestKey : null;206}207208function roundToStep(value: number): number {209return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP);210}211212function floorToStep(value: number): number {213return Math.max(SIZE_STEP, Math.floor(value / SIZE_STEP) * SIZE_STEP);214}215216function fitToPixelBudget(217width: number,218height: number,219minPixels: number,220maxPixels: number,221): { width: number; height: number } {222let nextWidth = width;223let nextHeight = height;224let pixels = nextWidth * nextHeight;225226if (pixels > maxPixels) {227const scale = Math.sqrt(maxPixels / pixels);228nextWidth *= scale;229nextHeight *= scale;230} else if (pixels < minPixels) {231const scale = Math.sqrt(minPixels / pixels);232nextWidth *= scale;233nextHeight *= scale;234}235236let roundedWidth = roundToStep(nextWidth);237let roundedHeight = roundToStep(nextHeight);238pixels = roundedWidth * roundedHeight;239240while (pixels > maxPixels && (roundedWidth > SIZE_STEP || roundedHeight > SIZE_STEP)) {241if (roundedWidth >= roundedHeight && roundedWidth > SIZE_STEP) {242roundedWidth -= SIZE_STEP;243} else if (roundedHeight > SIZE_STEP) {244roundedHeight -= SIZE_STEP;245} else {246break;247}248pixels = roundedWidth * roundedHeight;249}250251while (pixels < minPixels) {252if (roundedWidth <= roundedHeight) {253roundedWidth += SIZE_STEP;254} else {255roundedHeight += SIZE_STEP;256}257pixels = roundedWidth * roundedHeight;258}259260return { width: roundedWidth, height: roundedHeight };261}262263function clampWan27DerivedSizeToRatioBounds(264size: { width: number; height: number },265): { width: number; height: number } {266let { width, height } = size;267const ratio = width / height;268269if (ratio > 8) {270width = floorToStep(height * 8);271} else if (ratio < 1 / 8) {272height = floorToStep(width * 8);273}274275return { width, height };276}277278export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {279const normalizedQuality = normalizeQuality(quality);280const sizes = normalizedQuality === "2k" ? LEGACY_STANDARD_SIZES_2K : LEGACY_STANDARD_SIZES;281const defaultSize = normalizedQuality === "2k" ? "1536*1536" : "1024*1024";282283if (!ar) return defaultSize;284285const parsed = parseAspectRatio(ar);286if (!parsed) return defaultSize;287288const targetRatio = parsed.width / parsed.height;289let best = defaultSize;290let bestDiff = Infinity;291292for (const [width, height] of sizes) {293const diff = Math.abs(width / height - targetRatio);294if (diff < bestDiff) {295bestDiff = diff;296best = formatSize(width, height);297}298}299300return best;301}302303export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {304const normalizedQuality = normalizeQuality(quality);305306if (!ar) {307return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];308}309310const recommendedRatio = findKnownRatioKey(ar, Object.keys(QWEN_2_RECOMMENDED));311if (recommendedRatio) {312return QWEN_2_RECOMMENDED[recommendedRatio][normalizedQuality];313}314315const parsed = parseAspectRatio(ar);316if (!parsed) {317return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];318}319320const targetRatio = parsed.width / parsed.height;321const targetPixels = QWEN_2_TARGET_PIXELS[normalizedQuality];322const rawWidth = Math.sqrt(targetPixels * targetRatio);323const rawHeight = Math.sqrt(targetPixels / targetRatio);324const fitted = fitToPixelBudget(325rawWidth,326rawHeight,327MIN_QWEN_2_TOTAL_PIXELS,328MAX_QWEN_2_TOTAL_PIXELS,329);330331return formatSize(fitted.width, fitted.height);332}333334function isWan27ProModel(model: string): boolean {335return model.trim().toLowerCase() === "wan2.7-image-pro";336}337338function getWan27MaxPixels(model: string, hasReferenceImages: boolean): number {339if (isWan27ProModel(model) && !hasReferenceImages) {340return MAX_WAN27_PRO_T2I_PIXELS;341}342return MAX_WAN27_GENERAL_PIXELS;343}344345export function getWan27SizeFromAspectRatio(346ar: string | null,347quality: CliArgs["quality"],348maxPixels: number,349): string {350const normalizedQuality = normalizeQuality(quality);351const targetPixels = Math.min(WAN27_TARGET_PIXELS[normalizedQuality], maxPixels);352353if (!ar) {354const side = roundToStep(Math.sqrt(targetPixels));355return formatSize(side, side);356}357358const parsed = parseAspectRatio(ar);359if (!parsed) {360const side = roundToStep(Math.sqrt(targetPixels));361return formatSize(side, side);362}363364const ratio = parsed.width / parsed.height;365if (ratio < 1 / 8 || ratio > 8) {366throw new Error(367`DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]. Received "${ar}".`368);369}370371const rawWidth = Math.sqrt(targetPixels * ratio);372const rawHeight = Math.sqrt(targetPixels / ratio);373const fitted = fitToPixelBudget(374rawWidth,375rawHeight,376MIN_WAN27_TOTAL_PIXELS,377maxPixels,378);379const bounded = clampWan27DerivedSizeToRatioBounds(fitted);380381return formatSize(bounded.width, bounded.height);382}383384function validateWan27Size(size: string, maxPixels: number, model: string): string {385const normalized = normalizeSize(size);386const parsed = validateSizeFormat(normalized);387const totalPixels = parsed.width * parsed.height;388if (totalPixels < MIN_WAN27_TOTAL_PIXELS || totalPixels > maxPixels) {389const limit = maxPixels === MAX_WAN27_PRO_T2I_PIXELS ? "4096*4096" : "2048*2048";390throw new Error(391`DashScope ${model} requires total pixels between 768*768 and ${limit} ` +392`for the current request. Received ${normalized} (${totalPixels} pixels).`393);394}395const ratio = parsed.width / parsed.height;396if (ratio < 1 / 8 || ratio > 8) {397throw new Error(398`DashScope wan2.7 image models support aspect ratios in [1:8, 8:1]. ` +399`Received ${normalized} (ratio ${ratio.toFixed(3)}).`400);401}402return normalized;403}404405function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {406if (quality === "normal") {407console.warn(408"DashScope qwen-image-max/plus/image models use fixed output sizes; --quality normal does not change the generated resolution."409);410}411412if (!ar) return QWEN_FIXED_SPEC.defaultSize;413414const ratioKey = findKnownRatioKey(ar, Object.keys(QWEN_FIXED_SIZES_BY_RATIO));415if (!ratioKey) {416throw new Error(417`DashScope model supports only fixed ratios ${Object.keys(QWEN_FIXED_SIZES_BY_RATIO).join(", ")}. ` +418`For custom ratios like "${ar}", use --model qwen-image-2.0-pro.`419);420}421422return QWEN_FIXED_SIZES_BY_RATIO[ratioKey]!;423}424425function validateSizeFormat(size: string): { width: number; height: number } {426const parsed = parseSize(size);427if (!parsed) {428throw new Error(`Invalid DashScope size "${size}". Expected <width>x<height> or <width>*<height>.`);429}430return parsed;431}432433function validateQwen2Size(size: string): string {434const normalized = normalizeSize(size);435const parsed = validateSizeFormat(normalized);436const totalPixels = parsed.width * parsed.height;437if (totalPixels < MIN_QWEN_2_TOTAL_PIXELS || totalPixels > MAX_QWEN_2_TOTAL_PIXELS) {438throw new Error(439`DashScope qwen-image-2.0* models require total pixels between ${MIN_QWEN_2_TOTAL_PIXELS} ` +440`and ${MAX_QWEN_2_TOTAL_PIXELS}. Received ${normalized} (${totalPixels} pixels).`441);442}443return normalized;444}445446function validateQwenFixedSize(size: string): string {447const normalized = normalizeSize(size);448validateSizeFormat(normalized);449if (!QWEN_FIXED_SIZES.includes(normalized)) {450throw new Error(451`DashScope qwen-image-max/plus/image models support only these sizes: ${QWEN_FIXED_SIZES.join(", ")}. ` +452`Received ${normalized}.`453);454}455return normalized;456}457458export function resolveSizeForModel(459model: string,460args: Pick<CliArgs, "size" | "aspectRatio" | "quality"> & { referenceImages?: string[] },461): string {462const spec = getModelSpec(model);463const referenceCount = args.referenceImages?.length ?? 0;464465if (spec.family === "wan27") {466const maxPixels = getWan27MaxPixels(model, referenceCount > 0);467if (args.size) return validateWan27Size(args.size, maxPixels, model);468return getWan27SizeFromAspectRatio(args.aspectRatio, args.quality, maxPixels);469}470471if (args.size) {472if (spec.family === "qwen2") return validateQwen2Size(args.size);473if (spec.family === "qwenFixed") return validateQwenFixedSize(args.size);474validateSizeFormat(args.size);475return normalizeSize(args.size);476}477478if (spec.family === "qwen2") {479return getQwen2SizeFromAspectRatio(args.aspectRatio, args.quality);480}481482if (spec.family === "qwenFixed") {483return getQwenFixedSizeFromAspectRatio(args.aspectRatio, args.quality);484}485486return getSizeFromAspectRatio(args.aspectRatio, args.quality);487}488489function buildParameters(490family: DashScopeModelFamily,491size: string,492): Record<string, unknown> {493if (family === "wan27") {494return {495size,496n: 1,497watermark: false,498};499}500501const parameters: Record<string, unknown> = {502prompt_extend: false,503size,504};505506if (family === "qwen2" || family === "qwenFixed") {507parameters.watermark = false;508parameters.negative_prompt = QWEN_NEGATIVE_PROMPT;509}510511return parameters;512}513514type DashScopeResponse = {515output?: {516result_image?: string;517choices?: Array<{518message?: {519content?: Array<{ image?: string }>;520};521}>;522};523};524525async function extractImageFromResponse(result: DashScopeResponse): Promise<Uint8Array> {526let imageData: string | null = null;527528if (result.output?.result_image) {529imageData = result.output.result_image;530} else if (result.output?.choices?.[0]?.message?.content) {531const content = result.output.choices[0].message.content;532for (const item of content) {533if (item.image) {534imageData = item.image;535break;536}537}538}539540if (!imageData) {541console.error("Response:", JSON.stringify(result, null, 2));542throw new Error("No image in response");543}544545if (imageData.startsWith("http://") || imageData.startsWith("https://")) {546const imgRes = await fetch(imageData);547if (!imgRes.ok) throw new Error("Failed to download image");548const buf = await imgRes.arrayBuffer();549return new Uint8Array(buf);550}551552return Uint8Array.from(Buffer.from(imageData, "base64"));553}554555export async function generateImage(556prompt: string,557model: string,558args: CliArgs559): Promise<Uint8Array> {560const apiKey = getApiKey();561if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");562563const spec = getModelSpec(model);564565if (args.referenceImages.length > 0 && spec.family !== "wan27") {566throw new Error(567"Reference images are not supported with this DashScope model. Use a wan2.7 image model (--model wan2.7-image-pro or wan2.7-image), or switch to --provider google with a Gemini multimodal model."568);569}570571if (args.referenceImages.length > WAN27_MAX_REFERENCE_IMAGES) {572throw new Error(573`DashScope wan2.7 image models accept at most ${WAN27_MAX_REFERENCE_IMAGES} reference images. Received ${args.referenceImages.length}.`574);575}576577if (spec.family === "wan27" && args.n !== 1) {578throw new Error(579"DashScope wan2.7 image models in baoyu-image-gen support exactly one output image per request (extra images would be billed but discarded). Remove --n or use --n 1."580);581}582583const size = resolveSizeForModel(model, args);584const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;585586const content: Array<Record<string, unknown>> = [];587if (spec.family === "wan27" && args.referenceImages.length > 0) {588for (const refPath of args.referenceImages) {589content.push({ image: await loadReferenceImage(refPath) });590}591}592content.push({ text: prompt });593594const body = {595model,596input: {597messages: [598{599role: "user",600content,601},602],603},604parameters: buildParameters(spec.family, size),605};606607console.log(`Generating image with DashScope (${model})...`, { family: spec.family, size });608609const res = await fetch(url, {610method: "POST",611headers: {612"Content-Type": "application/json",613Authorization: `Bearer ${apiKey}`,614},615body: JSON.stringify(body),616});617618if (!res.ok) {619const err = await res.text();620throw new Error(`DashScope API error (${res.status}): ${err}`);621}622623const result = await res.json() as DashScopeResponse;624return extractImageFromResponse(result);625}626