feat(gallery): add inspiration reuse and image actions
This commit is contained in:
@@ -30,7 +30,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Policy pages start mid-page after navigation | `src/components/site-policy-page.tsx`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` | Scroll reset behavior and shared policy page wrapper. |
|
||||
| Site name/logo/favicon not updating | `src/components/site-config-sync.tsx`, `src/components/site-brand.tsx`, `src/app/api/site-config/route.ts`, `src/lib/local-storage.ts` | `site_config` row, base64 image save, generated `/api/local-storage/*` URL. |
|
||||
| Page content leaves large unused horizontal margins, or wide screens look like the UI was simply enlarged | `src/components/app-shell.tsx`, `src/components/navbar.tsx`, `src/components/site-footer.tsx`, page-level wrappers under `src/app/*/page.tsx`, `src/components/site-policy-page.tsx` | The viewport/background can be `w-full`, but product content should keep the original component scale and readable containers such as `max-w-7xl`, `max-w-4xl`, or `max-w-3xl`. Do not fix this by removing all max widths or scaling controls up on wide monitors. |
|
||||
| Scrollbars look native or do not match glass UI in dialogs/pages | `src/app/globals.css` | Global `*::-webkit-scrollbar*` and Firefox `scrollbar-color` rules should provide rounded glass scrollbars for both light and dark themes. Avoid adding one-off scrollbar styles to individual components unless there is a real exception. |
|
||||
| Scrollbars look native, stay visible when idle, or do not match glass UI in dialogs/pages | `src/app/globals.css`, `src/components/app-shell.tsx` | Global scrollbar styling is hidden by default and becomes visible only while wheel/touch scrolling through the `scrollbars-visible` class on `<html>`. `globals.css` owns both the hidden state and the rounded glass visible state for light/dark themes; `app-shell` owns the short-lived wheel/touch listener. Avoid adding one-off scrollbar styles to individual components unless there is a real exception. |
|
||||
| Disabled canvas/`画布` appears again in public UI | `src/components/navbar.tsx`, `src/app/canvas/page.tsx`, `docs/codex-miaojing/feature-code-index.md` | Navbar should not include `/canvas`, and `/canvas` should continue to call `notFound()` unless the product explicitly re-enables the legacy canvas feature. |
|
||||
| Announcement not popping up | `src/components/announcement-popup.tsx`, `src/app/api/announcements/route.ts`, `src/components/app-shell.tsx` | App shell includes popup, active date range, local/session dismissal behavior, GET payload shape. |
|
||||
| Announcement admin edit fails | `src/components/admin/announcement-tab.tsx`, `src/app/api/announcements/route.ts` | Admin token, required fields, `starts_at`/`expires_at` compatibility. |
|
||||
@@ -49,8 +49,8 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| 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. 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/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`. |
|
||||
| Creation detail reuse/edit buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Detail actions should write the shared draft key/event, route to `/create?type=text2img` or `/create?type=img2img`, and the create page should react to query-param changes after navigation. Image-to-image edit should use the current output image as the first reference image. |
|
||||
| Fullscreen/preview/download/right-click image actions broken | `src/components/fullscreen-preview.tsx`, `src/components/lightbox.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx`, `src/app/api/download/route.ts` | Dialog state, URL type, download proxy supports local/remote URL. Image result and history/detail previews should open on single click. Right-click copy, download, edit, and share actions must use the uncompressed original image URL, not a thumbnail, preview cache, or compressed reference blob. Share links should open `/image-viewer?url=...` as a standalone original-image fullscreen page. Image result and history/detail previews should show upper-right actual aspect ratio and natural resolution via `ImageMetadataBadge`. |
|
||||
| Creation detail, gallery one-click reuse, or inspiration reuse buttons do not fill create forms or switch tabs | `src/components/creation-detail-dialog.tsx`, `src/app/gallery/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx`, `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `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` | Reuse actions should write the shared draft key/event for `text2img`, `img2img`, `text2video`, or `img2video`, route to the matching `/create?type=...` when leaving gallery, and already-mounted create panels should react to the event. Image-to-image and image-to-video reuse should include reference images from the work, falling back to the output image or thumbnail only when stored references are missing. |
|
||||
| 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. |
|
||||
|
||||
@@ -8,7 +8,7 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Feature | Primary Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Root layout and providers | `src/app/layout.tsx`, `src/components/app-shell.tsx`, `src/app/globals.css` | App shell wires navbar, site config sync, visit tracking, theme/account sync, toaster, and full-width page mounting. Keep product content at the original component scale; use centered responsive containers instead of stretching all content to viewport edges. Global scrollbars use a rounded glass style for light/dark themes in `globals.css`. |
|
||||
| Root layout and providers | `src/app/layout.tsx`, `src/components/app-shell.tsx`, `src/app/globals.css` | App shell wires navbar, site config sync, visit tracking, theme/account sync, toaster, full-width page mounting, and transient scrollbar visibility. Keep product content at the original component scale; use centered responsive containers instead of stretching all content to viewport edges. Global scrollbars are hidden by default and briefly show the rounded glass style when wheel/touch scrolling adds `scrollbars-visible` on `<html>`. |
|
||||
| Home page | `src/app/page.tsx` | Landing/dashboard-like public entry. Check site config dependencies when changing brand text. |
|
||||
| Navbar | `src/components/navbar.tsx`, `src/components/site-brand.tsx` | Navigation, brand display, auth-aware links. User-facing nav intentionally excludes the disabled legacy canvas route. |
|
||||
| Footer | `src/components/site-footer.tsx` | Uses site config for policy/help/about links and filing text; footer background spans browser width while inner content keeps the original `max-w-7xl` scale. |
|
||||
@@ -23,6 +23,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| `/` | `src/app/page.tsx` | `src/components/app-shell.tsx` |
|
||||
| `/create` | `src/app/create/page.tsx` | `src/components/create/*` |
|
||||
| `/gallery` | `src/app/gallery/page.tsx` | `src/lib/creation-history-store.ts`, `src/app/api/gallery/route.ts` |
|
||||
| `/image-viewer` | `src/app/image-viewer/page.tsx` | Fullscreen original-image share page opened from image right-click share links. |
|
||||
| `/profile` | `src/app/profile/page.tsx` | `src/components/profile/*`, `src/app/api/profile/route.ts` |
|
||||
| `/canvas` | `src/app/canvas/page.tsx` | Disabled legacy route; currently calls `notFound()` and must not be linked from navbar. |
|
||||
| `/about` | `src/app/about/page.tsx` | `src/components/site-policy-page.tsx`, `src/lib/site-policy-defaults.ts` |
|
||||
@@ -49,17 +50,17 @@ Use this document to jump directly to code before broad searching.
|
||||
| Feature | Primary Files | Server/API Files |
|
||||
| --- | --- | --- |
|
||||
| Tab container | `src/app/create/page.tsx` | Owns the five creation tabs. Active tab is persisted in localStorage and mirrored to `/create?type=...`, so refreshes and shared links stay on text-to-image, image-to-image, text-to-video, image-to-video, or reverse-prompt. |
|
||||
| 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. It also consumes image reuse drafts from `src/lib/creation-reuse.ts` so creation details can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the text-to-image form. |
|
||||
| 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`. It also consumes image reuse drafts from `src/lib/creation-reuse.ts` so creation details can place the selected output image as the reference image and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the image-to-image form. |
|
||||
| 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`. |
|
||||
| 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. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-image works can fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and guidance into the form. |
|
||||
| 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`. It consumes reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-image works can place reference images and fill prompt, negative prompt, model, ratio, resolution, format, quality, count, style, and strength into the form. |
|
||||
| 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`. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery text-to-video works can fill prompt, negative prompt, model, ratio, duration, camera movement, and style. |
|
||||
| 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`. It consumes video reuse drafts from `src/lib/creation-reuse.ts` and opens `src/components/create/inspiration-gallery-dialog.tsx` from the `获取灵感` action so gallery image-to-video works can place reference images and fill prompt, negative prompt, model, ratio, duration, and camera movement. |
|
||||
| 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-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. |
|
||||
| Creation reuse drafts | `src/lib/creation-reuse.ts`, `src/app/create/page.tsx` | Shared localStorage/event bridge used by detail and reverse-prompt actions to prefill create panels. `/create?type=...` changes the active tab after navigation, so callers can route directly to text-to-image or image-to-image. |
|
||||
| Lightbox/fullscreen/detail actions | `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, reuse config, edit output, and delete work. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. 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. |
|
||||
| Creation reuse drafts | `src/lib/creation-reuse.ts`, `src/app/create/page.tsx`, `src/components/create/inspiration-gallery-dialog.tsx` | Shared localStorage/event bridge used by detail, reverse-prompt, gallery, and inspiration actions to prefill create panels. It supports `text2img`, `img2img`, `text2video`, and `img2video` draft keys/events; `/create?type=...` changes the active tab after navigation, so callers can route directly to the matching creation mode. The inspiration dialog filters to the current mode, keeps per-card mode labels hidden, and offers a fuzzy search box that animates leftward from the header search icon; empty searches auto-collapse after the pointer leaves the search control for 1 second, while non-empty searches stay open until the dialog closes. |
|
||||
| Lightbox/fullscreen/detail actions | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-actions-context-menu.tsx`, `src/components/image-metadata-badge.tsx`, `src/app/image-viewer/page.tsx` | Image cards, detail images, reference thumbnails, and generation results should enter fullscreen preview on single click, not double-click. Detail and fullscreen images use the shared right-click image action menu for copy, download, edit-to-image-to-image, and share; these actions must receive the original image URL, not thumbnails or cached display blobs. Share copies a `/image-viewer?url=...` full-display link for the original image. Delete work must use a confirmation dialog warning that deletion cannot be recovered before calling the server delete path. 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
|
||||
|
||||
@@ -102,7 +103,7 @@ Use this document to jump directly to code before broad searching.
|
||||
|
||||
| Feature | Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution. |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css` | Lists public works, search/sort/filter, preview/download, and one-click reuse. The search box is custom styled in-page to match the glass UI; gallery cards sample 3-5 distinct colors from the image and use a real `gallery-card-border-frame` wrapper with a single 3px blurred, continuous clockwise multicolor border around the full work-card container, including all four corners and the prompt/footer area. Avoid image-covering dark overlays, broad square glow blocks, or a separate outer halo layer. Hover like/download/reuse buttons invert against sampled image brightness. Gallery detail image previews use `ImageMetadataBadge` for actual ratio/resolution, and the detail footer writes a reuse draft before navigating to the matching `/create?type=...` mode. |
|
||||
| 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. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
|
||||
|
||||
@@ -29,6 +29,8 @@ import { usePublishedWorks, useCreationHistory, syncPublishedToSupabase } from '
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import { FullscreenPreview } from '@/components/fullscreen-preview';
|
||||
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
|
||||
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
|
||||
import { buildCreationReuseDraft, type CreationReuseTarget, writeCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -148,6 +150,10 @@ function getCreateUrlForCategory(category: string): string {
|
||||
return `/create?type=${type}`;
|
||||
}
|
||||
|
||||
function isCreationReuseTarget(value: string): value is CreationReuseTarget {
|
||||
return value === 'text2img' || value === 'img2img' || value === 'text2video' || value === 'img2video';
|
||||
}
|
||||
|
||||
type MediaSize = { width: number; height: number };
|
||||
type GalleryCardPalette = {
|
||||
accent1: string;
|
||||
@@ -407,6 +413,7 @@ export default function GalleryPage() {
|
||||
const [measuredMediaSizes, setMeasuredMediaSizes] = useState<Record<string, MediaSize>>({});
|
||||
const [cardPalettes, setCardPalettes] = useState<Record<string, GalleryCardPalette>>({});
|
||||
const [selectedGalleryIds, setSelectedGalleryIds] = useState<Set<string>>(new Set());
|
||||
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
|
||||
|
||||
useEffect(() => {
|
||||
const updateColumnCount = () => {
|
||||
@@ -610,6 +617,22 @@ export default function GalleryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReuseGalleryWork = (work: GalleryWork, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
const target = getCategoryFromWork(work);
|
||||
if (!isCreationReuseTarget(target)) {
|
||||
toast.error('该作品暂不支持一键复用');
|
||||
return;
|
||||
}
|
||||
const draft = buildCreationReuseDraft(work, target, {
|
||||
source: 'gallery',
|
||||
useOutputAsReference: true,
|
||||
});
|
||||
writeCreationReuseDraft(target, draft);
|
||||
toast.success('已带入创作参数');
|
||||
window.location.href = getCreateUrlForCategory(target);
|
||||
};
|
||||
|
||||
const toggleSelectGalleryWork = (id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setSelectedGalleryIds(prev => {
|
||||
@@ -782,7 +805,8 @@ export default function GalleryPage() {
|
||||
className="block h-auto w-full object-contain"
|
||||
loading="lazy"
|
||||
onLoad={(e) => handleCardImageLoad(work.id, e)}
|
||||
onDoubleClick={(e) => { e.stopPropagation(); setFullscreenSrc(work.url); }}
|
||||
onClick={(e) => { e.stopPropagation(); setFullscreenSrc(work.url); }}
|
||||
onContextMenu={(e) => openImageMenu(e, work.url)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-square w-full flex-col items-center justify-center bg-gradient-to-br from-muted to-muted/50">
|
||||
@@ -820,6 +844,15 @@ export default function GalleryPage() {
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${likedIds.has(work.id) ? 'fill-current' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="gallery-work-action-button pointer-events-auto h-9 w-9 p-0"
|
||||
onClick={(e) => handleReuseGalleryWork(work, e)}
|
||||
title="一键复用"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -913,7 +946,8 @@ export default function GalleryPage() {
|
||||
src={selectedWork.url}
|
||||
alt={(selectedWork.prompt || '').slice(0, 30)}
|
||||
className="relative z-10 h-full w-full cursor-zoom-in object-contain"
|
||||
onDoubleClick={() => setFullscreenSrc(selectedWork.url)}
|
||||
onClick={() => setFullscreenSrc(selectedWork.url)}
|
||||
onContextMenu={(event) => openImageMenu(event, selectedWork.url)}
|
||||
/>
|
||||
)}
|
||||
{selectedWork.type !== 'video' && selectedWork.type !== 'text2video' && selectedWork.type !== 'img2video' && (
|
||||
@@ -1048,7 +1082,8 @@ export default function GalleryPage() {
|
||||
src={url}
|
||||
alt={`参考图 ${index + 1}`}
|
||||
className="aspect-square w-full cursor-zoom-in object-cover"
|
||||
onDoubleClick={() => setFullscreenSrc(url)}
|
||||
onClick={() => setFullscreenSrc(url)}
|
||||
onContextMenu={(event) => openImageMenu(event, url)}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-black/35 p-1 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
@@ -1117,11 +1152,11 @@ export default function GalleryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-white/[0.07] light:border-amber-900/14 pt-4">
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/[0.07] light:border-amber-900/14 pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={likedIds.has(selectedWork.id) ? 'default' : 'outline'}
|
||||
className="h-9 min-w-[92px] gap-1.5 px-3 text-sm font-semibold"
|
||||
className="mr-auto h-9 min-w-[92px] gap-1.5 px-3 text-sm font-semibold"
|
||||
onClick={() => toggleLike(selectedWork.id)}
|
||||
>
|
||||
<Heart className={`h-3.5 w-3.5 ${likedIds.has(selectedWork.id) ? 'fill-current' : ''}`} />
|
||||
@@ -1146,6 +1181,14 @@ export default function GalleryPage() {
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 min-w-[112px] gap-1.5 px-3 text-sm font-semibold"
|
||||
onClick={(e) => handleReuseGalleryWork(selectedWork, e)}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
一键复用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1161,6 +1204,7 @@ export default function GalleryPage() {
|
||||
open={!!fullscreenSrc}
|
||||
onClose={() => setFullscreenSrc(null)}
|
||||
/>
|
||||
{ImageActionsContextMenu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -575,16 +575,26 @@
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
html.scrollbars-visible * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(151 114 58 / 0.58) rgb(255 255 255 / 0.22);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
html.scrollbars-visible *::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
html.scrollbars-visible *::-webkit-scrollbar-track {
|
||||
border: 1px solid rgb(116 88 43 / 0.16);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
@@ -598,7 +608,7 @@
|
||||
-webkit-backdrop-filter: blur(14px) saturate(128%);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
html.scrollbars-visible *::-webkit-scrollbar-thumb {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
border: 4px solid transparent;
|
||||
@@ -611,27 +621,27 @@
|
||||
0 3px 9px rgb(92 69 32 / 0.18);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
html.scrollbars-visible *::-webkit-scrollbar-thumb:hover {
|
||||
background:
|
||||
linear-gradient(180deg, rgb(191 145 72 / 0.86), rgb(135 101 51 / 0.76))
|
||||
padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-button {
|
||||
html.scrollbars-visible *::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
html.scrollbars-visible *::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
.dark.scrollbars-visible * {
|
||||
scrollbar-color: rgb(237 190 104 / 0.58) rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-track {
|
||||
.dark.scrollbars-visible *::-webkit-scrollbar-track {
|
||||
border-color: rgb(255 255 255 / 0.10);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.045)),
|
||||
@@ -642,7 +652,7 @@
|
||||
0 8px 22px rgb(0 0 0 / 0.20);
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
.dark.scrollbars-visible *::-webkit-scrollbar-thumb {
|
||||
background:
|
||||
linear-gradient(180deg, rgb(237 190 104 / 0.70), rgb(145 103 46 / 0.68))
|
||||
padding-box;
|
||||
@@ -651,7 +661,7 @@
|
||||
0 3px 10px rgb(0 0 0 / 0.30);
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb:hover {
|
||||
.dark.scrollbars-visible *::-webkit-scrollbar-thumb:hover {
|
||||
background:
|
||||
linear-gradient(180deg, rgb(250 205 119 / 0.84), rgb(165 118 52 / 0.78))
|
||||
padding-box;
|
||||
|
||||
56
src/app/image-viewer/page.tsx
Normal file
56
src/app/image-viewer/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
function normalizeImageUrl(value: string | null): string {
|
||||
if (!value) return '';
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.startsWith('data:') || trimmed.startsWith('[')) return '';
|
||||
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function ImageViewerContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const imageUrl = useMemo(() => normalizeImageUrl(searchParams.get('url')), [searchParams]);
|
||||
|
||||
if (!imageUrl) {
|
||||
return (
|
||||
<main className="fixed inset-0 z-[2147483000] flex items-center justify-center bg-black text-sm text-white/70">
|
||||
图片链接无效
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="fixed inset-0 z-[2147483000] flex items-center justify-center overflow-auto bg-black p-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="原图预览"
|
||||
className="max-h-[calc(100vh-32px)] max-w-[calc(100vw-32px)] select-auto object-contain"
|
||||
draggable
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImageViewerPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="fixed inset-0 z-[2147483000] flex items-center justify-center bg-black text-sm text-white/70">
|
||||
正在打开原图
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<ImageViewerContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { Navbar } from '@/components/navbar';
|
||||
@@ -11,6 +11,32 @@ import { AccountThemeSync } from '@/components/account-theme-sync';
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isConsole = pathname === '/console' || pathname.startsWith('/console/');
|
||||
const scrollbarTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const showScrollbars = () => {
|
||||
root.classList.add('scrollbars-visible');
|
||||
if (scrollbarTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollbarTimerRef.current);
|
||||
}
|
||||
scrollbarTimerRef.current = window.setTimeout(() => {
|
||||
root.classList.remove('scrollbars-visible');
|
||||
scrollbarTimerRef.current = null;
|
||||
}, 900);
|
||||
};
|
||||
|
||||
window.addEventListener('wheel', showScrollbars, { passive: true, capture: true });
|
||||
window.addEventListener('touchmove', showScrollbars, { passive: true, capture: true });
|
||||
return () => {
|
||||
window.removeEventListener('wheel', showScrollbars, { capture: true });
|
||||
window.removeEventListener('touchmove', showScrollbars, { capture: true });
|
||||
if (scrollbarTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollbarTimerRef.current);
|
||||
}
|
||||
root.classList.remove('scrollbars-visible');
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactEventHandler } from 'react';
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactEventHandler, type MouseEventHandler } from 'react';
|
||||
|
||||
type CachedPreviewImageProps = {
|
||||
src: string;
|
||||
@@ -10,6 +10,7 @@ type CachedPreviewImageProps = {
|
||||
badgeClassName?: string;
|
||||
onDoubleClick?: () => void;
|
||||
onClick?: () => void;
|
||||
onContextMenu?: MouseEventHandler<HTMLImageElement>;
|
||||
onLoad?: ReactEventHandler<HTMLImageElement>;
|
||||
};
|
||||
|
||||
@@ -160,6 +161,7 @@ export function CachedPreviewImage({
|
||||
badgeClassName = 'absolute right-2 top-2 z-10',
|
||||
onDoubleClick,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
onLoad,
|
||||
}: CachedPreviewImageProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
@@ -241,6 +243,7 @@ export function CachedPreviewImage({
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
onLoad={handleLoad}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -47,6 +47,7 @@ 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';
|
||||
import { CachedPreviewImage } from '@/components/create/cached-preview-image';
|
||||
import { InspirationGalleryDialog } from '@/components/create/inspiration-gallery-dialog';
|
||||
import { IMAGE_TO_IMAGE_DRAFT_EVENT, IMAGE_TO_IMAGE_DRAFT_KEY, type ImageCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
|
||||
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
|
||||
@@ -90,6 +91,7 @@ export function ImageToImagePanel() {
|
||||
const [results, setResults] = useState<string[]>([]);
|
||||
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
|
||||
const generating = activeTasks.length > 0;
|
||||
|
||||
@@ -556,7 +558,9 @@ export function ImageToImagePanel() {
|
||||
toast.success('已分享到画廊');
|
||||
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel]);
|
||||
|
||||
return (
|
||||
return (
|
||||
<>
|
||||
<InspirationGalleryDialog mode="img2img" open={inspirationOpen} onOpenChange={setInspirationOpen} />
|
||||
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
{/* Left: Settings */}
|
||||
<div className="min-w-0 space-y-5 pb-8 pr-2">
|
||||
@@ -640,15 +644,21 @@ export function ImageToImagePanel() {
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>创作描述</Label>
|
||||
{textModelOptions.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
|
||||
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{optimizing ? '优化中...' : '优化提示词'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>创作描述</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={() => setInspirationOpen(true)}>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
获取灵感
|
||||
</Button>
|
||||
{textModelOptions.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
|
||||
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{optimizing ? '优化中...' : '优化提示词'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ExpandablePromptTextarea
|
||||
title="创作描述"
|
||||
placeholder="描述你想要的图片变化..."
|
||||
@@ -727,7 +737,7 @@ export function ImageToImagePanel() {
|
||||
|
||||
{/* Strength */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>重绘幅度</Label>
|
||||
<span className="text-xs text-muted-foreground">{strength.toFixed(2)}</span>
|
||||
</div>
|
||||
@@ -763,7 +773,7 @@ export function ImageToImagePanel() {
|
||||
src={url}
|
||||
alt={`生成结果 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover cursor-zoom-in"
|
||||
onDoubleClick={() => setLightboxSrc(url)}
|
||||
onClick={() => setLightboxSrc(url)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
<Button size="sm" variant="secondary" className="gap-1" onClick={() => setLightboxSrc(url)}><ImageIcon className="h-3.5 w-3.5" />预览</Button>
|
||||
@@ -830,6 +840,7 @@ export function ImageToImagePanel() {
|
||||
setSelectedHistoryRecord(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ import { ExpandablePromptTextarea } from '@/components/create/expandable-prompt-
|
||||
import { compressImageFileForUpload } from '@/lib/browser-image-compression';
|
||||
import { BareImagePreview } from '@/components/lightbox';
|
||||
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
|
||||
import { InspirationGalleryDialog } from '@/components/create/inspiration-gallery-dialog';
|
||||
import { IMAGE_TO_VIDEO_DRAFT_EVENT, IMAGE_TO_VIDEO_DRAFT_KEY, type CreationReuseDraft } from '@/lib/creation-reuse';
|
||||
|
||||
interface RefImage {
|
||||
id: string;
|
||||
@@ -58,6 +60,7 @@ export function ImageToVideoPanel() {
|
||||
const [results, setResults] = useState<string[]>([]);
|
||||
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const [referencePreviewSrc, setReferencePreviewSrc] = useState<string | null>(null);
|
||||
const generating = activeTasks.length > 0;
|
||||
|
||||
@@ -80,13 +83,60 @@ export function ImageToVideoPanel() {
|
||||
|
||||
const hasModels = modelOptions.length > 0;
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
useEffect(() => {
|
||||
if (modelOptions.length > 0 && !modelOptions.find(o => o.id === selectedModel)) {
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
useEffect(() => {
|
||||
if (modelOptions.length > 0 && !modelOptions.find(o => o.id === selectedModel)) {
|
||||
const customOpt = modelOptions.find(o => o.group === '自定义模型');
|
||||
setSelectedModel(customOpt ? customOpt.id : modelOptions[0].id);
|
||||
}
|
||||
}, [modelOptions, selectedModel]);
|
||||
}
|
||||
}, [modelOptions, selectedModel]);
|
||||
|
||||
const applyImageToVideoDraft = useCallback((draft: unknown) => {
|
||||
if (!draft || typeof draft !== 'object') return;
|
||||
const data = draft as CreationReuseDraft;
|
||||
if (typeof data.prompt === 'string') setPrompt(data.prompt);
|
||||
if (typeof data.negativePrompt === 'string') setNegativePrompt(data.negativePrompt);
|
||||
if (typeof data.model === 'string' && data.model.trim()) setSelectedModel(data.model.trim());
|
||||
if (typeof data.aspectRatio === 'string' && data.aspectRatio.trim()) setAspectRatio(data.aspectRatio.trim());
|
||||
if (typeof data.duration === 'string' && data.duration.trim()) setDuration(data.duration.trim());
|
||||
if (typeof data.cameraMovement === 'string' && data.cameraMovement.trim()) setCameraMovement(data.cameraMovement.trim());
|
||||
|
||||
const rawReferences = Array.isArray(data.referenceImages)
|
||||
? data.referenceImages
|
||||
: typeof data.referenceImage === 'string'
|
||||
? [data.referenceImage]
|
||||
: [];
|
||||
const references = rawReferences.filter((item): item is string => (
|
||||
typeof item === 'string' && (
|
||||
item.startsWith('data:image/') ||
|
||||
/^https?:\/\/\S+/i.test(item) ||
|
||||
item.startsWith('/api/local-storage/')
|
||||
)
|
||||
));
|
||||
|
||||
if (references.length > 0) {
|
||||
setRefImages(references.map((dataUrl, index) => ({
|
||||
id: `draft-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
dataUrl,
|
||||
name: index === 0 ? '复用参考图' : `复用参考图 ${index + 1}`,
|
||||
})));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(IMAGE_TO_VIDEO_DRAFT_KEY);
|
||||
if (raw) applyImageToVideoDraft(JSON.parse(raw));
|
||||
} catch {
|
||||
// Ignore malformed local draft data.
|
||||
}
|
||||
|
||||
const handleDraft = (event: Event) => {
|
||||
applyImageToVideoDraft((event as CustomEvent).detail);
|
||||
};
|
||||
window.addEventListener(IMAGE_TO_VIDEO_DRAFT_EVENT, handleDraft);
|
||||
return () => window.removeEventListener(IMAGE_TO_VIDEO_DRAFT_EVENT, handleDraft);
|
||||
}, [applyImageToVideoDraft]);
|
||||
|
||||
const textModelOptions = useMemo(() => [
|
||||
...textKeys.map(k => ({ id: buildCustomModelId(k.id), label: `${k.modelName || k.provider} (自定义)`, config: { customApiKeyId: k.id, modelName: k.modelName } })),
|
||||
@@ -344,7 +394,9 @@ export function ImageToVideoPanel() {
|
||||
toast.success('已分享到画廊');
|
||||
}, [prompt, selectedModel, getCurrentModelLabel]);
|
||||
|
||||
return (
|
||||
return (
|
||||
<>
|
||||
<InspirationGalleryDialog mode="img2video" open={inspirationOpen} onOpenChange={setInspirationOpen} />
|
||||
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
{/* Left: Settings */}
|
||||
<div className="min-w-0 space-y-5 pb-8 pr-2">
|
||||
@@ -427,15 +479,21 @@ export function ImageToVideoPanel() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>视频描述 <span className="text-muted-foreground text-xs">(可选)</span></Label>
|
||||
{textModelOptions.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
|
||||
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{optimizing ? '优化中...' : '优化提示词'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label>视频描述 <span className="text-muted-foreground text-xs">(可选)</span></Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={() => setInspirationOpen(true)}>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
获取灵感
|
||||
</Button>
|
||||
{textModelOptions.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
|
||||
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{optimizing ? '优化中...' : '优化提示词'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ExpandablePromptTextarea
|
||||
title="视频描述"
|
||||
placeholder="描述你想要的视频效果..."
|
||||
@@ -569,5 +627,6 @@ export function ImageToVideoPanel() {
|
||||
/>
|
||||
<BareImagePreview src={referencePreviewSrc || ''} open={!!referencePreviewSrc} onClose={() => setReferencePreviewSrc(null)} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
327
src/components/create/inspiration-gallery-dialog.tsx
Normal file
327
src/components/create/inspiration-gallery-dialog.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ArrowLeft, Image as ImageIcon, Loader2, Search, Sparkles, X } from 'lucide-react';
|
||||
import { buildCreationReuseDraft, type CreationReuseTarget, writeCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type InspirationWork = {
|
||||
id: string;
|
||||
type: string;
|
||||
prompt?: string | null;
|
||||
negativePrompt?: string | null;
|
||||
url: string;
|
||||
thumbnailUrl?: string | null;
|
||||
duration?: number | null;
|
||||
params: Record<string, unknown>;
|
||||
referenceImage?: string | null;
|
||||
referenceImages?: string[];
|
||||
publisherNickname?: string | null;
|
||||
publishedAt?: string | null;
|
||||
};
|
||||
|
||||
const MODE_LABELS: Record<CreationReuseTarget, string> = {
|
||||
text2img: '文生图',
|
||||
img2img: '图生图',
|
||||
text2video: '文生视频',
|
||||
img2video: '图生视频',
|
||||
};
|
||||
|
||||
function getWorkMode(work: InspirationWork): CreationReuseTarget {
|
||||
const mode = work.params?.creationMode || work.params?.workType || work.params?.mode;
|
||||
if (mode === 'text2img' || mode === 'img2img' || mode === 'text2video' || mode === 'img2video') {
|
||||
return mode;
|
||||
}
|
||||
if (work.type === 'text2video' || work.type === 'img2video' || work.type === 'img2img') {
|
||||
return work.type;
|
||||
}
|
||||
const hasReference =
|
||||
Boolean(work.referenceImage) ||
|
||||
(Array.isArray(work.referenceImages) && work.referenceImages.length > 0) ||
|
||||
Boolean(work.params?.referenceImage) ||
|
||||
(Array.isArray(work.params?.referenceImages) && work.params.referenceImages.length > 0);
|
||||
if (work.type === 'video' || work.duration) return hasReference ? 'img2video' : 'text2video';
|
||||
return hasReference ? 'img2img' : 'text2img';
|
||||
}
|
||||
|
||||
function isVideoWork(work: InspirationWork): boolean {
|
||||
const mode = getWorkMode(work);
|
||||
return mode === 'text2video' || mode === 'img2video';
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function InspirationGalleryDialog({
|
||||
mode,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
mode: CreationReuseTarget;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [works, setWorks] = useState<InspirationWork[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedWork, setSelectedWork] = useState<InspirationWork | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchCollapseTimerRef = useRef<number | null>(null);
|
||||
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
|
||||
|
||||
const clearSearchCollapseTimer = useCallback(() => {
|
||||
if (searchCollapseTimerRef.current !== null) {
|
||||
window.clearTimeout(searchCollapseTimerRef.current);
|
||||
searchCollapseTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleSearchCollapse = useCallback(() => {
|
||||
clearSearchCollapseTimer();
|
||||
if (searchQuery.trim()) return;
|
||||
searchCollapseTimerRef.current = window.setTimeout(() => {
|
||||
setSearchOpen(false);
|
||||
searchCollapseTimerRef.current = null;
|
||||
}, 1000);
|
||||
}, [clearSearchCollapseTimer, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetch('/api/gallery?sort=newest&limit=300')
|
||||
.then(res => res.ok ? res.json() : Promise.reject(new Error('画廊加载失败')))
|
||||
.then(data => {
|
||||
if (!cancelled) setWorks(Array.isArray(data.works) ? data.works : []);
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) toast.error(err instanceof Error ? err.message : '画廊加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedWork(null);
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
clearSearchCollapseTimer();
|
||||
}
|
||||
}, [clearSearchCollapseTimer, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchOpen) return;
|
||||
searchInputRef.current?.focus();
|
||||
}, [searchOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearSearchCollapseTimer();
|
||||
}, [clearSearchCollapseTimer]);
|
||||
|
||||
const modeWorks = useMemo(
|
||||
() => works.filter(work => getWorkMode(work) === mode),
|
||||
[works, mode],
|
||||
);
|
||||
|
||||
const filteredWorks = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) return modeWorks;
|
||||
return modeWorks.filter(work => {
|
||||
const haystack = [
|
||||
work.prompt,
|
||||
work.negativePrompt,
|
||||
work.publisherNickname,
|
||||
work.type,
|
||||
work.params?.model,
|
||||
work.params?.modelLabel,
|
||||
work.params?.styleLabel,
|
||||
].map(value => String(value || '').toLowerCase()).join('\n');
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [modeWorks, searchQuery]);
|
||||
|
||||
const handleReuse = useCallback((work: InspirationWork) => {
|
||||
const draft = buildCreationReuseDraft(work, mode, {
|
||||
source: 'inspiration-gallery',
|
||||
useOutputAsReference: true,
|
||||
});
|
||||
writeCreationReuseDraft(mode, draft);
|
||||
toast.success('已带入创作参数');
|
||||
onOpenChange(false);
|
||||
}, [mode, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="h-[90vh] w-[min(96vw,1120px)] !max-w-[1120px] overflow-hidden p-0 sm:!max-w-[1120px]" showCloseButton={false}>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
获取灵感
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
当前仅显示画廊中的{MODE_LABELS[mode]}作品
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div
|
||||
className="relative flex shrink-0 items-center gap-1.5"
|
||||
onMouseEnter={clearSearchCollapseTimer}
|
||||
onMouseLeave={scheduleSearchCollapse}
|
||||
>
|
||||
<div
|
||||
className={`absolute right-11 top-1/2 z-10 flex h-10 -translate-y-1/2 origin-right items-center gap-2 overflow-hidden rounded-2xl border border-amber-900/12 bg-white/72 px-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.82),inset_0_0_0_1px_rgba(255,255,255,0.28),0_10px_28px_rgba(83,61,27,0.12)] backdrop-blur-xl transition-[width,opacity,transform] duration-300 ease-out focus-within:border-primary/35 focus-within:bg-white/82 dark:border-white/10 dark:bg-white/[0.07] dark:focus-within:bg-white/[0.10] ${
|
||||
searchOpen ? 'w-[min(56vw,320px)] scale-x-100 opacity-100' : 'pointer-events-none w-0 scale-x-90 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value);
|
||||
if (event.target.value.trim()) clearSearchCollapseTimer();
|
||||
}}
|
||||
onFocus={clearSearchCollapseTimer}
|
||||
placeholder={`搜索当前${MODE_LABELS[mode]}作品`}
|
||||
className="gallery-search-input h-full min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-sm font-medium text-foreground shadow-none outline-none ring-0 placeholder:text-muted-foreground/62 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="清空搜索"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setSearchOpen(true);
|
||||
clearSearchCollapseTimer();
|
||||
}}
|
||||
title="搜索"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedWork ? (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_380px]">
|
||||
<div className="min-h-0 overflow-hidden bg-black/10 p-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5" onClick={() => setSelectedWork(null)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回作品
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-[calc(100%-44px)] min-h-0 items-center justify-center overflow-hidden rounded-xl border border-border/70 bg-black/25">
|
||||
{isVideoWork(selectedWork) ? (
|
||||
<video src={selectedWork.url} controls className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<img
|
||||
src={selectedWork.url}
|
||||
alt={selectedWork.prompt || '作品详情'}
|
||||
className="h-full w-full object-contain"
|
||||
onContextMenu={(event) => openImageMenu(event, selectedWork.url)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="flex min-h-0 flex-col gap-3 overflow-hidden border-t border-border/60 bg-background/52 p-4 lg:border-l lg:border-t-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">{selectedWork.publisherNickname || '画廊作品'}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatDate(selectedWork.publishedAt)}</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-xl border border-border/70 bg-background/50 p-3">
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6">{selectedWork.prompt || '无提示词'}</p>
|
||||
{selectedWork.negativePrompt && (
|
||||
<p className="mt-3 border-t border-border/60 pt-3 text-xs leading-5 text-muted-foreground">
|
||||
{selectedWork.negativePrompt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" onClick={() => handleReuse(selectedWork)}>
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
一键复用
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-5">
|
||||
{loading ? (
|
||||
<div className="flex h-full min-h-[420px] flex-col items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="mb-3 h-8 w-8 animate-spin" />
|
||||
<span className="text-sm">正在加载画廊作品</span>
|
||||
</div>
|
||||
) : filteredWorks.length === 0 ? (
|
||||
<div className="flex h-full min-h-[420px] flex-col items-center justify-center text-muted-foreground">
|
||||
<ImageIcon className="mb-3 h-10 w-10 opacity-30" />
|
||||
<span className="text-sm">{searchQuery.trim() ? '没有匹配的作品' : `暂无可复用的${MODE_LABELS[mode]}作品`}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{filteredWorks.map(work => {
|
||||
const previewUrl = work.thumbnailUrl || work.url;
|
||||
const video = isVideoWork(work);
|
||||
return (
|
||||
<button
|
||||
key={work.id}
|
||||
type="button"
|
||||
className="group overflow-hidden rounded-xl border border-border/70 bg-background/45 text-left transition hover:border-primary/50 hover:shadow-[0_14px_36px_rgba(0,0,0,0.16)]"
|
||||
onClick={() => setSelectedWork(work)}
|
||||
>
|
||||
<div className="relative aspect-[4/5] overflow-hidden bg-muted">
|
||||
{video && !work.thumbnailUrl ? (
|
||||
<video src={previewUrl} className="h-full w-full object-cover" preload="metadata" />
|
||||
) : (
|
||||
<img src={previewUrl} alt={work.prompt || '画廊作品'} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" loading="lazy" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">{work.prompt || '无提示词'}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ImageActionsContextMenu}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ 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';
|
||||
import { CachedPreviewImage } from '@/components/create/cached-preview-image';
|
||||
import { InspirationGalleryDialog } from '@/components/create/inspiration-gallery-dialog';
|
||||
import { TEXT_TO_IMAGE_DRAFT_EVENT, TEXT_TO_IMAGE_DRAFT_KEY, type ImageCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
|
||||
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
|
||||
@@ -79,6 +80,7 @@ export function TextToImagePanel() {
|
||||
const [results, setResults] = useState<string[]>([]);
|
||||
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const activeSubmissionSignaturesRef = useRef(new Set<string>());
|
||||
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
|
||||
const generating = activeTasks.length > 0;
|
||||
@@ -485,6 +487,8 @@ export function TextToImagePanel() {
|
||||
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InspirationGalleryDialog mode="text2img" open={inspirationOpen} onOpenChange={setInspirationOpen} />
|
||||
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
{/* Left: Settings (scrollable) */}
|
||||
<div className="min-w-0 space-y-5 pb-8 pr-2">
|
||||
@@ -513,14 +517,20 @@ export function TextToImagePanel() {
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label>创作描述</Label>
|
||||
{textModelOptions.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
|
||||
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{optimizing ? '优化中...' : '优化提示词'}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={() => setInspirationOpen(true)}>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
获取灵感
|
||||
</Button>
|
||||
)}
|
||||
{textModelOptions.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
|
||||
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{optimizing ? '优化中...' : '优化提示词'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ExpandablePromptTextarea
|
||||
title="创作描述"
|
||||
@@ -600,7 +610,7 @@ export function TextToImagePanel() {
|
||||
|
||||
{/* Guidance Scale */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label>引导系数</Label>
|
||||
<span className="text-xs text-muted-foreground">{guidanceScale}</span>
|
||||
</div>
|
||||
@@ -637,7 +647,7 @@ export function TextToImagePanel() {
|
||||
src={url}
|
||||
alt={`生成结果 ${i + 1}`}
|
||||
className="w-full aspect-square object-cover cursor-zoom-in"
|
||||
onDoubleClick={() => setLightboxSrc(url)}
|
||||
onClick={() => setLightboxSrc(url)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
<Button size="sm" variant="secondary" className="gap-1" onClick={() => setLightboxSrc(url)}><ImageIcon className="h-3.5 w-3.5" />预览</Button>
|
||||
@@ -705,5 +715,6 @@ export function TextToImagePanel() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ 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 { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
|
||||
import { InspirationGalleryDialog } from '@/components/create/inspiration-gallery-dialog';
|
||||
import { TEXT_TO_VIDEO_DRAFT_EVENT, TEXT_TO_VIDEO_DRAFT_KEY, type CreationReuseDraft } from '@/lib/creation-reuse';
|
||||
|
||||
export function TextToVideoPanel() {
|
||||
const { user, accessToken } = useAuth();
|
||||
@@ -52,6 +54,7 @@ export function TextToVideoPanel() {
|
||||
const [results, setResults] = useState<string[]>([]);
|
||||
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const generating = activeTasks.length > 0;
|
||||
|
||||
const { records, add: addRecord } = useCreationHistory();
|
||||
@@ -80,6 +83,33 @@ export function TextToVideoPanel() {
|
||||
}
|
||||
}, [modelOptions, selectedModel]);
|
||||
|
||||
const applyVideoDraft = useCallback((draft: unknown) => {
|
||||
if (!draft || typeof draft !== 'object') return;
|
||||
const data = draft as CreationReuseDraft;
|
||||
if (typeof data.prompt === 'string') setPrompt(data.prompt);
|
||||
if (typeof data.negativePrompt === 'string') setNegativePrompt(data.negativePrompt);
|
||||
if (typeof data.model === 'string' && data.model.trim()) setSelectedModel(data.model.trim());
|
||||
if (typeof data.aspectRatio === 'string' && data.aspectRatio.trim()) setAspectRatio(data.aspectRatio.trim());
|
||||
if (typeof data.duration === 'string' && data.duration.trim()) setDuration(data.duration.trim());
|
||||
if (typeof data.cameraMovement === 'string' && data.cameraMovement.trim()) setCameraMovement(data.cameraMovement.trim());
|
||||
if (typeof data.style === 'string' && data.style.trim()) setStyle(data.style.trim());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(TEXT_TO_VIDEO_DRAFT_KEY);
|
||||
if (raw) applyVideoDraft(JSON.parse(raw));
|
||||
} catch {
|
||||
// Ignore malformed local draft data.
|
||||
}
|
||||
|
||||
const handleDraft = (event: Event) => {
|
||||
applyVideoDraft((event as CustomEvent).detail);
|
||||
};
|
||||
window.addEventListener(TEXT_TO_VIDEO_DRAFT_EVENT, handleDraft);
|
||||
return () => window.removeEventListener(TEXT_TO_VIDEO_DRAFT_EVENT, handleDraft);
|
||||
}, [applyVideoDraft]);
|
||||
|
||||
const textModelOptions = useMemo(() => [
|
||||
...textKeys.map(k => ({ id: buildCustomModelId(k.id), label: `${k.modelName || k.provider} (自定义)`, config: { customApiKeyId: k.id, modelName: k.modelName } })),
|
||||
...systemTextApis.map(api => ({ id: buildSystemModelId(api.id), label: `${api.name} (系统)`, config: { systemApiId: api.id, modelName: api.modelName } })),
|
||||
@@ -251,6 +281,8 @@ export function TextToVideoPanel() {
|
||||
}, [prompt, selectedModel, getCurrentModelLabel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InspirationGalleryDialog mode="text2video" open={inspirationOpen} onOpenChange={setInspirationOpen} />
|
||||
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
|
||||
{/* Left: Settings */}
|
||||
<div className="min-w-0 space-y-5 pb-8 pr-2">
|
||||
@@ -277,14 +309,20 @@ export function TextToVideoPanel() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label>视频描述</Label>
|
||||
{textModelOptions.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
|
||||
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{optimizing ? '优化中...' : '优化提示词'}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={() => setInspirationOpen(true)}>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
获取灵感
|
||||
</Button>
|
||||
)}
|
||||
{textModelOptions.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-primary hover:text-primary" onClick={handleOptimizePrompt} disabled={optimizing || !prompt.trim()}>
|
||||
{optimizing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
|
||||
{optimizing ? '优化中...' : '优化提示词'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ExpandablePromptTextarea
|
||||
title="视频描述"
|
||||
@@ -423,5 +461,6 @@ export function TextToVideoPanel() {
|
||||
onClose={() => setSelectedHistoryRecord(null)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Download, Copy, FileSearch, ImageOff, Film, ImageIcon, Share2, CheckCir
|
||||
import { toast } from 'sonner';
|
||||
import { FullscreenPreview } from '@/components/fullscreen-preview';
|
||||
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
|
||||
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
|
||||
|
||||
interface CreationDetailDialogProps {
|
||||
record: CreationRecord | null;
|
||||
@@ -139,6 +140,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
const [viewportSize, setViewportSize] = useState({ width: 1280, height: 900 });
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
|
||||
|
||||
useEffect(() => {
|
||||
if (record) {
|
||||
@@ -337,7 +339,8 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
src={referenceImage}
|
||||
alt="参考图片"
|
||||
className="h-full w-full cursor-zoom-in object-contain"
|
||||
onDoubleClick={() => setFullscreenSrc(referenceImage)}
|
||||
onClick={() => setFullscreenSrc(referenceImage)}
|
||||
onContextMenu={(event) => openImageMenu(event, referenceImage)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
@@ -416,6 +419,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
open={!!fullscreenSrc}
|
||||
onClose={() => setFullscreenSrc(null)}
|
||||
/>
|
||||
{ImageActionsContextMenu}
|
||||
{deleteConfirmDialog}
|
||||
</>
|
||||
);
|
||||
@@ -553,7 +557,8 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
setMediaAspectRatio(img.naturalWidth / img.naturalHeight);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => setFullscreenSrc(record.url)}
|
||||
onClick={() => setFullscreenSrc(record.url)}
|
||||
onContextMenu={(event) => openImageMenu(event, record.url)}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
@@ -599,7 +604,8 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
src={url}
|
||||
alt={`参考图 ${index + 1}`}
|
||||
className="aspect-square w-full cursor-zoom-in object-cover"
|
||||
onDoubleClick={() => setFullscreenSrc(url)}
|
||||
onClick={() => setFullscreenSrc(url)}
|
||||
onContextMenu={(event) => openImageMenu(event, url)}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-black/35 p-1 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
@@ -766,6 +772,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
open={!!fullscreenSrc}
|
||||
onClose={() => setFullscreenSrc(null)}
|
||||
/>
|
||||
{ImageActionsContextMenu}
|
||||
{deleteConfirmDialog}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,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';
|
||||
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
|
||||
|
||||
interface FullscreenPreviewProps {
|
||||
src: string;
|
||||
@@ -27,6 +28,7 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
|
||||
const dragRef = useRef({
|
||||
pointerId: -1,
|
||||
startX: 0,
|
||||
@@ -246,7 +248,7 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
|
||||
)}
|
||||
|
||||
<div className={`absolute bottom-4 left-4 z-10 rounded-full px-3 py-1.5 text-xs font-medium ${inverseControlClass}`}>
|
||||
{Math.round(scale * 100)}% · 滚轮缩放 · 放大后拖动 · 点击空白关闭
|
||||
{Math.round(scale * 100)}% · 滚轮/双击缩放 · 放大后拖动 · 点击空白关闭
|
||||
</div>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
@@ -264,6 +266,7 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
|
||||
touchAction: 'none',
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => openImageMenu(event, currentSrc)}
|
||||
onWheel={(event) => {
|
||||
zoomFromWheel(event.nativeEvent);
|
||||
}}
|
||||
@@ -304,6 +307,7 @@ export function FullscreenPreview({ src, alt, images, initialIndex = 0, open, on
|
||||
}}
|
||||
onPointerCancel={() => setDragging(false)}
|
||||
/>
|
||||
{ImageActionsContextMenu}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -325,8 +329,8 @@ export function useFullscreenPreview() {
|
||||
setPreviewOpen(false);
|
||||
}, []);
|
||||
|
||||
const getDoubleClickProps = useCallback((src: string, alt?: string) => ({
|
||||
onDoubleClick: (event: MouseEvent) => {
|
||||
const getClickProps = useCallback((src: string, alt?: string) => ({
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
openPreview(src, alt);
|
||||
},
|
||||
@@ -339,7 +343,8 @@ export function useFullscreenPreview() {
|
||||
previewAlt,
|
||||
openPreview,
|
||||
closePreview,
|
||||
getDoubleClickProps,
|
||||
getDoubleClickProps: getClickProps,
|
||||
getClickProps,
|
||||
FullscreenPreviewComponent: previewOpen ? (
|
||||
<FullscreenPreview
|
||||
src={previewSrc}
|
||||
|
||||
180
src/components/image-actions-context-menu.tsx
Normal file
180
src/components/image-actions-context-menu.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Copy, Download, PencilLine, Share2 } from 'lucide-react';
|
||||
import { downloadFile, copyTextToClipboard } from '@/lib/utils';
|
||||
import { writeCreationReuseDraft } from '@/lib/creation-reuse';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type MenuState = {
|
||||
src: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
function getAbsoluteImageUrl(src: string): string {
|
||||
const trimmed = src.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (typeof window !== 'undefined' && trimmed.startsWith('/')) {
|
||||
return `${window.location.origin}${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getImageViewerUrl(src: string): string {
|
||||
const absoluteUrl = getAbsoluteImageUrl(src);
|
||||
if (typeof window === 'undefined') return absoluteUrl;
|
||||
const viewerUrl = new URL('/image-viewer', window.location.origin);
|
||||
viewerUrl.searchParams.set('url', absoluteUrl);
|
||||
return viewerUrl.toString();
|
||||
}
|
||||
|
||||
function getImageExtension(src: string): string {
|
||||
const path = src.split('?')[0].split('#')[0];
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
if (ext && ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return ext;
|
||||
return 'png';
|
||||
}
|
||||
|
||||
async function fetchOriginalImageBlob(src: string): Promise<Blob> {
|
||||
const absoluteUrl = getAbsoluteImageUrl(src);
|
||||
const response = await fetch(`/api/download?url=${encodeURIComponent(absoluteUrl)}&filename=image.${getImageExtension(src)}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('原图获取失败');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (!blob.type.startsWith('image/')) {
|
||||
throw new Error('当前资源不是可复制的图片');
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function copyOriginalImageToClipboard(src: string): Promise<void> {
|
||||
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined' || !window.isSecureContext) {
|
||||
throw new Error('当前浏览器环境不支持直接复制图片');
|
||||
}
|
||||
const blob = await fetchOriginalImageBlob(src);
|
||||
const type = blob.type || 'image/png';
|
||||
if (typeof ClipboardItem.supports === 'function' && !ClipboardItem.supports(type)) {
|
||||
throw new Error('当前浏览器不支持复制该图片格式');
|
||||
}
|
||||
await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]);
|
||||
}
|
||||
|
||||
function clampMenuPosition(x: number, y: number) {
|
||||
if (typeof window === 'undefined') return { x, y };
|
||||
return {
|
||||
x: Math.min(x, window.innerWidth - 188),
|
||||
y: Math.min(y, window.innerHeight - 184),
|
||||
};
|
||||
}
|
||||
|
||||
export function useImageActionsContextMenu() {
|
||||
const router = useRouter();
|
||||
const [menu, setMenu] = useState<MenuState>(null);
|
||||
|
||||
const closeImageMenu = useCallback(() => setMenu(null), []);
|
||||
|
||||
const openImageMenu = useCallback((event: ReactMouseEvent, src: string) => {
|
||||
if (!src || src.startsWith('data:') || src.startsWith('[')) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMenu({ src, ...clampMenuPosition(event.clientX, event.clientY) });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const close = () => setMenu(null);
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', close);
|
||||
window.addEventListener('scroll', close, true);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', close);
|
||||
window.removeEventListener('scroll', close, true);
|
||||
};
|
||||
}, [menu]);
|
||||
|
||||
const copyImage = useCallback(async () => {
|
||||
if (!menu) return;
|
||||
try {
|
||||
await copyOriginalImageToClipboard(menu.src);
|
||||
toast.success('原图已复制到剪贴板');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '复制失败');
|
||||
} finally {
|
||||
closeImageMenu();
|
||||
}
|
||||
}, [closeImageMenu, menu]);
|
||||
|
||||
const downloadImage = useCallback(async () => {
|
||||
if (!menu) return;
|
||||
const result = await downloadFile(getAbsoluteImageUrl(menu.src), `miaojing-original-${Date.now()}.${getImageExtension(menu.src)}`);
|
||||
if (result.ok) {
|
||||
toast.success('原图下载已开始');
|
||||
} else {
|
||||
toast.error(result.error || '下载失败');
|
||||
}
|
||||
closeImageMenu();
|
||||
}, [closeImageMenu, menu]);
|
||||
|
||||
const editImage = useCallback(() => {
|
||||
if (!menu) return;
|
||||
const originalUrl = getAbsoluteImageUrl(menu.src);
|
||||
writeCreationReuseDraft('img2img', {
|
||||
prompt: '',
|
||||
referenceImage: originalUrl,
|
||||
referenceImages: [originalUrl],
|
||||
source: 'creation-detail',
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
closeImageMenu();
|
||||
router.push('/create?type=img2img');
|
||||
toast.success('已带入图生图参考图');
|
||||
}, [closeImageMenu, menu, router]);
|
||||
|
||||
const shareImage = useCallback(async () => {
|
||||
if (!menu) return;
|
||||
const url = getImageViewerUrl(menu.src);
|
||||
const result = await copyTextToClipboard(url);
|
||||
if (result === 'copied') {
|
||||
toast.success('原图全屏链接已复制');
|
||||
} else if (result === 'manual') {
|
||||
toast.info('已选中链接,请按 Ctrl+C 复制');
|
||||
} else {
|
||||
toast.error('链接复制失败');
|
||||
}
|
||||
closeImageMenu();
|
||||
}, [closeImageMenu, menu]);
|
||||
|
||||
const ImageActionsContextMenu = menu ? (
|
||||
<div
|
||||
className="fixed z-[2147483646] min-w-44 overflow-hidden rounded-xl border border-white/18 bg-black/82 p-1.5 text-sm text-white shadow-[0_18px_48px_rgba(0,0,0,0.36)] backdrop-blur-xl light:border-amber-900/18 light:bg-white/94 light:text-foreground light:shadow-[0_18px_48px_rgba(83,61,27,0.18)]"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<button className="flex h-9 w-full items-center gap-2 rounded-lg px-3 text-left hover:bg-white/12 light:hover:bg-amber-900/8" onClick={copyImage}>
|
||||
<Copy className="h-4 w-4" />
|
||||
复制
|
||||
</button>
|
||||
<button className="flex h-9 w-full items-center gap-2 rounded-lg px-3 text-left hover:bg-white/12 light:hover:bg-amber-900/8" onClick={downloadImage}>
|
||||
<Download className="h-4 w-4" />
|
||||
下载
|
||||
</button>
|
||||
<button className="flex h-9 w-full items-center gap-2 rounded-lg px-3 text-left hover:bg-white/12 light:hover:bg-amber-900/8" onClick={editImage}>
|
||||
<PencilLine className="h-4 w-4" />
|
||||
编辑
|
||||
</button>
|
||||
<button className="flex h-9 w-full items-center gap-2 rounded-lg px-3 text-left hover:bg-white/12 light:hover:bg-amber-900/8" onClick={shareImage}>
|
||||
<Share2 className="h-4 w-4" />
|
||||
分享
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return { openImageMenu, ImageActionsContextMenu };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
|
||||
|
||||
interface LightboxProps {
|
||||
/** Image URL to display */
|
||||
@@ -28,6 +29,7 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
});
|
||||
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
|
||||
|
||||
const setClampedZoom = useCallback((updater: number | ((current: number) => number)) => {
|
||||
setZoom(current => {
|
||||
@@ -104,7 +106,7 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
|
||||
{/* 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>}
|
||||
双击/滚轮缩放 | ESC 关闭
|
||||
滚轮/双击缩放 | ESC 关闭 | 右键更多
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
@@ -139,6 +141,7 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
|
||||
setClampedZoom(2);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => openImageMenu(event, src)}
|
||||
onPointerDown={e => {
|
||||
e.stopPropagation();
|
||||
if (zoom <= 1 || e.button !== 0) return;
|
||||
@@ -169,11 +172,14 @@ export function ImageLightbox({ src, alt, open, onClose }: LightboxProps) {
|
||||
onPointerCancel={() => setDragging(false)}
|
||||
/>
|
||||
</div>
|
||||
{ImageActionsContextMenu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BareImagePreview({ src, alt, open, onClose }: LightboxProps) {
|
||||
const { openImageMenu, ImageActionsContextMenu } = useImageActionsContextMenu();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -201,7 +207,9 @@ export function BareImagePreview({ src, alt, open, onClose }: LightboxProps) {
|
||||
alt={alt || '参考图预览'}
|
||||
className="max-h-[94vh] max-w-[94vw] object-contain"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => openImageMenu(event, src)}
|
||||
/>
|
||||
{ImageActionsContextMenu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,16 @@ import type { ImageOutputFormat, ImageQuality } from '@/lib/model-config';
|
||||
|
||||
export const TEXT_TO_IMAGE_DRAFT_KEY = 'miaojing:text-to-image-draft';
|
||||
export const IMAGE_TO_IMAGE_DRAFT_KEY = 'miaojing:image-to-image-draft';
|
||||
export const TEXT_TO_VIDEO_DRAFT_KEY = 'miaojing:text-to-video-draft';
|
||||
export const IMAGE_TO_VIDEO_DRAFT_KEY = 'miaojing:image-to-video-draft';
|
||||
export const TEXT_TO_IMAGE_DRAFT_EVENT = 'miaojing:text-to-image-draft';
|
||||
export const IMAGE_TO_IMAGE_DRAFT_EVENT = 'miaojing:image-to-image-draft';
|
||||
export const TEXT_TO_VIDEO_DRAFT_EVENT = 'miaojing:text-to-video-draft';
|
||||
export const IMAGE_TO_VIDEO_DRAFT_EVENT = 'miaojing:image-to-video-draft';
|
||||
|
||||
export type ImageCreationReuseDraft = {
|
||||
export type CreationReuseTarget = 'text2img' | 'img2img' | 'text2video' | 'img2video';
|
||||
|
||||
export type CreationReuseDraft = {
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
model?: string;
|
||||
@@ -18,19 +24,35 @@ export type ImageCreationReuseDraft = {
|
||||
outputFormat?: ImageOutputFormat;
|
||||
imageQuality?: ImageQuality;
|
||||
styleLabel?: string;
|
||||
duration?: string;
|
||||
cameraMovement?: string;
|
||||
style?: string;
|
||||
guidanceScale?: number;
|
||||
strength?: number;
|
||||
referenceImage?: string;
|
||||
referenceImages?: string[];
|
||||
source?: 'creation-detail' | 'reverse-prompt';
|
||||
source?: 'creation-detail' | 'reverse-prompt' | 'gallery' | 'inspiration-gallery';
|
||||
sourceRecordId?: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
type ReuseTarget = 'text2img' | 'img2img';
|
||||
export type ImageCreationReuseDraft = CreationReuseDraft;
|
||||
|
||||
type CreationReuseSource = {
|
||||
id: string;
|
||||
url: string;
|
||||
prompt?: string | null;
|
||||
negativePrompt?: string | null;
|
||||
model?: string | null;
|
||||
params?: Record<string, unknown>;
|
||||
referenceImage?: string | null;
|
||||
referenceImages?: string[];
|
||||
thumbnailUrl?: string | null;
|
||||
};
|
||||
|
||||
const TEXT_TO_IMAGE_ASPECT_RATIOS = new Set(['auto', '1:1', '16:9', '9:16', '4:3', '3:4']);
|
||||
const IMAGE_TO_IMAGE_ASPECT_RATIOS = new Set(['auto', 'original', '1:1', '16:9', '9:16', '4:3', '3:4']);
|
||||
const VIDEO_ASPECT_RATIOS = new Set(['1:1', '16:9', '9:16', '4:3', '3:4']);
|
||||
const RESOLUTIONS = new Set(['auto', '1080P', '2K', '4K']);
|
||||
const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']);
|
||||
const IMAGE_QUALITIES = new Set(['auto', 'high', 'medium', 'low']);
|
||||
@@ -53,11 +75,25 @@ function getNumber(params: Record<string, unknown>, keys: string[]): number | un
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeAspectRatio(value: string | undefined, target: ReuseTarget): string | undefined {
|
||||
function getStringArray(params: Record<string, unknown>, keys: string[]): string[] {
|
||||
for (const key of keys) {
|
||||
const value = params[key];
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeAspectRatio(value: string | undefined, target: CreationReuseTarget): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const allowed = target === 'img2img' ? IMAGE_TO_IMAGE_ASPECT_RATIOS : TEXT_TO_IMAGE_ASPECT_RATIOS;
|
||||
const allowed = target === 'img2img'
|
||||
? IMAGE_TO_IMAGE_ASPECT_RATIOS
|
||||
: target === 'text2video' || target === 'img2video'
|
||||
? VIDEO_ASPECT_RATIOS
|
||||
: TEXT_TO_IMAGE_ASPECT_RATIOS;
|
||||
if (allowed.has(value)) return value;
|
||||
if (value === 'original' && target === 'text2img') return 'auto';
|
||||
if (value === 'original' && target !== 'img2img') return target === 'text2img' ? 'auto' : undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -90,9 +126,38 @@ function normalizeReferenceUrl(url: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function buildImageCreationReuseDraft(record: CreationRecord, target: ReuseTarget): ImageCreationReuseDraft {
|
||||
function getReferenceImages(record: CreationReuseSource, target: CreationReuseTarget, useOutputAsReference: boolean): string[] {
|
||||
const params = record.params || {};
|
||||
const draft: ImageCreationReuseDraft = {
|
||||
const explicitReferences = [
|
||||
...(typeof record.referenceImage === 'string' && record.referenceImage.trim() ? [record.referenceImage] : []),
|
||||
...(Array.isArray(record.referenceImages) ? record.referenceImages : []),
|
||||
...getStringArray(params, ['referenceImages']),
|
||||
...getStringArray(params, ['images']),
|
||||
...(getString(params, ['referenceImage', 'image']) ? [getString(params, ['referenceImage', 'image']) as string] : []),
|
||||
];
|
||||
const normalized = explicitReferences
|
||||
.filter(url => url && !url.startsWith('data:') && !url.startsWith('['))
|
||||
.map(normalizeReferenceUrl);
|
||||
if (normalized.length > 0) return [...new Set(normalized)];
|
||||
|
||||
if (useOutputAsReference && target === 'img2img' && record.url && !record.url.startsWith('data:') && !record.url.startsWith('[')) {
|
||||
return [normalizeReferenceUrl(record.url)];
|
||||
}
|
||||
|
||||
if (useOutputAsReference && target === 'img2video' && record.thumbnailUrl && !record.thumbnailUrl.startsWith('data:') && !record.thumbnailUrl.startsWith('[')) {
|
||||
return [normalizeReferenceUrl(record.thumbnailUrl)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildCreationReuseDraft(
|
||||
record: CreationReuseSource,
|
||||
target: CreationReuseTarget,
|
||||
options: { source?: CreationReuseDraft['source']; useOutputAsReference?: boolean } = {},
|
||||
): CreationReuseDraft {
|
||||
const params = record.params || {};
|
||||
const draft: CreationReuseDraft = {
|
||||
prompt: record.prompt || '',
|
||||
negativePrompt: record.negativePrompt || '',
|
||||
model: record.model || getString(params, ['model']),
|
||||
@@ -102,29 +167,49 @@ export function buildImageCreationReuseDraft(record: CreationRecord, target: Reu
|
||||
outputFormat: normalizeOutputFormat(getString(params, ['outputFormat', 'format'])),
|
||||
imageQuality: normalizeImageQuality(getString(params, ['imageQuality', 'quality'])),
|
||||
styleLabel: getString(params, ['styleLabel']),
|
||||
duration: getString(params, ['duration']),
|
||||
cameraMovement: getString(params, ['cameraMovement']),
|
||||
style: getString(params, ['style']),
|
||||
guidanceScale: getNumber(params, ['guidanceScale']),
|
||||
strength: getNumber(params, ['strength']),
|
||||
source: 'creation-detail',
|
||||
source: options.source || 'creation-detail',
|
||||
sourceRecordId: record.id,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (target === 'img2img') {
|
||||
const referenceImages = record.url && !record.url.startsWith('[')
|
||||
? [normalizeReferenceUrl(record.url)]
|
||||
: [];
|
||||
if (target === 'img2img' || target === 'img2video') {
|
||||
const referenceImages = getReferenceImages(record, target, options.useOutputAsReference !== false);
|
||||
draft.referenceImage = referenceImages[0];
|
||||
draft.referenceImages = referenceImages;
|
||||
draft.strength = draft.strength ?? 0.5;
|
||||
if (target === 'img2img') {
|
||||
draft.strength = draft.strength ?? 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function writeImageCreationReuseDraft(target: ReuseTarget, draft: ImageCreationReuseDraft): void {
|
||||
export function buildImageCreationReuseDraft(record: CreationRecord, target: 'text2img' | 'img2img'): ImageCreationReuseDraft {
|
||||
return buildCreationReuseDraft(record, target, { source: 'creation-detail', useOutputAsReference: true });
|
||||
}
|
||||
|
||||
function getDraftStorage(target: CreationReuseTarget): { key: string; eventName: string } {
|
||||
switch (target) {
|
||||
case 'img2img':
|
||||
return { key: IMAGE_TO_IMAGE_DRAFT_KEY, eventName: IMAGE_TO_IMAGE_DRAFT_EVENT };
|
||||
case 'text2video':
|
||||
return { key: TEXT_TO_VIDEO_DRAFT_KEY, eventName: TEXT_TO_VIDEO_DRAFT_EVENT };
|
||||
case 'img2video':
|
||||
return { key: IMAGE_TO_VIDEO_DRAFT_KEY, eventName: IMAGE_TO_VIDEO_DRAFT_EVENT };
|
||||
case 'text2img':
|
||||
default:
|
||||
return { key: TEXT_TO_IMAGE_DRAFT_KEY, eventName: TEXT_TO_IMAGE_DRAFT_EVENT };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCreationReuseDraft(target: CreationReuseTarget, draft: CreationReuseDraft): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const key = target === 'img2img' ? IMAGE_TO_IMAGE_DRAFT_KEY : TEXT_TO_IMAGE_DRAFT_KEY;
|
||||
const eventName = target === 'img2img' ? IMAGE_TO_IMAGE_DRAFT_EVENT : TEXT_TO_IMAGE_DRAFT_EVENT;
|
||||
const { key, eventName } = getDraftStorage(target);
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(draft));
|
||||
} catch {
|
||||
@@ -132,3 +217,7 @@ export function writeImageCreationReuseDraft(target: ReuseTarget, draft: ImageCr
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(eventName, { detail: draft }));
|
||||
}
|
||||
|
||||
export function writeImageCreationReuseDraft(target: 'text2img' | 'img2img', draft: ImageCreationReuseDraft): void {
|
||||
writeCreationReuseDraft(target, draft);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user