fix: cap image results and dedupe history inserts
This commit is contained in:
@@ -51,6 +51,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Job remains queued | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-runner.ts` | `processNextGenerationJob()` invoked, stale job handling, DB locks/status, internal base URL. |
|
||||
| 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. |
|
||||
| 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 `生成数量`. |
|
||||
|
||||
@@ -46,6 +46,20 @@ await runTest('generation worker persists completed jobs back into creation hist
|
||||
assert.match(source, /status: 'succeeded'/);
|
||||
});
|
||||
|
||||
await runTest('image generation caps persisted images to the requested count', () => {
|
||||
const source = read('src/app/api/generate/image/route.ts');
|
||||
assert.match(source, /function capPersistedImagesToRequestedCount/);
|
||||
assert.match(source, /imageResponsePayload\([^,\n]+,\s*n\)/);
|
||||
assert.match(source, /persistQualifiedImageUrls\([^)]*requestedCount/s);
|
||||
});
|
||||
|
||||
await runTest('creation history serializes same-user same-url inserts to prevent duplicate rows', () => {
|
||||
const source = read('src/app/api/creation-history/route.ts');
|
||||
assert.match(source, /pg_advisory_xact_lock/);
|
||||
assert.match(source, /historyRecordDedupeLockKey/);
|
||||
assert.match(source, /WHERE user_id = \$1 AND result_url = \$2/);
|
||||
});
|
||||
|
||||
await runTest('create panels restore active jobs from the server after reload or auth change', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/text-to-image.tsx',
|
||||
|
||||
@@ -142,6 +142,10 @@ function dedupeRowsByResultUrl(rows: Record<string, unknown>[]) {
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function historyRecordDedupeLockKey(userId: string, url: string): string {
|
||||
return `${userId}:${url}`;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
@@ -196,6 +200,9 @@ export async function POST(request: NextRequest) {
|
||||
url = url && !url.startsWith('data:') ? url : `[reverse-prompt:${record.id || Date.now()}]`;
|
||||
}
|
||||
if (!url || url.startsWith('data:')) continue;
|
||||
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [
|
||||
historyRecordDedupeLockKey(userId, url),
|
||||
]);
|
||||
if (!thumbnailUrl && isVideoWorkType(workType)) {
|
||||
try {
|
||||
thumbnailUrl = await ensureLocalVideoThumbnail(url, 'thumbnails/works/videos', String(record.prompt || 'Video'));
|
||||
|
||||
@@ -264,10 +264,12 @@ async function persistQualifiedImageUrls(
|
||||
prefix: string,
|
||||
targetSize: TargetImageSize | null,
|
||||
context: string,
|
||||
requestedCount = urls.length,
|
||||
): Promise<PersistQualifiedImageUrlsResult> {
|
||||
const images: QualifiedImageResult[] = [];
|
||||
const rejected: string[] = [];
|
||||
const failureKinds: GeneratedImagePersistenceFailureKind[] = [];
|
||||
const cappedCount = Math.max(1, Math.floor(Number(requestedCount) || 1));
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
@@ -292,21 +294,45 @@ async function persistQualifiedImageUrls(
|
||||
}
|
||||
|
||||
images.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.bytes - a.bytes);
|
||||
const selected = images.slice(0, cappedCount);
|
||||
return {
|
||||
images: images.map(image => image.url),
|
||||
thumbnails: Object.fromEntries(images.map(image => [image.url, image.thumbnailUrl])),
|
||||
dimensions: Object.fromEntries(images.map(image => [image.url, { width: image.width, height: image.height }])),
|
||||
images: selected.map(image => image.url),
|
||||
thumbnails: Object.fromEntries(selected.map(image => [image.url, image.thumbnailUrl])),
|
||||
dimensions: Object.fromEntries(selected.map(image => [image.url, { width: image.width, height: image.height }])),
|
||||
rejected,
|
||||
failureKinds,
|
||||
};
|
||||
}
|
||||
|
||||
function imageResponsePayload(result: { images: string[]; thumbnails: Record<string, string>; dimensions: Record<string, { width: number; height: number }> }) {
|
||||
function capPersistedImagesToRequestedCount<T extends { images: string[]; thumbnails: Record<string, string>; dimensions: Record<string, { width: number; height: number }> }>(
|
||||
result: T,
|
||||
requestedCount: number,
|
||||
) {
|
||||
const cappedCount = Math.max(1, Math.floor(Number(requestedCount) || 1));
|
||||
if (result.images.length <= cappedCount) return result;
|
||||
const images = result.images.slice(0, cappedCount);
|
||||
return {
|
||||
images: result.images,
|
||||
thumbnails: result.thumbnails,
|
||||
thumbnailUrls: result.images.map(url => result.thumbnails[url] || url),
|
||||
dimensions: result.dimensions,
|
||||
...result,
|
||||
images,
|
||||
thumbnails: Object.fromEntries(images.map(url => [url, result.thumbnails[url] || url])),
|
||||
dimensions: Object.fromEntries(
|
||||
images
|
||||
.map(url => [url, result.dimensions[url]])
|
||||
.filter((entry): entry is [string, { width: number; height: number }] => Boolean(entry[1])),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function imageResponsePayload(
|
||||
result: { images: string[]; thumbnails: Record<string, string>; dimensions: Record<string, { width: number; height: number }> },
|
||||
requestedCount = result.images.length,
|
||||
) {
|
||||
const capped = capPersistedImagesToRequestedCount(result, requestedCount);
|
||||
return {
|
||||
images: capped.images,
|
||||
thumbnails: capped.thumbnails,
|
||||
thumbnailUrls: capped.images.map(url => capped.thumbnails[url] || url),
|
||||
dimensions: capped.dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -404,6 +430,7 @@ async function requestQualifiedCustomImages(
|
||||
'generated/images',
|
||||
targetSize,
|
||||
`Custom API Image attempt ${attempt}`,
|
||||
targetCount - accepted.length,
|
||||
);
|
||||
accepted.push(...persisted.images);
|
||||
Object.assign(thumbnails, persisted.thumbnails);
|
||||
@@ -913,8 +940,8 @@ async function customApiImageToImage(
|
||||
onProgress,
|
||||
);
|
||||
if (result1.success && result1.images) {
|
||||
const persisted = await persistQualifiedImageUrls(result1.images, 'generated/images', targetSize, 'Custom API img2img strategy1');
|
||||
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
|
||||
const persisted = await persistQualifiedImageUrls(result1.images, 'generated/images', targetSize, 'Custom API img2img strategy1', count);
|
||||
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted, count));
|
||||
result1 = { ...result1, success: false, error: imageResultFailureError(targetSize, persisted) };
|
||||
}
|
||||
}
|
||||
@@ -929,8 +956,8 @@ async function customApiImageToImage(
|
||||
onProgress,
|
||||
);
|
||||
if (result2.success && result2.images) {
|
||||
const persisted = await persistQualifiedImageUrls(result2.images, 'generated/images', targetSize, 'Custom API img2img strategy2');
|
||||
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
|
||||
const persisted = await persistQualifiedImageUrls(result2.images, 'generated/images', targetSize, 'Custom API img2img strategy2', count);
|
||||
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted, count));
|
||||
result2.success = false;
|
||||
result2.error = imageResultFailureError(targetSize, persisted);
|
||||
}
|
||||
@@ -945,8 +972,8 @@ async function customApiImageToImage(
|
||||
onProgress,
|
||||
);
|
||||
if (result3.success && result3.images) {
|
||||
const persisted = await persistQualifiedImageUrls(result3.images, 'generated/images', targetSize, 'Custom API img2img strategy3');
|
||||
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
|
||||
const persisted = await persistQualifiedImageUrls(result3.images, 'generated/images', targetSize, 'Custom API img2img strategy3', count);
|
||||
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted, count));
|
||||
result3.success = false;
|
||||
result3.error = imageResultFailureError(targetSize, persisted);
|
||||
}
|
||||
@@ -1113,11 +1140,12 @@ export async function POST(request: NextRequest) {
|
||||
'generated/images',
|
||||
targetSize,
|
||||
'User API Manifest Image',
|
||||
resolvedAutoParams.count,
|
||||
);
|
||||
if (persisted.images.length === 0) {
|
||||
return NextResponse.json({ error: generatedImagePersistenceError(persisted) }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json(imageResponsePayload(persisted));
|
||||
return NextResponse.json(imageResponsePayload(persisted, resolvedAutoParams.count));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1230,7 +1258,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
console.log('[Custom API Image] Persisted', customGenerationResult.images.length, '/', n, 'qualified images',
|
||||
'| target:', customTargetSize ? formatTargetSize(customTargetSize) : 'none');
|
||||
return NextResponse.json(imageResponsePayload(customGenerationResult));
|
||||
return NextResponse.json(imageResponsePayload(customGenerationResult, n));
|
||||
} catch (customError: unknown) {
|
||||
const msg = customError instanceof Error ? customError.message : '自定义API请求异常';
|
||||
console.error('[Custom API Image Exception]', msg);
|
||||
@@ -1365,11 +1393,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '图片生成失败,请稍后重试' }, { status: 500 });
|
||||
}
|
||||
|
||||
const persistedImages = await persistQualifiedImageUrls(images, 'generated/images', targetSize, 'SDK Image');
|
||||
const persistedImages = await persistQualifiedImageUrls(images, 'generated/images', targetSize, 'SDK Image', resolvedAutoParams.count);
|
||||
if (persistedImages.images.length === 0) {
|
||||
return NextResponse.json({ error: imageResultFailureError(targetSize, persistedImages) }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json(imageResponsePayload(persistedImages));
|
||||
return NextResponse.json(imageResponsePayload(persistedImages, resolvedAutoParams.count));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '图片生成失败';
|
||||
console.error('[Image Generation Error]', message, error instanceof Error ? error.stack : '');
|
||||
|
||||
Reference in New Issue
Block a user