fix: honor generated image output format

This commit is contained in:
FengLee
2026-06-04 11:10:49 +08:00
parent fee527e1a3
commit 9f41d2c87a
5 changed files with 129 additions and 10 deletions

View File

@@ -52,6 +52,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Job remains running forever | `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-estimates.ts` | Stale timeout updates, `updated_at`, worker exceptions swallowed into error field. |
| Image generation returns upstream error | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts`, `src/lib/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser, and system-default stream timeout fallback. Gateway 502/503/504 errors are retried once; system default model failures should return the last actionable upstream timeout/gateway message instead of hiding everything behind the generic busy message. |
| One submitted image task shows extra images, or the same generated URL appears twice in history | `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/lib/generation-job-worker.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | First check production API logs for `count:1` with upstream messages such as `Got 2 images`, then query `generation_jobs.result.images` and `works` grouped by `user_id,result_url`. The image route should cap persisted response images to the requested count because some upstream/custom providers can return more images than `n`; creation-history POST should serialize same-user same-URL inserts before the existing lookup so concurrent completion/local persistence cannot insert duplicate `works` rows. |
| User selects JPEG/WebP but the returned generated image is PNG | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts` | First check PM2 logs for `[Image Generation] Params` and upstream request logs to confirm `outputFormat`/`output_format` reached the server/provider. Then query `works.params->>'outputFormat'` with `result_url` and inspect the object-storage response `Content-Type`/file magic for a recent key. Some providers may ignore `output_format` and still return PNG, so generated-image persistence must normalize the downloaded bytes to the selected format before `persistOriginalImageWithThumbnail(...)` uploads the object and writes history. |
| Video generation returns upstream error | `src/app/api/generate/video/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/server-api-config.ts` | Reference image upload/compression, endpoint URL, response parser, persistence timeout. |
| Wrong image size, aspect ratio, or custom API says returned resolution is lower than requested | `src/lib/model-config.ts`, `src/app/api/generate/image/route.ts` | `resolveImageSize`, `resolveCustomApiImageSize`, New API/DALL-E size normalization, prompt aspect hint, and custom API result qualification. Exact or larger generated images pass normally; lower-resolution images with matching aspect ratio and at least 60% of the requested dimensions are accepted as degraded upstream output instead of failing the job, while wrong-ratio or much smaller images are still rejected. |
| Text-to-image or image-to-image says `请在提示词中写明画面比例` even after selecting a Yuanjie resolution such as `4K 竖版 (3:4)` | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/lib/yuanjie-image-model-templates.ts` | Some Yuanjie image templates set `supportsAspectRatio: false` and encode orientation in `resolution`/`size` options. Generation validation must derive the ratio from the selected resolution label or dimensions instead of requiring a separate aspect-ratio control. Image-to-image should also default count to `1` rather than requiring prompt inference for `生成数量`. |

View File

@@ -73,7 +73,7 @@ Use this document to jump directly to code before broad searching.
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default image/video generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. Failed generation jobs do not enter the charge path. |
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
| ETA/progress | `src/lib/generation-job-estimates.ts` | Runtime schema, ETA samples, progress payload. |
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering and `dimensions` maps each original URL to persisted width/height so history detail metadata can avoid loading originals. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. |
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering and `dimensions` maps each original URL to persisted width/height so history detail metadata can avoid loading originals. Generated image originals are normalized to the user-selected output format before upload, so providers that ignore `output_format` and return PNG still produce `.jpg`/`.webp` objects when JPEG/WebP was requested. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. |
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. Generated video data URLs and upstream video URLs are persisted through `localStorage.uploadFileObjectOnly(...)` under `generated/videos`, so production video originals live in object storage when configured. Video create panels must use backend returned `creditsCost`/`creditsBalance` after job success; they should not locally predict or deduct credits. |
| Custom API transport | `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts` | Headers, one retry for 502/503/504 gateway failures, progress JSON parsing, upstream error parsing, stream-to-sync fallback policy for system image APIs. |
| Server API resolution | `src/lib/server-api-config.ts`, `src/lib/yuanjie-system-manifest.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). For known 元界 system rows with missing or stale `manifest_path`, both direct system API resolution and default-model polling candidates can rewrite the built-in Manifest and normalize `api_url` to the 元界 base URL before generation. The upstream `model_name` remains the per-provider request model only. |

View File

@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const { normalizeImageBufferForOutputFormat } = await import(`../src/lib/media-storage.ts?test=${Date.now()}`);
const repoRoot = path.resolve(import.meta.dirname, '..');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
await runTest('converts upstream PNG bytes to JPEG when JPEG is requested', async () => {
const upstreamPng = await sharp({
create: {
width: 12,
height: 8,
channels: 4,
background: { r: 64, g: 128, b: 192, alpha: 1 },
},
}).png().toBuffer();
const converted = await normalizeImageBufferForOutputFormat({
buffer: upstreamPng,
mimeType: 'image/png',
ext: 'png',
}, 'jpeg', 'high');
assert.equal(converted.mimeType, 'image/jpeg');
assert.equal(converted.ext, 'jpg');
assert.deepEqual([...converted.buffer.subarray(0, 3)], [0xff, 0xd8, 0xff]);
const metadata = await sharp(converted.buffer).metadata();
assert.equal(metadata.format, 'jpeg');
assert.equal(metadata.width, 12);
assert.equal(metadata.height, 8);
});
await runTest('image generation persistence passes the requested output format to storage', () => {
const source = fs.readFileSync(path.join(repoRoot, 'src/app/api/generate/image/route.ts'), 'utf8');
assert.match(source, /persistImageWithMetadata\(url,\s*prefix,\s*outputFormat,\s*imageQuality\)/);
assert.match(source, /requestQualifiedCustomImages\([\s\S]*resolvedOutputFormat,\s*resolvedImageQuality,\s*handleUpstreamProgress/);
assert.match(source, /User API Manifest Image'[\s\S]*resolvedOutputFormat,\s*resolvedImageQuality/);
assert.match(source, /Custom API img2img strategy1'[\s\S]*outputFormat,\s*imageQuality/);
assert.match(source, /Custom API img2img strategy2'[\s\S]*outputFormat,\s*imageQuality/);
assert.match(source, /Custom API img2img strategy3'[\s\S]*outputFormat,\s*imageQuality/);
assert.match(source, /SDK Image'[\s\S]*resolvedOutputFormat,\s*resolvedImageQuality/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -30,6 +30,8 @@ import {
import { updateGenerationJobProgress } from '@/lib/generation-job-estimates';
import {
resolveImageApiTemplate,
type ImageOutputFormat,
type ImageQuality,
type ImageApiTemplate,
} from '@/lib/image-api-templates';
import {
@@ -38,6 +40,7 @@ import {
import { executeUserApiManifest } from '@/lib/user-api-manifest-executor';
import {
getImageExtension as getMediaImageExtension,
normalizeImageBufferForOutputFormat,
parseImageDataUrl as parseMediaImageDataUrl,
persistOriginalImageWithThumbnail,
readImageBufferFromUrl,
@@ -219,14 +222,20 @@ async function getReferenceImagePublicUrlFromKey(fileKey: string): Promise<strin
return toAbsolutePublicUrl(publicUrl);
}
async function persistImageWithMetadata(url: string, prefix: string): Promise<PersistedImageResult | null> {
async function persistImageWithMetadata(
url: string,
prefix: string,
outputFormat?: ImageOutputFormat,
imageQuality?: ImageQuality,
): Promise<PersistedImageResult | null> {
const source = await readImageBufferFromUrl(url);
if (!source) return null;
const normalizedSource = await normalizeImageBufferForOutputFormat(source, outputFormat, imageQuality);
return withTimeout(
persistOriginalImageWithThumbnail({
buffer: source.buffer,
mimeType: source.mimeType,
ext: source.ext,
buffer: normalizedSource.buffer,
mimeType: normalizedSource.mimeType,
ext: normalizedSource.ext,
originalPrefix: prefix,
thumbnailPrefix: 'thumbnails/generated/images',
}),
@@ -265,6 +274,8 @@ async function persistQualifiedImageUrls(
targetSize: TargetImageSize | null,
context: string,
requestedCount = urls.length,
outputFormat?: ImageOutputFormat,
imageQuality?: ImageQuality,
): Promise<PersistQualifiedImageUrlsResult> {
const images: QualifiedImageResult[] = [];
const rejected: string[] = [];
@@ -273,7 +284,7 @@ async function persistQualifiedImageUrls(
for (const url of urls) {
try {
const persisted = await persistImageWithMetadata(url, prefix);
const persisted = await persistImageWithMetadata(url, prefix, outputFormat, imageQuality);
if (!persisted) {
rejected.push('无法读取生成图片');
failureKinds.push('download');
@@ -373,6 +384,8 @@ async function requestQualifiedCustomImages(
requestBody: Record<string, unknown>,
targetCount: number,
targetSize: TargetImageSize | null,
outputFormat: ImageOutputFormat,
imageQuality: ImageQuality,
onProgress?: (progress: Record<string, unknown>) => void | Promise<void>,
options: { autoRetryWithoutStream?: boolean } = {},
): Promise<PersistQualifiedImageUrlsResult & { upstreamError?: { status: number; text: string } }> {
@@ -431,6 +444,8 @@ async function requestQualifiedCustomImages(
targetSize,
`Custom API Image attempt ${attempt}`,
targetCount - accepted.length,
outputFormat,
imageQuality,
);
accepted.push(...persisted.images);
Object.assign(thumbnails, persisted.thumbnails);
@@ -940,7 +955,7 @@ async function customApiImageToImage(
onProgress,
);
if (result1.success && result1.images) {
const persisted = await persistQualifiedImageUrls(result1.images, 'generated/images', targetSize, 'Custom API img2img strategy1', count);
const persisted = await persistQualifiedImageUrls(result1.images, 'generated/images', targetSize, 'Custom API img2img strategy1', count, outputFormat, imageQuality);
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted, count));
result1 = { ...result1, success: false, error: imageResultFailureError(targetSize, persisted) };
}
@@ -956,7 +971,7 @@ async function customApiImageToImage(
onProgress,
);
if (result2.success && result2.images) {
const persisted = await persistQualifiedImageUrls(result2.images, 'generated/images', targetSize, 'Custom API img2img strategy2', count);
const persisted = await persistQualifiedImageUrls(result2.images, 'generated/images', targetSize, 'Custom API img2img strategy2', count, outputFormat, imageQuality);
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted, count));
result2.success = false;
result2.error = imageResultFailureError(targetSize, persisted);
@@ -972,7 +987,7 @@ async function customApiImageToImage(
onProgress,
);
if (result3.success && result3.images) {
const persisted = await persistQualifiedImageUrls(result3.images, 'generated/images', targetSize, 'Custom API img2img strategy3', count);
const persisted = await persistQualifiedImageUrls(result3.images, 'generated/images', targetSize, 'Custom API img2img strategy3', count, outputFormat, imageQuality);
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted, count));
result3.success = false;
result3.error = imageResultFailureError(targetSize, persisted);
@@ -1141,6 +1156,8 @@ export async function POST(request: NextRequest) {
targetSize,
'User API Manifest Image',
resolvedAutoParams.count,
resolvedOutputFormat,
resolvedImageQuality,
);
if (persisted.images.length === 0) {
return NextResponse.json({ error: generatedImagePersistenceError(persisted) }, { status: 502 });
@@ -1230,6 +1247,8 @@ export async function POST(request: NextRequest) {
requestBody,
n,
customTargetSize,
resolvedOutputFormat,
resolvedImageQuality,
handleUpstreamProgress,
{ autoRetryWithoutStream: !!resolvedCustomApiConfig.systemApiId },
);
@@ -1393,7 +1412,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '图片生成失败,请稍后重试' }, { status: 500 });
}
const persistedImages = await persistQualifiedImageUrls(images, 'generated/images', targetSize, 'SDK Image', resolvedAutoParams.count);
const persistedImages = await persistQualifiedImageUrls(images, 'generated/images', targetSize, 'SDK Image', resolvedAutoParams.count, resolvedOutputFormat, resolvedImageQuality);
if (persistedImages.images.length === 0) {
return NextResponse.json({ error: imageResultFailureError(targetSize, persistedImages) }, { status: 502 });
}

View File

@@ -34,6 +34,9 @@ type ImageBufferSource = {
ext: string;
};
export type ImageOutputFormat = 'png' | 'jpeg' | 'webp';
export type ImageOutputQuality = 'auto' | 'high' | 'medium' | 'low';
type VideoThumbnailInput = {
input: string;
cleanup?: () => Promise<void>;
@@ -107,6 +110,44 @@ export async function readImageBufferFromUrl(url: string): Promise<ImageBufferSo
};
}
function imageFormatQuality(format: ImageOutputFormat, quality?: ImageOutputQuality): number {
if (format === 'png') return 100;
if (quality === 'low') return format === 'webp' ? 72 : 78;
if (quality === 'medium' || quality === 'auto') return format === 'webp' ? 84 : 86;
return format === 'webp' ? 92 : 94;
}
export async function normalizeImageBufferForOutputFormat(
source: ImageBufferSource,
outputFormat?: ImageOutputFormat,
quality?: ImageOutputQuality,
): Promise<ImageBufferSource> {
if (!outputFormat) return source;
if (outputFormat === 'png') {
if (source.mimeType === 'image/png' && source.ext === 'png') return source;
const buffer = await sharp(source.buffer, { failOn: 'none' }).rotate().png().toBuffer();
return { buffer, mimeType: 'image/png', ext: 'png' };
}
if (outputFormat === 'jpeg') {
if ((source.mimeType === 'image/jpeg' || source.mimeType === 'image/jpg') && /jpe?g/i.test(source.ext)) return source;
const buffer = await sharp(source.buffer, { failOn: 'none' })
.rotate()
.flatten({ background: '#ffffff' })
.jpeg({ quality: imageFormatQuality('jpeg', quality), mozjpeg: true })
.toBuffer();
return { buffer, mimeType: 'image/jpeg', ext: 'jpg' };
}
if (source.mimeType === 'image/webp' && source.ext === 'webp') return source;
const buffer = await sharp(source.buffer, { failOn: 'none' })
.rotate()
.webp({ quality: imageFormatQuality('webp', quality), effort: 5, smartSubsample: true })
.toBuffer();
return { buffer, mimeType: 'image/webp', ext: 'webp' };
}
export async function persistOriginalImageWithThumbnail(input: {
buffer: Buffer;
mimeType: string;