Refine previews and concurrent generation UI

This commit is contained in:
FengLee
2026-05-12 22:07:28 +08:00
parent c52bfa98da
commit 11d98a6fc6
13 changed files with 411 additions and 190 deletions

View File

@@ -44,14 +44,14 @@ Use this table before searching.
| --- | --- | --- |
| Home page, shell, navigation, footer, announcement popup | `src/app/page.tsx`, `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, `src/components/announcement-popup.tsx` | `src/lib/site-config.ts`, `src/app/api/site-config/route.ts`, `src/app/api/announcements/route.ts` |
| Create center tabs | `src/app/create/page.tsx` | `src/components/create/*` |
| Text/image generation | `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/generation-job-*`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts` |
| Video generation | `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | `src/app/api/generate/video/route.ts` |
| Text/image generation | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/lib/generation-job-*`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`. Image panels allow multiple active submissions and keep active job cards inside the results column. |
| Video generation | `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/components/create/generation-task-list.tsx` | `src/app/api/generate/video/route.ts`. Video panels also allow multiple active submissions and keep active job cards inside the results column. |
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts` |
| Model/provider visibility | `src/lib/model-config.ts`, `src/lib/model-config-types.ts`, `src/lib/server-api-config.ts` | `src/app/api/model-config/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/app/api/admin/providers/route.ts`, `src/app/api/user-api-keys/route.ts` |
| User auth/login/register/profile | `src/lib/session-auth.ts`, `src/lib/auth-store.ts` | `src/app/api/auth/*`, `src/app/api/profile/*` |
| Admin console | `src/app/console/page.tsx`, `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/*` | `src/components/admin/*`, `src/app/api/admin/*` |
| Canvas (legacy, disabled in UI) | `src/app/canvas/page.tsx`, `src/components/canvas/infinite-canvas-workspace.tsx`, `src/components/canvas/react-flow-canvas.tsx` | `/canvas` intentionally returns 404 and navbar must not show `画布`; legacy source/API files remain only for future cleanup or explicit re-enable work. |
| Gallery and creation history | `src/app/gallery/page.tsx`, `src/app/profile/page.tsx`, `src/components/profile/creation-history-tab.tsx` | `src/lib/creation-history-store.ts`, `src/app/api/gallery/*`, `src/app/api/creation-history/route.ts` |
| Gallery and creation history | `src/app/gallery/page.tsx`, `src/app/profile/page.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/components/image-metadata-badge.tsx` | `src/lib/creation-history-store.ts`, `src/app/api/gallery/*`, `src/app/api/creation-history/route.ts`. Gallery/detail image previews show actual ratio and natural resolution in the upper-right badge. |
| Local files/downloads | `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts` | `src/app/api/download/route.ts` |
| Email and policy pages | `src/lib/email-service.ts`, `src/components/site-policy-page.tsx` | `src/app/api/email/*`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` |
| Upgrade/deploy/backup | `scripts/*`, `ecosystem.config.cjs` | `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx` |

View File

@@ -40,14 +40,15 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Symptom | Check Files | What To Verify |
| --- | --- | --- |
| Create button does nothing | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/generation-job-client.ts` | Client validation, auth token, `/api/generation-jobs` POST response, UI disabled/loading state. |
| Cannot submit a new generation job while another job is running, or active job cards overflow horizontally | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/components/create/generation-task-list.tsx` | Create panels should keep the submit button enabled while models are available; active job cards should render inside the results column with wrapping vertical growth, not outside the result area. |
| 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/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser. |
| 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 or aspect ratio | `src/lib/model-config.ts`, `src/app/api/generate/image/route.ts` | `resolveImageSize`, `resolveCustomApiImageSize`, New API/DALL-E size normalization, prompt aspect hint. |
| Reference image upload too large or fails | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/browser-image-compression.ts`, `src/lib/server-image-compression.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Browser compression, `MAX_UPSTREAM_REFERENCE_IMAGE_BYTES`, data URL conversion. |
| Reference image upload too large or fails | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/browser-image-compression.ts`, `src/lib/server-image-compression.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Browser compression, `MAX_UPSTREAM_REFERENCE_IMAGE_BYTES`, data URL conversion. Uploaded reference thumbnails should single-click into the no-container `BareImagePreview`; blank area closes it. |
| Generated result previews but does not persist | `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts`, `src/lib/local-storage.ts`, `src/app/api/creation-history/route.ts` | Media copied to local storage, presigned URL returned, history POST called. |
| Fullscreen/preview/download broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. |
| Fullscreen/preview/download broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. Image result and history/detail previews should show upper-right actual aspect ratio and natural resolution via `ImageMetadataBadge`. |
| Image generation count dropdown too wide, options missing, or manual count input unavailable | `src/components/create/image-count-combobox.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Use the shared compact combobox instead of browser `datalist`; verify manual numeric entry and dropdown options in both text-to-image and image-to-image panels. |
| Style presets are hardcoded, missing, or not ordered by usage | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/app/api/generation-jobs/route.ts` | Presets should come from `image_style_presets`; `generation-jobs` increments `usage_count`; GET `/api/style-presets` should return active presets sorted by usage count. |
| Reverse prompt option missing | `src/components/create/reverse-prompt-panel.tsx`, `src/app/api/generate/reverse-prompt/route.ts` | UI option list and server `outputMode` handling both updated, app rebuilt/restarted if deployed. |
@@ -72,6 +73,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Imported gallery images do not render after production data import | `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, DB `works.result_url` | Check whether production media files were copied into `LOCAL_STORAGE_DIR`; DB rows alone are not enough for relative `/api/local-storage/*` URLs. |
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
| Search/filter/sort wrong | `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | Query params `type`, `limit`, `offset`, `sort`, `q/search`; SQL where/order. |
| Gallery search box looks inconsistent with the rest of the UI | `src/app/gallery/page.tsx` | The search field is a custom glass panel with an inner focused input surface; avoid reverting it to a plain transparent input row. |
## Canvas

View File

@@ -49,16 +49,16 @@ Use this document to jump directly to code before broad searching.
| Feature | Primary Files | Server/API Files |
| --- | --- | --- |
| Tab container | `src/app/create/page.tsx` | N/A |
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts` |
| Image to image | `src/components/create/image-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts` |
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts` |
| Image to video | `src/components/create/image-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts` |
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx` inside the results column. |
| Image to image | `src/components/create/image-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`. Reference thumbnails single-click into a bare image overlay, and active jobs render through `src/components/create/generation-task-list.tsx`. |
| Text to video | `src/components/create/text-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx`. |
| Image to video | `src/components/create/image-to-video.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image, and active jobs render through `src/components/create/generation-task-list.tsx`. |
| Reverse prompt | `src/components/create/reverse-prompt-panel.tsx` | `src/app/api/generate/reverse-prompt/route.ts`, `src/app/api/generate/suggest-prompt/route.ts` |
| Prompt textarea | `src/components/create/expandable-prompt-textarea.tsx` | Shared prompt input. |
| Image count input/dropdown | `src/components/create/image-count-combobox.tsx` | Shared compact count control for manual image count entry and common dropdown options. |
| Style presets | `src/components/create/style-preset-selector.tsx`, `src/lib/style-presets-client.ts`, `src/app/api/style-presets/route.ts`, `src/lib/style-preset-store.ts`, `src/lib/model-config.ts` | Style presets are stored in `image_style_presets`, seeded from defaults, sorted by `usage_count`, and incremented from image generation jobs. |
| Loading/error panels | `src/components/create/generation-loading-panel.tsx`, `src/components/create/generation-error-panel.tsx` | Shared generation status UI. |
| Lightbox/fullscreen | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx` | Preview, copy, download, share. |
| Loading/error panels | `src/components/create/generation-loading-panel.tsx`, `src/components/create/generation-task-list.tsx`, `src/components/create/generation-error-panel.tsx` | Shared generation status UI. `generation-task-list` keeps multiple active job cards constrained to the results column. |
| Lightbox/fullscreen | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-metadata-badge.tsx` | Preview, copy, download, share. Image previews show actual natural resolution and computed aspect ratio in the upper-right metadata badge. `BareImagePreview` is the no-container overlay for uploaded reference image previews. |
## Generation System
@@ -101,7 +101,7 @@ Use this document to jump directly to code before broad searching.
| Feature | Files | Notes |
| --- | --- | --- |
| Public gallery page | `src/app/gallery/page.tsx` | Lists public works, search/sort/filter, preview/download. |
| Public gallery page | `src/app/gallery/page.tsx` | Lists public works, search/sort/filter, preview/download. The search box is custom styled in-page to match the glass UI; gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution. |
| Public gallery API | `src/app/api/gallery/route.ts` | GET public works, admin DELETE unpublishes. |
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies media into gallery folders and inserts public work. |
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works and published state. |

View File

@@ -28,6 +28,7 @@ import { copyTextToClipboard, downloadFile } from '@/lib/utils';
import { usePublishedWorks, useCreationHistory, syncPublishedToSupabase, type PublishedWork } from '@/lib/creation-history-store';
import { useAuth } from '@/lib/auth-store';
import { FullscreenPreview } from '@/components/fullscreen-preview';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
import { toast } from 'sonner';
const CATEGORIES = [
@@ -436,22 +437,25 @@ export default function GalleryPage() {
<p className="mt-2 text-muted-foreground"></p>
</div>
<div className={`${galleryGlassPanel} mb-4 flex items-center gap-2.5 rounded-2xl px-4 py-2`}>
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索作品、用户、提示词、模型"
className="h-8 min-w-0 flex-1 bg-transparent text-sm font-medium outline-none placeholder:text-muted-foreground/70"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
<div className={`${galleryGlassPanel} mb-4 rounded-[28px] border-amber-900/10 p-3 shadow-[0_18px_45px_rgba(83,61,27,0.08),inset_0_1px_0_rgba(255,255,255,0.70)]`}>
<div className="flex h-12 items-center gap-3 rounded-2xl border border-amber-900/12 bg-white/58 px-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.82),inset_0_0_0_1px_rgba(255,255,255,0.28)] transition-colors focus-within:border-primary/35 focus-within:bg-white/72 focus-within:shadow-[0_0_0_3px_rgba(245,166,35,0.12),inset_0_1px_0_rgba(255,255,255,0.86)] dark:border-white/10 dark:bg-white/[0.045] dark:focus-within:bg-white/[0.07]">
<Search className="h-[18px] w-[18px] shrink-0 text-primary/75" />
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索作品、用户、提示词、模型"
className="h-full min-w-0 flex-1 bg-transparent text-sm font-medium text-foreground outline-none placeholder:text-muted-foreground/62"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
aria-label="清空搜索"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Filters */}
@@ -674,6 +678,9 @@ export default function GalleryPage() {
onDoubleClick={() => setFullscreenSrc(selectedWork.url)}
/>
)}
{selectedWork.type !== 'video' && selectedWork.type !== 'text2video' && selectedWork.type !== 'img2video' && (
<ImageMetadataBadge src={selectedWork.url} className="absolute right-4 top-4 z-20" />
)}
{/* Fullscreen button overlay */}
{selectedWork.type !== 'video' && selectedWork.type !== 'text2video' && selectedWork.type !== 'img2video' && (
<button

View File

@@ -0,0 +1,49 @@
'use client';
import { GenerationLoadingPanel } from '@/components/create/generation-loading-panel';
import type { GenerationJobStatus } from '@/lib/generation-job-client';
export type ActiveGenerationTask = {
id: string;
title: string;
startedAt: number;
estimateSeconds: number;
jobStatus: GenerationJobStatus | null;
finalCountdownSeconds: number | null;
};
type GenerationTaskListProps = {
tasks: ActiveGenerationTask[];
};
export function GenerationTaskList({ tasks }: GenerationTaskListProps) {
if (tasks.length === 0) return null;
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 text-sm font-medium">
<span></span>
<span className="rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs text-primary">
{tasks.length}
</span>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{tasks.map((task, index) => (
<div
key={task.id}
className="liquid-glass min-h-[260px] overflow-hidden rounded-2xl border-dashed text-muted-foreground"
>
<GenerationLoadingPanel
startedAt={task.startedAt}
estimateSeconds={task.estimateSeconds}
jobStatus={task.jobStatus}
finalCountdownSeconds={task.finalCountdownSeconds}
title={`${task.title} #${index + 1}`}
className="min-h-[260px] px-5 py-10"
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -38,15 +38,15 @@ import { GenerationJobStillRunningError, runGenerationFinalCountdown, runGenerat
import { useSiteConfig } from '@/lib/site-config';
import { toast } from 'sonner';
import Link from 'next/link';
import { ImageLightbox } from '@/components/lightbox';
import { BareImagePreview, ImageLightbox } from '@/components/lightbox';
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
import { GenerationErrorPanel, createGenerationError, type GenerationErrorState } from '@/components/create/generation-error-panel';
import { ExpandablePromptTextarea } from '@/components/create/expandable-prompt-textarea';
import { GenerationLoadingPanel } from '@/components/create/generation-loading-panel';
import { compressImageFileForUpload } from '@/lib/browser-image-compression';
import { ImageCountCombobox } from '@/components/create/image-count-combobox';
import { StylePresetSelector } from '@/components/create/style-preset-selector';
import { useImageStylePresets } from '@/lib/style-presets-client';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
const IMAGE_TO_IMAGE_DRAFT_KEY = 'miaojing:image-to-image-draft';
@@ -78,21 +78,20 @@ export function ImageToImagePanel() {
const [refImages, setRefImages] = useState<RefImage[]>([]);
// Generation state
const [generating, setGenerating] = useState(false);
const [generationStartedAt, setGenerationStartedAt] = useState(() => Date.now());
const [generationJobStatus, setGenerationJobStatus] = useState<GenerationJobStatus | null>(null);
const [finalCountdownSeconds, setFinalCountdownSeconds] = useState<number | null>(null);
const [activeTasks, setActiveTasks] = useState<ActiveGenerationTask[]>([]);
const [results, setResults] = useState<string[]>([]);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const generating = activeTasks.length > 0;
// History
const { records, add: addRecord } = useCreationHistory();
const [showHistory, setShowHistory] = useState(false);
const imageHistory = records.filter(r => getCreationMode(r) === 'img2img');
// Lightbox state
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
// Lightbox state
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const [referencePreviewSrc, setReferencePreviewSrc] = useState<string | null>(null);
// History detail dialog
const [selectedHistoryRecord, setSelectedHistoryRecord] = useState<CreationRecord | null>(null);
@@ -343,6 +342,14 @@ export function ImageToImagePanel() {
return { aspectRatio: resolvedAspectRatio, resolution: resolvedResolution, count: resolvedCount };
}, [aspectRatio, resolution, count, inferredImageParams]);
const updateActiveTask = useCallback((taskId: string, update: Partial<ActiveGenerationTask>) => {
setActiveTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...update } : task));
}, []);
const removeActiveTask = useCallback((taskId: string) => {
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
}, []);
// Generate
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) { toast.error('请输入创作描述'); return; }
@@ -350,14 +357,21 @@ export function ImageToImagePanel() {
if (refImages.length === 0) { toast.error('请至少上传一张参考图片'); return; }
setGenerationError(null);
setResults([]);
setGenerationStartedAt(Date.now());
setGenerationJobStatus(null);
setFinalCountdownSeconds(null);
setGenerating(true);
const taskId = `img2img-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
try {
const resolvedParams = resolveGenerationParams();
if (!resolvedParams) return;
setActiveTasks(prev => [
...prev,
{
id: taskId,
title: '正在生成图片',
startedAt: Date.now(),
estimateSeconds: 90,
jobStatus: null,
finalCountdownSeconds: null,
},
]);
// Send first reference image as primary, others as additional context
const primaryImage = refImages[0].dataUrl;
// Keep the outgoing API size aligned with the selected resolution.
@@ -400,11 +414,11 @@ export function ImageToImagePanel() {
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
'image',
requestBody,
{ timeoutMs: 900_000, onStatus: setGenerationJobStatus },
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
);
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
if (data.images && data.images.length > 0) {
setResults(data.images);
setResults(prev => [...data.images!, ...prev]);
setGenerationError(null);
for (const url of data.images) {
addRecord({
@@ -444,6 +458,7 @@ export function ImageToImagePanel() {
} catch (err: unknown) {
if (err instanceof GenerationJobStillRunningError) {
setGenerationError(null);
removeActiveTask(taskId);
toast.info('生成任务仍在执行,可稍后在创作历史中查看');
} else if (err instanceof DOMException && err.name === 'AbortError') {
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
@@ -451,8 +466,8 @@ export function ImageToImagePanel() {
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
}
}
finally { setGenerating(false); setFinalCountdownSeconds(null); }
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, strength, refImages, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, resolveGenerationParams]);
finally { removeActiveTask(taskId); }
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, strength, refImages, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, resolveGenerationParams, removeActiveTask, updateActiveTask]);
const handleDownload = useCallback(async (url: string, index: number) => {
const result = await downloadFile(url, `miaojing-img2img-${Date.now()}-${index}.png`);
@@ -496,15 +511,22 @@ export function ImageToImagePanel() {
{refImages.length > 0 ? (
<div className="grid w-full grid-cols-3 gap-3">
{refImages.map(img => (
<div key={img.id} className="liquid-glass-soft relative group aspect-square overflow-hidden rounded-2xl">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.dataUrl} alt={img.name} className="w-full h-full object-cover" />
<button
className="absolute top-0.5 right-0.5 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeRefImage(img.id)}
>
<X className="h-3 w-3" />
</button>
<div
key={img.id}
className="liquid-glass-soft relative group aspect-square cursor-zoom-in overflow-hidden rounded-2xl"
onClick={() => setReferencePreviewSrc(img.dataUrl)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.dataUrl} alt={img.name} className="w-full h-full object-cover" />
<button
className="absolute top-0.5 right-0.5 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(event) => {
event.stopPropagation();
removeRefImage(img.id);
}}
>
<X className="h-3 w-3" />
</button>
</div>
))}
<button
@@ -657,24 +679,15 @@ export function ImageToImagePanel() {
</div>
{/* Generate */}
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={generating || !hasModels}>
{generating ? (<><Loader2 className="h-4 w-4 animate-spin" />...</>) : (<><Sparkles className="h-4 w-4" /> {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
</Button>
</div>
{/* Right: Results + History */}
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
{generating ? (<><Plus className="h-4 w-4" /></>) : (<><Sparkles className="h-4 w-4" /> {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
</Button>
</div>
{/* Right: Results + History */}
<div className="min-w-0 space-y-4">
{generating ? (
<div className="liquid-glass min-h-[300px] overflow-hidden rounded-2xl border-dashed text-muted-foreground">
<GenerationLoadingPanel
startedAt={generationStartedAt}
estimateSeconds={90}
jobStatus={generationJobStatus}
finalCountdownSeconds={finalCountdownSeconds}
title="正在生成图片"
/>
</div>
) : generationError ? (
<GenerationTaskList tasks={activeTasks} />
{generationError ? (
<GenerationErrorPanel error={generationError} />
) : results.length > 0 ? (
<div className="space-y-3">
@@ -736,10 +749,11 @@ export function ImageToImagePanel() {
)}
</div>
{/* Lightbox */}
<ImageLightbox src={lightboxSrc || ''} open={!!lightboxSrc} onClose={() => setLightboxSrc(null)} />
{/* History Detail Dialog */}
{/* Lightbox */}
<ImageLightbox src={lightboxSrc || ''} open={!!lightboxSrc} onClose={() => setLightboxSrc(null)} />
<BareImagePreview src={referencePreviewSrc || ''} open={!!referencePreviewSrc} onClose={() => setReferencePreviewSrc(null)} />
{/* History Detail Dialog */}
<CreationDetailDialog
record={selectedHistoryRecord}
open={!!selectedHistoryRecord}

View File

@@ -30,8 +30,9 @@ import Link from 'next/link';
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
import { GenerationErrorPanel, createGenerationError, type GenerationErrorState } from '@/components/create/generation-error-panel';
import { ExpandablePromptTextarea } from '@/components/create/expandable-prompt-textarea';
import { GenerationLoadingPanel } from '@/components/create/generation-loading-panel';
import { compressImageFileForUpload } from '@/lib/browser-image-compression';
import { BareImagePreview } from '@/components/lightbox';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
interface RefImage {
id: string;
@@ -53,13 +54,12 @@ export function ImageToVideoPanel() {
const [cameraMovement, setCameraMovement] = useState(IMG2VIDEO_CAMERA_MOVEMENTS[0]);
const [refImages, setRefImages] = useState<RefImage[]>([]);
const [generating, setGenerating] = useState(false);
const [generationStartedAt, setGenerationStartedAt] = useState(() => Date.now());
const [generationJobStatus, setGenerationJobStatus] = useState<GenerationJobStatus | null>(null);
const [finalCountdownSeconds, setFinalCountdownSeconds] = useState<number | null>(null);
const [activeTasks, setActiveTasks] = useState<ActiveGenerationTask[]>([]);
const [results, setResults] = useState<string[]>([]);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const [referencePreviewSrc, setReferencePreviewSrc] = useState<string | null>(null);
const generating = activeTasks.length > 0;
const { records, add: addRecord } = useCreationHistory();
const [showHistory, setShowHistory] = useState(false);
@@ -228,20 +228,35 @@ export function ImageToVideoPanel() {
setRefImages(prev => prev.filter(img => img.id !== id));
}, []);
const credits = calcVideoCredits(duration, selectedModel);
const handleGenerate = useCallback(async () => {
const credits = calcVideoCredits(duration, selectedModel);
const updateActiveTask = useCallback((taskId: string, update: Partial<ActiveGenerationTask>) => {
setActiveTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...update } : task));
}, []);
const removeActiveTask = useCallback((taskId: string) => {
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
}, []);
const handleGenerate = useCallback(async () => {
if (!user) { toast.error('请先登录'); return; }
if (refImages.length === 0 && !prompt.trim()) { toast.error('请上传参考图片或输入视频描述'); return; }
setGenerationError(null);
setResults([]);
setGenerationStartedAt(Date.now());
setGenerationJobStatus(null);
setFinalCountdownSeconds(null);
setGenerating(true);
try {
const primaryImage = refImages[0]?.dataUrl;
const taskId = `img2video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
try {
setActiveTasks(prev => [
...prev,
{
id: taskId,
title: '正在生成视频',
startedAt: Date.now(),
estimateSeconds: 300,
jobStatus: null,
finalCountdownSeconds: null,
},
]);
const primaryImage = refImages[0]?.dataUrl;
let requestBody: Record<string, unknown> = {
prompt: prompt.trim() || undefined,
negativePrompt: negativePrompt.trim() || undefined,
@@ -268,11 +283,11 @@ export function ImageToVideoPanel() {
const data = await runGenerationJob<{ videos?: string[]; error?: string }>(
'video',
requestBody,
{ timeoutMs: 600_000, onStatus: setGenerationJobStatus },
{ timeoutMs: 600_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
);
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
if (data.videos && data.videos.length > 0) {
setResults(data.videos);
setResults(prev => [...data.videos!, ...prev]);
setGenerationError(null);
for (const url of data.videos) {
addRecord({
@@ -305,9 +320,9 @@ export function ImageToVideoPanel() {
} else {
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
}
}
finally { setGenerating(false); setFinalCountdownSeconds(null); }
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, cameraMovement, refImages, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, credits, membershipEnabled]);
}
finally { removeActiveTask(taskId); }
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, cameraMovement, refImages, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, removeActiveTask, updateActiveTask]);
const handleDownload = useCallback(async (url: string, index: number) => {
const result = await downloadFile(url, `miaojing-img2vid-${Date.now()}-${index}.mp4`);
@@ -347,15 +362,22 @@ export function ImageToVideoPanel() {
{refImages.length > 0 ? (
<div className="grid w-full grid-cols-3 gap-3">
{refImages.map(img => (
<div key={img.id} className="liquid-glass-soft relative aspect-square overflow-hidden rounded-2xl">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.dataUrl} alt={img.name} className="h-full w-full object-cover" />
<button
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white"
onClick={() => removeRefImage(img.id)}
>
<X className="h-3 w-3" />
</button>
<div
key={img.id}
className="liquid-glass-soft relative aspect-square cursor-zoom-in overflow-hidden rounded-2xl"
onClick={() => setReferencePreviewSrc(img.dataUrl)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.dataUrl} alt={img.name} className="h-full w-full object-cover" />
<button
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white"
onClick={(event) => {
event.stopPropagation();
removeRefImage(img.id);
}}
>
<X className="h-3 w-3" />
</button>
</div>
))}
<button
@@ -473,24 +495,15 @@ export function ImageToVideoPanel() {
</Select>
</div>
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={generating || !hasModels}>
{generating ? (<><Loader2 className="h-4 w-4 animate-spin" />...</>) : (<><Sparkles className="h-4 w-4" /> {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
</Button>
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
{generating ? (<><Plus className="h-4 w-4" /></>) : (<><Sparkles className="h-4 w-4" /> {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
</Button>
</div>
{/* Right: Results + History */}
{/* Right: Results + History */}
<div className="min-w-0 space-y-4">
{generating ? (
<div className="liquid-glass min-h-[300px] overflow-hidden rounded-2xl border-dashed text-muted-foreground">
<GenerationLoadingPanel
startedAt={generationStartedAt}
estimateSeconds={300}
jobStatus={generationJobStatus}
finalCountdownSeconds={finalCountdownSeconds}
title="正在生成视频"
/>
</div>
) : generationError ? (
<GenerationTaskList tasks={activeTasks} />
{generationError ? (
<GenerationErrorPanel error={generationError} />
) : results.length > 0 ? (
<div className="space-y-3">
@@ -548,11 +561,12 @@ export function ImageToVideoPanel() {
</div>
{/* History Detail Dialog */}
<CreationDetailDialog
record={selectedHistoryRecord}
open={!!selectedHistoryRecord}
onClose={() => setSelectedHistoryRecord(null)}
/>
</div>
);
}
<CreationDetailDialog
record={selectedHistoryRecord}
open={!!selectedHistoryRecord}
onClose={() => setSelectedHistoryRecord(null)}
/>
<BareImagePreview src={referencePreviewSrc || ''} open={!!referencePreviewSrc} onClose={() => setReferencePreviewSrc(null)} />
</div>
);
}

View File

@@ -41,10 +41,10 @@ import { ImageLightbox } from '@/components/lightbox';
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
import { GenerationErrorPanel, createGenerationError, type GenerationErrorState } from '@/components/create/generation-error-panel';
import { ExpandablePromptTextarea } from '@/components/create/expandable-prompt-textarea';
import { GenerationLoadingPanel } from '@/components/create/generation-loading-panel';
import { ImageCountCombobox } from '@/components/create/image-count-combobox';
import { StylePresetSelector } from '@/components/create/style-preset-selector';
import { useImageStylePresets } from '@/lib/style-presets-client';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
const TEXT_TO_IMAGE_DRAFT_KEY = 'miaojing:text-to-image-draft';
@@ -67,13 +67,11 @@ export function TextToImagePanel() {
const [guidanceScale, setGuidanceScale] = useState(7);
// Generation state
const [generating, setGenerating] = useState(false);
const [generationStartedAt, setGenerationStartedAt] = useState(() => Date.now());
const [generationJobStatus, setGenerationJobStatus] = useState<GenerationJobStatus | null>(null);
const [finalCountdownSeconds, setFinalCountdownSeconds] = useState<number | null>(null);
const [activeTasks, setActiveTasks] = useState<ActiveGenerationTask[]>([]);
const [results, setResults] = useState<string[]>([]);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const generating = activeTasks.length > 0;
// History state
const { records, add: addRecord } = useCreationHistory();
@@ -218,20 +216,35 @@ export function TextToImagePanel() {
return { aspectRatio: resolvedAspectRatio, resolution: resolvedResolution, count: resolvedCount };
}, [aspectRatio, resolution, count, inferredImageParams]);
const updateActiveTask = useCallback((taskId: string, update: Partial<ActiveGenerationTask>) => {
setActiveTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...update } : task));
}, []);
const removeActiveTask = useCallback((taskId: string) => {
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
}, []);
// Generate
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) { toast.error('请输入创作描述'); return; }
if (!user) { toast.error('请先登录'); return; }
setGenerationError(null);
setResults([]);
setGenerationStartedAt(Date.now());
setGenerationJobStatus(null);
setFinalCountdownSeconds(null);
setGenerating(true);
const taskId = `text2img-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
try {
const resolvedParams = resolveGenerationParams();
if (!resolvedParams) return;
setActiveTasks(prev => [
...prev,
{
id: taskId,
title: '正在生成图片',
startedAt: Date.now(),
estimateSeconds: 90,
jobStatus: null,
finalCountdownSeconds: null,
},
]);
// Keep custom/system API size aligned with the selected resolution.
const useCustomApiSize = isCustomModel(selectedModel) || isSystemModel(selectedModel);
const resolvedSize = useCustomApiSize
@@ -269,11 +282,11 @@ export function TextToImagePanel() {
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
'image',
requestBody,
{ timeoutMs: 900_000, onStatus: setGenerationJobStatus },
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
);
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
if (data.images && data.images.length > 0) {
setResults(data.images);
setResults(prev => [...data.images!, ...prev]);
setGenerationError(null);
for (const url of data.images) {
addRecord({
@@ -311,6 +324,7 @@ export function TextToImagePanel() {
} catch (err: unknown) {
if (err instanceof GenerationJobStillRunningError) {
setGenerationError(null);
removeActiveTask(taskId);
toast.info('生成任务仍在执行,可稍后在创作历史中查看');
} else if (err instanceof DOMException && err.name === 'AbortError') {
setGenerationError(createGenerationError('请求超时,请尝试减少生成数量或降低分辨率'));
@@ -318,8 +332,8 @@ export function TextToImagePanel() {
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
}
}
finally { setGenerating(false); setFinalCountdownSeconds(null); }
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, guidanceScale, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, resolveGenerationParams]);
finally { removeActiveTask(taskId); }
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, guidanceScale, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, resolveGenerationParams, removeActiveTask, updateActiveTask]);
// Download
const handleDownload = useCallback(async (url: string, index: number) => {
@@ -477,25 +491,16 @@ export function TextToImagePanel() {
</div>
{/* Generate Button */}
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={generating || !hasModels}>
{generating ? (<><Loader2 className="h-4 w-4 animate-spin" />...</>) : (<><Sparkles className="h-4 w-4" /> {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
{generating ? (<><Plus className="h-4 w-4" /></>) : (<><Sparkles className="h-4 w-4" /> {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
</Button>
</div>
{/* Right: Results + History (flex-1, takes remaining space) */}
<div className="min-w-0 space-y-4">
<GenerationTaskList tasks={activeTasks} />
{/* Results area */}
{generating ? (
<div className="liquid-glass min-h-[300px] overflow-hidden rounded-2xl border-dashed text-muted-foreground">
<GenerationLoadingPanel
startedAt={generationStartedAt}
estimateSeconds={90}
jobStatus={generationJobStatus}
finalCountdownSeconds={finalCountdownSeconds}
title="正在生成图片"
/>
</div>
) : generationError ? (
{generationError ? (
<GenerationErrorPanel error={generationError} />
) : results.length > 0 ? (
<div className="space-y-3">

View File

@@ -21,7 +21,7 @@ import {
buildSystemModelId,
calcVideoCredits,
} from '@/lib/model-config';
import { Sparkles, Loader2, Download, Wand2, Video, Film, History, ChevronDown, ChevronUp, KeyRound, Share2 } from 'lucide-react';
import { Sparkles, Loader2, Download, Wand2, Video, Film, History, ChevronDown, ChevronUp, KeyRound, Share2, Plus } from 'lucide-react';
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
import { addCreditRecord } from '@/lib/credit-records-store';
import { downloadFile } from '@/lib/utils';
@@ -32,7 +32,7 @@ import Link from 'next/link';
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
import { GenerationErrorPanel, createGenerationError, type GenerationErrorState } from '@/components/create/generation-error-panel';
import { ExpandablePromptTextarea } from '@/components/create/expandable-prompt-textarea';
import { GenerationLoadingPanel } from '@/components/create/generation-loading-panel';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
export function TextToVideoPanel() {
const { user, accessToken } = useAuth();
@@ -48,13 +48,11 @@ export function TextToVideoPanel() {
const [cameraMovement, setCameraMovement] = useState(CAMERA_MOVEMENTS[0]);
const [style, setStyle] = useState(VIDEO_STYLES[0]);
const [generating, setGenerating] = useState(false);
const [generationStartedAt, setGenerationStartedAt] = useState(() => Date.now());
const [generationJobStatus, setGenerationJobStatus] = useState<GenerationJobStatus | null>(null);
const [finalCountdownSeconds, setFinalCountdownSeconds] = useState<number | null>(null);
const [activeTasks, setActiveTasks] = useState<ActiveGenerationTask[]>([]);
const [results, setResults] = useState<string[]>([]);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const generating = activeTasks.length > 0;
const { records, add: addRecord } = useCreationHistory();
const [showHistory, setShowHistory] = useState(false);
@@ -144,17 +142,32 @@ export function TextToVideoPanel() {
const credits = calcVideoCredits(duration, selectedModel);
const updateActiveTask = useCallback((taskId: string, update: Partial<ActiveGenerationTask>) => {
setActiveTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...update } : task));
}, []);
const removeActiveTask = useCallback((taskId: string) => {
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
}, []);
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) { toast.error('请输入视频描述'); return; }
if (!user) { toast.error('请先登录'); return; }
setGenerationError(null);
setResults([]);
setGenerationStartedAt(Date.now());
setGenerationJobStatus(null);
setFinalCountdownSeconds(null);
setGenerating(true);
const taskId = `text2video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
try {
setActiveTasks(prev => [
...prev,
{
id: taskId,
title: '正在生成视频',
startedAt: Date.now(),
estimateSeconds: 300,
jobStatus: null,
finalCountdownSeconds: null,
},
]);
let requestBody: Record<string, unknown> = {
prompt: prompt.trim(),
negativePrompt: negativePrompt.trim() || undefined,
@@ -178,11 +191,11 @@ export function TextToVideoPanel() {
const data = await runGenerationJob<{ videos?: string[]; error?: string }>(
'video',
requestBody,
{ timeoutMs: 600_000, onStatus: setGenerationJobStatus },
{ timeoutMs: 600_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
);
await runGenerationFinalCountdown(setFinalCountdownSeconds, 3);
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
if (data.videos && data.videos.length > 0) {
setResults(data.videos);
setResults(prev => [...data.videos!, ...prev]);
setGenerationError(null);
for (const url of data.videos) {
addRecord({
@@ -214,8 +227,8 @@ export function TextToVideoPanel() {
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
}
}
finally { setGenerating(false); setFinalCountdownSeconds(null); }
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, cameraMovement, style, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, credits, membershipEnabled]);
finally { removeActiveTask(taskId); }
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, cameraMovement, style, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, removeActiveTask, updateActiveTask]);
const handleDownload = useCallback(async (url: string, index: number) => {
const result = await downloadFile(url, `miaojing-video-${Date.now()}-${index}.mp4`);
@@ -337,24 +350,15 @@ export function TextToVideoPanel() {
</Select>
</div>
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={generating || !hasModels}>
{generating ? (<><Loader2 className="h-4 w-4 animate-spin" />...</>) : (<><Sparkles className="h-4 w-4" /> {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
{generating ? (<><Plus className="h-4 w-4" /></>) : (<><Sparkles className="h-4 w-4" /> {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
</Button>
</div>
{/* Right: Results + History */}
<div className="min-w-0 space-y-4">
{generating ? (
<div className="liquid-glass min-h-[300px] overflow-hidden rounded-2xl border-dashed text-muted-foreground">
<GenerationLoadingPanel
startedAt={generationStartedAt}
estimateSeconds={300}
jobStatus={generationJobStatus}
finalCountdownSeconds={finalCountdownSeconds}
title="正在生成视频"
/>
</div>
) : generationError ? (
<GenerationTaskList tasks={activeTasks} />
{generationError ? (
<GenerationErrorPanel error={generationError} />
) : results.length > 0 ? (
<div className="space-y-3">

View File

@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
import { Download, Copy, FileSearch, ImageOff, Film, ImageIcon, Share2, CheckCircle2, Maximize2 } from 'lucide-react';
import { toast } from 'sonner';
import { FullscreenPreview } from '@/components/fullscreen-preview';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
interface CreationDetailDialogProps {
record: CreationRecord | null;
@@ -472,6 +473,9 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
/>
)}
{/* Fullscreen button */}
{!isPlaceholderUrl && record.type === 'image' && (
<ImageMetadataBadge src={record.url} className="absolute right-3 top-3 z-20" />
)}
{!isPlaceholderUrl && record.type === 'image' && (
<button
onClick={() => setFullscreenSrc(record.url)}

View File

@@ -3,6 +3,7 @@
import { useState, useCallback, useEffect, useRef, type MouseEvent } from 'react';
import { createPortal } from 'react-dom';
import { X, ZoomIn, ZoomOut, ChevronLeft, ChevronRight } from 'lucide-react';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
interface FullscreenPreviewProps {
src: string;
@@ -209,6 +210,8 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
</button>
</div>
<ImageMetadataBadge src={currentSrc} className="absolute right-4 top-16 z-10" />
{images && images.length > 1 && (
<div className={`absolute top-4 left-4 z-10 rounded-full px-3 py-1.5 text-sm font-medium ${inverseControlClass}`}>
{currentIndex + 1} / {images.length}

View File

@@ -0,0 +1,83 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
type ImageMetadataBadgeProps = {
src: string;
className?: string;
};
type ImageSize = {
width: number;
height: number;
};
function gcd(a: number, b: number): number {
let x = Math.abs(a);
let y = Math.abs(b);
while (y) {
const next = x % y;
x = y;
y = next;
}
return x || 1;
}
function getAspectLabel(width: number, height: number) {
if (!width || !height) return '';
const divisor = gcd(width, height);
const ratioWidth = Math.round(width / divisor);
const ratioHeight = Math.round(height / divisor);
if (ratioWidth > 60 || ratioHeight > 60) {
const decimal = width / height;
if (decimal >= 0.98 && decimal <= 1.02) return '1:1';
if (decimal > 1) return `${decimal.toFixed(2)}:1`;
return `1:${(height / width).toFixed(2)}`;
}
return `${ratioWidth}:${ratioHeight}`;
}
export function ImageMetadataBadge({ src, className = '' }: ImageMetadataBadgeProps) {
const [size, setSize] = useState<ImageSize | null>(null);
useEffect(() => {
if (!src) {
setSize(null);
return;
}
let cancelled = false;
const image = new Image();
image.onload = () => {
if (cancelled) return;
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
setSize({ width: image.naturalWidth, height: image.naturalHeight });
}
};
image.onerror = () => {
if (!cancelled) setSize(null);
};
image.src = src;
return () => {
cancelled = true;
};
}, [src]);
const label = useMemo(() => {
if (!size) return '';
return `${getAspectLabel(size.width, size.height)} · ${size.width}×${size.height}`;
}, [size]);
if (!label) return null;
return (
<div
className={`rounded-full border border-white/24 bg-black/48 px-3 py-1.5 text-xs font-semibold text-white shadow-[0_10px_32px_rgba(0,0,0,0.35)] backdrop-blur-md light:border-amber-900/18 light:bg-white/60 light:text-foreground light:shadow-[0_10px_32px_rgba(83,61,27,0.14)] ${className}`}
>
{label}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { X, Download, ZoomIn, ZoomOut } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { downloadFile } from '@/lib/utils';
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
interface LightboxProps {
/** Image URL to display */
@@ -98,6 +99,8 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
</Button>
</div>
<ImageMetadataBadge src={src} className="absolute right-4 top-16 z-10" />
{/* Info bar */}
<div className="absolute bottom-4 left-4 z-10 rounded-full border border-white/20 bg-black/45 px-3 py-1.5 text-xs font-medium text-white/78 shadow-lg backdrop-blur-md light:border-amber-900/18 light:bg-white/52 light:text-foreground/70 light:shadow-[0_10px_32px_rgba(83,61,27,0.12)]" onClick={e => e.stopPropagation()}>
{zoom !== 1 && <span>{Math.round(zoom * 100)}% {' | '} </span>}
@@ -169,3 +172,36 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
</div>
);
}
export function BareImagePreview({ src, alt, open, onClose }: LightboxProps) {
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/88 p-6 backdrop-blur-xl light:bg-white/74"
onClick={onClose}
>
<ImageMetadataBadge src={src} className="absolute right-4 top-4 z-10" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt || '参考图预览'}
className="max-h-[94vh] max-w-[94vw] object-contain"
onClick={(event) => event.stopPropagation()}
/>
</div>
);
}