fix: preserve image download format
This commit is contained in:
@@ -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 `生成数量`. |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user