fix: preserve image download format

This commit is contained in:
FengLee
2026-06-04 12:02:17 +08:00
parent 4a00eb7ef5
commit 79f00aa8f2
9 changed files with 89 additions and 27 deletions

View File

@@ -53,7 +53,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. |
| 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/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, `src/lib/utils.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. If the object headers/magic bytes are already JPEG/WebP but the downloaded file still appears as PNG, check frontend `downloadFile(...)` callers and ensure filenames use `getImageDownloadExtension(...)` instead of a hard-coded `.png`. |
| 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

@@ -55,4 +55,21 @@ await runTest('image generation persistence passes the requested output format t
assert.match(source, /SDK Image'[\s\S]*resolvedOutputFormat,\s*resolvedImageQuality/);
});
await runTest('image downloads derive filename extension from URL or selected output format', () => {
const utils = fs.readFileSync(path.join(repoRoot, 'src/lib/utils.ts'), 'utf8');
const textToImage = fs.readFileSync(path.join(repoRoot, 'src/components/create/text-to-image.tsx'), 'utf8');
const imageToImage = fs.readFileSync(path.join(repoRoot, 'src/components/create/image-to-image.tsx'), 'utf8');
const detail = fs.readFileSync(path.join(repoRoot, 'src/components/creation-detail-dialog.tsx'), 'utf8');
const lightbox = fs.readFileSync(path.join(repoRoot, 'src/components/lightbox.tsx'), 'utf8');
assert.match(utils, /export function getImageDownloadExtension\(/);
assert.match(utils, /jpeg['"]?\s*\)\s*return ['"]jpg['"]/);
assert.doesNotMatch(textToImage, /downloadFile\(url,\s*`miaojing-\$\{Date\.now\(\)\}-\$\{index\}\.png`\)/);
assert.doesNotMatch(imageToImage, /downloadFile\(url,\s*`miaojing-img2img-\$\{Date\.now\(\)\}-\$\{index\}\.png`\)/);
assert.match(textToImage, /getImageDownloadExtension\(url,\s*outputFormat\)/);
assert.match(imageToImage, /getImageDownloadExtension\(url,\s*outputFormat\)/);
assert.match(detail, /getImageDownloadExtension\(\s*url,[\s\S]*record\.params\?\.outputFormat/);
assert.match(lightbox, /getImageDownloadExtension\(src\)/);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -24,7 +24,7 @@ import {
Trash2,
Search,
} from 'lucide-react';
import { copyTextToClipboard, downloadFile, triggerDownloadFile } from '@/lib/utils';
import { copyTextToClipboard, downloadFile, getImageDownloadExtension, triggerDownloadFile } from '@/lib/utils';
import { useAuth } from '@/lib/auth-store';
import { FullscreenPreview } from '@/components/fullscreen-preview';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
@@ -275,7 +275,13 @@ function escapeSvgText(value: string): string {
}
function getDownloadFilename(work: GalleryWork): string {
return `miaojing-${work.id}.${isVideoWork(work) ? 'mp4' : 'png'}`;
const extension = isVideoWork(work)
? 'mp4'
: getImageDownloadExtension(
work.url,
typeof work.params?.outputFormat === 'string' ? work.params.outputFormat : undefined,
);
return `miaojing-${work.id}.${extension}`;
}
function formatDate(iso: string): string {
@@ -1389,7 +1395,7 @@ export default function GalleryPage() {
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"
onClick={(event) => handleDownload(url, `miaojing-reference-${selectedWork.id}-${index + 1}.png`, event)}
onClick={(event) => handleDownload(url, `miaojing-reference-${selectedWork.id}-${index + 1}.${getImageDownloadExtension(url)}`, event)}
>
<Download className="h-3.5 w-3.5" />
</button>

View File

@@ -32,7 +32,7 @@ import { getCustomApiModelLabel, getSystemApiModelLabel } from '@/lib/model-disp
import { GroupedModelSelectItems } from '@/components/create/grouped-model-select-items';
import { Sparkles, Loader2, Download, Upload, Wand2, Image as ImageIcon, History, ChevronDown, ChevronUp, Plus, X, KeyRound, Share2 } from 'lucide-react';
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
import { downloadFile } from '@/lib/utils';
import { downloadFile, getImageDownloadExtension } from '@/lib/utils';
import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
import { toast } from 'sonner';
import Link from 'next/link';
@@ -714,10 +714,11 @@ export function ImageToImagePanel() {
}
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, strength, refImages, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, updateProfile, resolveGenerationParams, removeActiveTask, updateActiveTask, requestSyncConfirmation]);
const handleDownload = useCallback(async (url: string, index: number) => {
const result = await downloadFile(url, `miaojing-img2img-${Date.now()}-${index}.png`);
if (!result.ok) toast.error(result.error || '下载失败');
}, []);
const handleDownload = useCallback(async (url: string, index: number) => {
const extension = getImageDownloadExtension(url, outputFormat);
const result = await downloadFile(url, `miaojing-img2img-${Date.now()}-${index}.${extension}`);
if (!result.ok) toast.error(result.error || '下载失败');
}, [outputFormat]);
const handleShareToGallery = useCallback(async (url: string) => {
if (isUrlPublished(url)) {

View File

@@ -31,7 +31,7 @@ import { getCustomApiModelLabel, getSystemApiModelLabel } from '@/lib/model-disp
import { GroupedModelSelectItems } from '@/components/create/grouped-model-select-items';
import { Sparkles, Loader2, Download, Wand2, Image as ImageIcon, History, ChevronDown, ChevronUp, Plus, KeyRound, Share2 } from 'lucide-react';
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
import { downloadFile } from '@/lib/utils';
import { downloadFile, getImageDownloadExtension } from '@/lib/utils';
import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
import { toast } from 'sonner';
import Link from 'next/link';
@@ -668,9 +668,10 @@ export function TextToImagePanel() {
// Download
const handleDownload = useCallback(async (url: string, index: number) => {
const result = await downloadFile(url, `miaojing-${Date.now()}-${index}.png`);
const extension = getImageDownloadExtension(url, outputFormat);
const result = await downloadFile(url, `miaojing-${Date.now()}-${index}.${extension}`);
if (!result.ok) toast.error(result.error || '下载失败');
}, []);
}, [outputFormat]);
const handleShareToGallery = useCallback(async (url: string) => {
if (isUrlPublished(url)) {

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { type CreationRecord, deleteCreationRecord, isPlaceholder, shareToGallery, isUrlPublished } from '@/lib/creation-history-store';
import { buildImageCreationReuseDraft, writeImageCreationReuseDraft } from '@/lib/creation-reuse';
import { copyTextToClipboard, downloadFile } from '@/lib/utils';
import { copyTextToClipboard, downloadFile, getImageDownloadExtension } from '@/lib/utils';
import { useAuth } from '@/lib/auth-store';
import {
Dialog,
@@ -189,7 +189,12 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
return;
}
const ext = record.type === 'video' ? 'mp4' : 'png';
const ext = record.type === 'video'
? 'mp4'
: getImageDownloadExtension(
url,
typeof record.params?.outputFormat === 'string' ? record.params.outputFormat : undefined,
);
const filename = `miaojing-${Date.now()}.${ext}`;
const result = await downloadFile(url, filename);
if (result.ok) {
@@ -637,7 +642,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
</button>
<button
onClick={async () => {
const result = await downloadFile(url, `miaojing-reference-${record.id}-${index + 1}.png`);
const result = await downloadFile(url, `miaojing-reference-${record.id}-${index + 1}.${getImageDownloadExtension(url)}`);
if (!result.ok) window.open(url, '_blank');
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState, type MouseEvent as ReactMouseEvent } from 'react';
import { useRouter } from 'next/navigation';
import { Copy, Download, PencilLine, Share2 } from 'lucide-react';
import { downloadFile, copyTextToClipboard } from '@/lib/utils';
import { downloadFile, copyTextToClipboard, getImageDownloadExtension } from '@/lib/utils';
import { writeCreationReuseDraft } from '@/lib/creation-reuse';
import { toast } from 'sonner';
@@ -30,16 +30,9 @@ function getImageViewerUrl(src: string): string {
return viewerUrl.toString();
}
function getImageExtension(src: string): string {
const path = src.split('?')[0].split('#')[0];
const ext = path.split('.').pop()?.toLowerCase();
if (ext && ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return ext;
return 'png';
}
async function fetchOriginalImageBlob(src: string): Promise<Blob> {
const absoluteUrl = getAbsoluteImageUrl(src);
const response = await fetch(`/api/download?url=${encodeURIComponent(absoluteUrl)}&filename=image.${getImageExtension(src)}`, {
const response = await fetch(`/api/download?url=${encodeURIComponent(absoluteUrl)}&filename=image.${getImageDownloadExtension(src)}`, {
cache: 'no-store',
});
if (!response.ok) {
@@ -112,7 +105,7 @@ export function useImageActionsContextMenu() {
const downloadImage = useCallback(async () => {
if (!menu) return;
const result = await downloadFile(getAbsoluteImageUrl(menu.src), `miaojing-original-${Date.now()}.${getImageExtension(menu.src)}`);
const result = await downloadFile(getAbsoluteImageUrl(menu.src), `miaojing-original-${Date.now()}.${getImageDownloadExtension(menu.src)}`);
if (result.ok) {
toast.success('原图下载已开始');
} else {

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useRef, type PointerEvent } from 'react';
import { X, Download, ZoomIn, ZoomOut } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { downloadFile } from '@/lib/utils';
import { downloadFile, getImageDownloadExtension } from '@/lib/utils';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
@@ -107,7 +107,7 @@ export function ImageLightbox({ src, fallbackSrc, alt, open, onClose }: Lightbox
if (!open) return null;
const handleDownload = async () => {
const result = await downloadFile(src, `miaojing-${Date.now()}.png`);
const result = await downloadFile(src, `miaojing-${Date.now()}.${getImageDownloadExtension(src)}`);
if (!result.ok) {
// Fallback: open in new tab
window.open(src, '_blank');

View File

@@ -182,6 +182,17 @@ export async function downloadFile(
}
}
export function getImageDownloadExtension(url: string, preferredFormat?: string | null): string {
const normalizedUrlExtension = normalizeImageDownloadExtension(getImageExtensionFromUrl(url));
if (normalizedUrlExtension) return normalizedUrlExtension;
const normalizedFormat = normalizeImageDownloadExtension(preferredFormat);
if (normalizedFormat) return normalizedFormat;
const normalizedDataUrlExtension = normalizeImageDownloadExtension(getImageExtensionFromDataUrl(url));
return normalizedDataUrlExtension || 'png';
}
export function getDownloadProxyUrl(url: string, filename: string): string {
return `/api/download?url=${encodeURIComponent(url)}&filename=${encodeURIComponent(filename)}`;
}
@@ -197,6 +208,34 @@ export function triggerDownloadFile(url: string, filename: string): void {
link.remove();
}
function getImageExtensionFromUrl(url: string): string | null {
const trimmed = url.trim();
if (!trimmed || trimmed.startsWith('data:')) return null;
try {
const base = typeof window !== 'undefined' ? window.location.href : 'http://localhost';
const pathname = new URL(trimmed, base).pathname;
return pathname.split('.').pop()?.toLowerCase() || null;
} catch {
const pathname = trimmed.split('?')[0].split('#')[0];
return pathname.split('.').pop()?.toLowerCase() || null;
}
}
function getImageExtensionFromDataUrl(url: string): string | null {
const match = url.match(/^data:(image\/[^;,]+)/i);
if (!match) return null;
return match[1].split('/')[1]?.toLowerCase() || null;
}
function normalizeImageDownloadExtension(value: string | null | undefined): string | null {
const normalized = value?.trim().toLowerCase();
if (!normalized) return null;
if (normalized === 'jpg' || normalized === 'jpeg') return 'jpg';
if (normalized === 'png' || normalized === 'webp' || normalized === 'gif') return normalized;
return null;
}
/**
* Safely parse a fetch Response as JSON.
* Handles empty bodies, HTML error pages, and non-JSON responses gracefully.