Refine previews and concurrent generation UI
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
49
src/components/create/generation-task-list.tsx
Normal file
49
src/components/create/generation-task-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
83
src/components/image-metadata-badge.tsx
Normal file
83
src/components/image-metadata-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user