fix: honor generated image output format
This commit is contained in:
@@ -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 `生成数量`. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
58
scripts/test-image-output-format-persistence.mjs
Normal file
58
scripts/test-image-output-format-persistence.mjs
Normal 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);
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user