fix: optimize video storage and gallery preview
This commit is contained in:
@@ -36,8 +36,8 @@ All routes are Next.js App Router route handlers under `src/app/api/**/route.ts`
|
||||
| DELETE | `/api/announcements?id=...` | Admin | `src/app/api/announcements/route.ts` | Delete announcement. |
|
||||
| GET | `/api/model-config` | Public, optional bearer token | `src/app/api/model-config/route.ts` | Read managed provider/model configuration for clients. System APIs are filtered to active platform-default models allowed for the current user's membership tier; anonymous users are treated as `free`. |
|
||||
| GET | `/api/style-presets` | Public | `src/app/api/style-presets/route.ts` | Returns active image style presets from `image_style_presets`, sorted by usage count. |
|
||||
| GET | `/api/local-storage/[...path]` | Public by URL | `src/app/api/local-storage/[...path]/route.ts` | Serve storage object by key. Thumbnail keys under `thumbnails/...` are served from local disk with long immutable browser cache headers; object-backed originals return a short-lived signed object-storage redirect when configured. The public URL shape remains stable across migration. |
|
||||
| GET | `/api/download?url=...&filename=...` | Public by URL | `src/app/api/download/route.ts` | Download proxy for remote, same-origin, and `/api/local-storage/*` URLs, including object-backed storage keys. Add `disposition=inline` or `inline=1` when the proxy is used as an image/video preview source instead of a forced download. |
|
||||
| GET | `/api/local-storage/[...path]` | Public by URL | `src/app/api/local-storage/[...path]/route.ts` | Serve storage object by key. Thumbnail keys under `thumbnails/...` are served from local disk with long immutable browser cache headers; object-backed originals return a short-lived signed object-storage redirect when configured. SVG video thumbnails under `thumbnails/.../*.svg` are served as `image/svg+xml`. The public URL shape remains stable across migration. |
|
||||
| GET | `/api/download?url=...&filename=...` | Public by URL | `src/app/api/download/route.ts` | Download proxy for remote, same-origin, and `/api/local-storage/*` URLs, including object-backed storage keys. Object-backed local-storage keys redirect to short-lived signed object URLs with content-disposition so large videos are not buffered through Next.js. Add `disposition=inline` or `inline=1` when the proxy is used as an image/video preview source instead of a forced download. |
|
||||
|
||||
## Auth And Account Routes
|
||||
|
||||
@@ -86,7 +86,7 @@ All email sends route through `src/lib/email-service.ts`, which renders HTML and
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| GET | `/api/admin/invitations` | Admin | `src/app/api/admin/invitations/route.ts` | Optional `search`, `page`, `pageSize` | Long-term invitation records joining inviter and invitee profile details. |
|
||||
| POST | `/api/generate/image` | Trusted internal or resolved user/system API context | `src/app/api/generate/image/route.ts` | Image generation payload; supports prompt, negative prompt, reference images, model/system/custom API config, aspect/size/resolution/count/quality. | Calls SDK or OpenAI/New API-compatible endpoint, persists original images to object storage and local WEBP thumbnails to `thumbnails/generated/images`, returns `images` original URLs plus `thumbnails`, `thumbnailUrls`, and `dimensions` `{ [originalUrl]: { width, height } }`, updates job progress when headers include job ID. |
|
||||
| POST | `/api/generate/video` | Trusted internal or resolved user/system API context | `src/app/api/generate/video/route.ts` | Video generation payload; supports prompt, reference image, model/system/custom API config, ratio/duration/fps-like params. | Calls SDK or Manifest/custom endpoint, polls async Manifest providers such as 元界 media tasks, then persists media through the storage adapter. |
|
||||
| POST | `/api/generate/video` | Trusted internal or resolved user/system API context | `src/app/api/generate/video/route.ts` | Video generation payload; supports prompt, reference image, model/system/custom API config, ratio/duration/fps-like params. | Calls SDK or Manifest/custom endpoint, polls async Manifest providers such as 元界 media tasks, then persists generated video media as object-backed `/api/local-storage/generated/videos/...` URLs when object storage is configured. |
|
||||
| POST | `/api/generate/reverse-prompt` | Uses supplied/resolved API config; Bearer token required when resolving user custom or gated system API IDs | `src/app/api/generate/reverse-prompt/route.ts` | `image`, `outputMode`, `language`, optional `customApiConfig`/system/custom IDs | Returns prompt fields and may persist reference image. The create-panel caller must forward the stored access token in `Authorization` because server-side API resolution cannot read browser localStorage. |
|
||||
| POST | `/api/generate/suggest-prompt` | Uses supplied/resolved API config | `src/app/api/generate/suggest-prompt/route.ts` | `prompt`, optional `customApiConfig`, `systemPrefix` | Returns optimized `prompt` and optional `negativePrompt`. |
|
||||
|
||||
|
||||
@@ -181,10 +181,12 @@ Admin console navigation state is intentionally short-lived. `src/modules/consol
|
||||
- Download route: `src/app/api/download/route.ts`.
|
||||
- Storage key validation prevents traversal through `normalizeKey`, `path.resolve`, and `..` checks.
|
||||
|
||||
Generation routes persist generated media through the storage adapter. Gallery publish copies referenced result URLs into gallery folders when possible. Admin data export/import reads and restores through the same adapter, and import whitelists `manifest_path` plus system API pricing fields so intelligent API configurations survive server migration. Import preserves `auth.users.password_hash` and existing encrypted secret fields as encrypted values; production migrations must carry the same `DATA_ENCRYPTION_KEY`/JWT secret family or encrypted API/payment secrets and existing sessions cannot be decoded correctly. Work dedupe is scoped by `user_id` plus URL/source URL/media SHA to protect private data ownership when different users have identical media.
|
||||
Generation routes persist generated media through the storage adapter. Image originals and video originals are object-first when object storage is configured: images go through `src/lib/media-storage.ts`, while videos from `src/app/api/generate/video/route.ts` are stored with `uploadFileObjectOnly(...)` under `generated/videos`. Gallery publish copies image originals into gallery folders, but object-backed video URLs under `/api/local-storage/...` are reused rather than synchronously copying large video files again. Admin data export/import reads and restores through the same adapter, and import whitelists `manifest_path` plus system API pricing fields so intelligent API configurations survive server migration. Import preserves `auth.users.password_hash` and existing encrypted secret fields as encrypted values; production migrations must carry the same `DATA_ENCRYPTION_KEY`/JWT secret family or encrypted API/payment secrets and existing sessions cannot be decoded correctly. Work dedupe is scoped by `user_id` plus URL/source URL/media SHA to protect private data ownership when different users have identical media.
|
||||
|
||||
Image originals and previews have separate storage rules. `src/lib/media-storage.ts` persists new generated image originals through `localStorage.uploadFileObjectOnly(...)` so production originals live in the object bucket even while the app remains in `STORAGE_MODE=dual`. The same helper writes high-quality compressed WEBP thumbnails through `uploadFileLocalOnly(...)` under `thumbnails/...`; the current thumbnail profile uses 1280px max edge, WEBP quality 86, Lanczos resize, and light sharpening, with an `m1280q86` filename suffix so older thumbnail profiles can be replaced in the background. Create results, creation history, gallery cards, and gallery detail previews should render `thumbnailUrl`, while fullscreen preview, download, copy, edit, and share actions must continue to use the original `url`. Legacy rows without current `works.thumbnail_url` are queued for background thumbnail backfill by `/api/creation-history` and `/api/gallery` when image works are read; list responses must not wait for thumbnail generation. Thumbnail backfill should read object-only originals through short-lived signed object URLs instead of slow SDK buffering. The `/api/local-storage/*` route reads `thumbnails/...` from local disk directly instead of probing object storage first and serves them with long immutable browser cache headers because thumbnail filenames include the profile/hash; `src/proxy.ts` must explicitly preserve cacheable thumbnail/gallery routes instead of applying the default `/api` no-store header. Object-backed originals should return a short-lived 302 signed object-storage URL instead of buffering the full original through Next.js.
|
||||
|
||||
Video originals and previews also have separate storage rules. Generated videos are stored as object-backed `/api/local-storage/generated/videos/...` URLs. Video thumbnails are lightweight local SVG files under `thumbnails/works/videos` or `thumbnails/gallery/videos`, generated by `ensureLocalVideoThumbnail(...)` when history/gallery rows are written or read. Gallery video cards and detail overlays render the thumbnail first; the original video element is mounted only after the user clicks play, so opening the gallery detail does not immediately download the object-storage video. `/api/download` redirects object-backed local-storage downloads to signed object URLs with content-disposition, and video buttons use a normal anchor-triggered download instead of fetching the full video into a browser blob.
|
||||
|
||||
Gallery detail metadata must not load original images just to compute size. `ImageMetadataBadge` accepts stored `width`/`height`; gallery detail passes those values with `loadMetadata={false}` so preview surfaces stay thumbnail-only and original requests are reserved for fullscreen, download, copy, edit, and share.
|
||||
|
||||
The public gallery page should use server gallery rows only. It must not merge `miaojing_published_gallery` or `miaojing_creation_history` from browser localStorage into the gallery feed, and it must not auto-sync historical local published records into Supabase on page load. `/api/gallery` is the authority for all gallery views, including all/category filters and search, and should only return stable platform media URLs under `/api/local-storage/...`; legacy external import URLs are not public gallery candidates. To keep reopen latency low, `src/app/gallery/page.tsx` caches bounded page data in browser localStorage for instant first paint, uses cached rows up to the 7-day prune window while revalidating page 0 in the background, and shows a masonry skeleton instead of a blocking centered loading message when no cache exists. Public gallery serialization in `src/lib/gallery-response.ts` filters generated default `data:` avatars and oversized avatar URLs so repeated `publisherAvatarUrl` fields do not bloat `/api/gallery` responses or exceed localStorage quota. It should request small pages and append via IntersectionObserver as the user scrolls, not load the entire public gallery into the DOM.
|
||||
|
||||
@@ -55,11 +55,14 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Text-to-image or image-to-image says `请在提示词中写明画面比例` even after selecting a Yuanjie resolution such as `4K 竖版 (3:4)` | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/lib/yuanjie-image-model-templates.ts` | Some Yuanjie image templates set `supportsAspectRatio: false` and encode orientation in `resolution`/`size` options. Generation validation must derive the ratio from the selected resolution label or dimensions instead of requiring a separate aspect-ratio control. Image-to-image should also default count to `1` rather than requiring prompt inference for `生成数量`. |
|
||||
| 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 through the storage adapter, stable `/api/local-storage/<key>` URL returned, history POST called. In object storage mode, verify `STORAGE_MODE` and `OBJECT_STORAGE_*` health. |
|
||||
| Generated video is not in object storage or video download/share feels slow | `src/app/api/generate/video/route.ts`, `src/lib/local-storage.ts`, `src/app/api/download/route.ts`, `src/app/api/gallery/publish/route.ts`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/app/gallery/page.tsx` | Video generation should persist remote/data video results via `uploadFileObjectOnly(...)` under `generated/videos`. `/api/download` should redirect object-backed `/api/local-storage/*` downloads to signed object URLs instead of buffering large videos, and video buttons should call `triggerDownloadFile(...)`. Gallery publish should reuse object-backed video URLs rather than copying large videos again; missing video thumbnails should be local SVG previews. |
|
||||
| A single generated video appears twice in text-to-video or image-to-video history | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | The client creates an optimistic local history row, then the server returns a DB row with a different id. Local storage and `/api/creation-history` must de-duplicate by `url`/`result_url`, preserving only one visible record for the same generated video. |
|
||||
| Image preview cards load slowly, look blurry in detail, or fetch full originals | `src/lib/media-storage.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/app/api/gallery/route.ts`, `src/app/api/gallery/publish/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/app/gallery/page.tsx` | New generated image originals should be object-only, while WEBP thumbnails should be local-only under `thumbnails/...`. Current thumbnails should have the `m1280q86` suffix and come from the 1280px/Lanczos/sharpened profile. Cards and detail preview surfaces use `thumbnailUrl || url`; fullscreen, right-click copy/download/edit, and share must use original `url`. Detail metadata badges must use stored width/height with `loadMetadata={false}` rather than requesting original images. `GET /api/creation-history` and `GET /api/gallery` should queue missing or old-profile legacy thumbnails in the background, not block the list response. `/api/local-storage/thumbnails/...` must read local disk directly in dual mode instead of checking object storage first; original image keys should 302 to a short-lived signed object-storage URL so fullscreen does not wait for Next.js to buffer the full file. |
|
||||
| Gallery shows `加载中...` for seconds on every visit or loads too many images at once | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts`, `src/lib/gallery-cache-policy.ts`, `src/app/api/local-storage/[...path]/route.ts`, `src/proxy.ts` | The page should show cached `miaojing:gallery:v3` rows immediately when available, even when older than the short freshness TTL, then revalidate page 0 in the background. It should show the masonry skeleton instead of the old centered `加载中...` when no cache exists, debounce search, request small `limit/offset` pages, and append more rows only through the scroll sentinel. Check `/api/gallery` response size with curl; generated default avatar `data:` URLs in `publisherAvatarUrl` can make every page hundreds of KB larger and can break localStorage caching, so public gallery serialization must filter `data:`/oversized avatars. Do not restore the old `limit=300` full-gallery request. Thumbnail URLs under `/api/local-storage/thumbnails/...` should return long immutable cache headers so browser image cache is actually used; if curl still shows `no-store`, check `src/proxy.ts` because the global `/api` cache header can override the route response. |
|
||||
| `/api/health` or page probes are slow after object migration | `src/app/api/health/route.ts`, `src/lib/local-storage.ts` | Health checks call `getStorageHealthStatus()`. Object bucket checks should be cached briefly and bounded with an abort timeout so a slow S3-compatible endpoint does not hold request threads for many seconds. |
|
||||
| Logs repeatedly show `must be owner of table ...` on normal requests | `src/lib/generation-job-estimates.ts`, `src/lib/email-service.ts`, `src/lib/profile-preferences.ts`, `src/lib/user-profile-defaults.ts`, `src/lib/server-api-config.ts` | Optional runtime schema checks can hit `42501` when the production app user is not the table owner. Treat existing-schema `42501` as a one-time warning and cache the skip; apply real schema migrations through deployment/DB owner operations rather than request-time DDL. |
|
||||
| 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. Fullscreen components should receive a thumbnail fallback so the preview appears immediately while the original object-storage image loads. 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`. |
|
||||
| Gallery video detail says `下载图片` or opens the original video too early | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/media-storage.ts`, `src/app/api/gallery/publish/route.ts` | Use `isVideoWork(...)` for labels and filenames. Video cards/details should render `thumbnailUrl` or the generated SVG fallback first and mount the original `<video>` only after the user clicks play. If thumbnails are missing, `/api/gallery` and publish should backfill local SVG thumbnails under `thumbnails/gallery/videos`. |
|
||||
| Gallery or history detail logs/requests original generated URLs while preview should use thumbnails | `src/app/gallery/page.tsx`, `src/components/creation-detail-dialog.tsx`, `src/components/image-metadata-badge.tsx` | Check the actual `<img>` `src` and `/api/gallery` or `/api/creation-history` response first. The console line/request can be caused by metadata probing rather than the preview image. Gallery and history detail should pass stored `width`/`height` to `ImageMetadataBadge` and set `loadMetadata={false}` so the badge does not trigger an original-image request just to calculate dimensions. |
|
||||
| Gallery shows many historical/imported works or thumbnail rules differ between all/category/search | `src/app/gallery/page.tsx`, `src/app/api/gallery/route.ts`, `src/lib/creation-history-store.ts` | The gallery page should render only `/api/gallery` rows, not merge browser localStorage published/history records and not call `syncPublishedToSupabase()` on load. `/api/gallery` should apply `category`/`q` filters server-side against public works with stable `/api/local-storage/...` result URLs so all, text-to-image, image-to-image, text-to-video, image-to-video, and search share the same thumbnail/original split. |
|
||||
| Generated image preview zooms but cannot be dragged | `src/components/lightbox.tsx`, `src/components/fullscreen-preview.tsx` | Result-card previews use `ImageLightbox`; after zooming above 100%, panning should be bound to the whole preview stage as well as the image so mouse drag remains available even when the transformed image extends beyond its original element box. Keep wheel zoom, double-click zoom/reset, right-click actions, and ESC close intact. |
|
||||
|
||||
@@ -74,7 +74,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
|
||||
| ETA/progress | `src/lib/generation-job-estimates.ts` | Runtime schema, ETA samples, progress payload. |
|
||||
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering and `dimensions` maps each original URL to persisted width/height so history detail metadata can avoid loading originals. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. |
|
||||
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. Video create panels must use backend returned `creditsCost`/`creditsBalance` after job success; they should not locally predict or deduct credits. |
|
||||
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. Generated video data URLs and upstream video URLs are persisted through `localStorage.uploadFileObjectOnly(...)` under `generated/videos`, so production video originals live in object storage when configured. Video create panels must use backend returned `creditsCost`/`creditsBalance` after job success; they should not locally predict or deduct credits. |
|
||||
| Custom API transport | `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts` | Headers, one retry for 502/503/504 gateway failures, progress JSON parsing, upstream error parsing, stream-to-sync fallback policy for system image APIs. |
|
||||
| Server API resolution | `src/lib/server-api-config.ts`, `src/lib/yuanjie-system-manifest.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). For known 元界 system rows missing `manifest_path`, the resolver can write the built-in Manifest and normalize `api_url` to the 元界 base URL before generation. The upstream `model_name` remains the per-provider request model only. |
|
||||
| User API smart import | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts`, `src/lib/model-display.ts` | The profile API settings page has an `智能配置 API` button next to `添加 API 密钥`. It opens a wide viewport-capped Manifest editor, can copy the LLM prompt, shows guidance under the prompt button explaining the copy-to-chat-AI and paste-and-import flow, can paste clipboard JSON without importing, and can paste-and-import in one action. The prompt instructs the LLM to stop and ask the user for the relay API Base URL when the docs do not contain it. Imports create each profile/model as an independent `user_api_keys` row plus a separate `user-api-manifests/<userId>/<keyId>.json` file and reject incomplete configs without a resolvable request URL. Imported rows should store a human-readable provider name in the editable provider/supplier fields and resolve the visible API request URL from `profile.baseUrl + submit.path` for synchronous endpoints. Generic placeholder notes such as `导入的 API Key` must not be used as model labels; creation/profile UI should prefer a real note plus model, or provider plus model. Optional `profile.capabilities` filters or hides create-page aspect ratio, resolution, image format, and quality controls for the selected model. Polling Manifest query values can include `{task_id}` so task IDs are sent as real query parameters rather than being embedded into pathname strings. Generation routes must use the selected model key's `manifest_path`; do not merge different request configs under one user-level file. |
|
||||
@@ -99,7 +99,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| --- | --- |
|
||||
| Profile page | `src/app/profile/page.tsx` |
|
||||
| Profile API | `src/app/api/profile/route.ts`, `src/app/api/profile/theme/route.ts`, `src/lib/user-profile-defaults.ts` |
|
||||
| Creation history tab | `src/components/profile/creation-history-tab.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts` |
|
||||
| Creation history tab | `src/components/profile/creation-history-tab.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts` | User-private completed works. History storage and the API de-duplicate repeated rows by result URL so a single generated video does not appear twice after the local optimistic record is replaced by the server row. Video records without a thumbnail receive a local SVG thumbnail under `thumbnails/works/videos` for fast list/detail preview. |
|
||||
| Credits tab/store | `src/components/profile/credits-tab.tsx`, `src/lib/credit-records-store.ts`, `src/app/api/credit-transactions/route.ts`, `src/app/api/redeem-codes/redeem/route.ts`, `src/app/api/invitations/me/route.ts`, `src/lib/invitation-service.ts` | The credits tab includes redeem-code input, a `获取兑换码` button, and a per-user invite link. The get-code and recharge buttons open `site_config.redeem_code_mall_url` from `/api/site-config` when configured. Successful redemption calls the server transaction route, updates either `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes, refreshes the auth profile, and then reloads server credit records. Invite links use `profiles.invite_code`; registrations through `/auth/register?invite=...` create an `invitation_referrals` row and award 50 credits to both inviter and invitee. |
|
||||
| Orders tab/store | `src/components/profile/orders-tab.tsx`, `src/lib/order-store.ts`, `src/app/api/admin/orders/route.ts` |
|
||||
| Billing guard | `src/components/billing-plan-guard.tsx`, `src/lib/admin-store.ts` |
|
||||
@@ -108,9 +108,9 @@ 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`, `src/lib/gallery-cache-policy.ts` | Lists public works, search/sort/filter, preview/download, and one-click reuse. It requests `/api/gallery` in small pages instead of fetching the full gallery, uses a bounded `miaojing:gallery:v3` browser localStorage cache for instant reopen, revalidates page 0 in the background, debounces search, and uses an IntersectionObserver sentinel to append the next page only when the user scrolls near it. Cached rows remain usable for instant first paint until the 7-day prune window; page 0 is refreshed in the background for freshness, and a masonry skeleton replaces the old centered `加载中...` state when no cache exists. Image cards and detail display use `thumbnailUrl || url`, while fullscreen, download, copy/share, and reuse actions use original `url`. 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. Mobile gallery must keep at least two masonry columns; `masonryColumnCount` bottoms out at 2 and `.gallery-masonry-grid`/card CSS trims spacing and metadata density on phones. |
|
||||
| Public gallery page | `src/app/gallery/page.tsx`, `src/app/globals.css`, `src/lib/gallery-cache-policy.ts` | Lists public works, search/sort/filter, preview/download, and one-click reuse. It requests `/api/gallery` in small pages instead of fetching the full gallery, uses a bounded `miaojing:gallery:v3` browser localStorage cache for instant reopen, revalidates page 0 in the background, debounces search, and uses an IntersectionObserver sentinel to append the next page only when the user scrolls near it. Cached rows remain usable for instant first paint until the 7-day prune window; page 0 is refreshed in the background for freshness, and a masonry skeleton replaces the old centered `加载中...` state when no cache exists. Image cards and detail display use `thumbnailUrl || url`, while fullscreen, download, copy/share, and reuse actions use original `url`. Video gallery cards and detail surfaces render `thumbnailUrl` or a lightweight generated SVG fallback first; the detail overlay only mounts `<video src=original>` after the user clicks play, so object-storage originals are not fetched during list browsing or detail open. Video detail/download labels must say `下载视频`, not `下载图片`. 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. Mobile gallery must keep at least two masonry columns; `masonryColumnCount` bottoms out at 2 and `.gallery-masonry-grid`/card CSS trims spacing and metadata density on phones. |
|
||||
| Public gallery API | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | GET public works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`, queues missing or old-profile image thumbnails for background backfill without delaying the response, admin DELETE unpublishes. Gallery author names use `profiles.display_nickname` first and never expose login username unless no display nickname exists. Public list serialization filters `data:` and oversized `publisherAvatarUrl` values so generated default avatars do not bloat the gallery JSON payload or localStorage cache. |
|
||||
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies image originals into object-backed gallery folders, stores local thumbnails, and inserts public work. |
|
||||
| Publish API | `src/app/api/gallery/publish/route.ts` | Copies image originals into object-backed gallery folders, stores local thumbnails, and inserts public work. Object-backed video URLs under `/api/local-storage/...` are reused instead of synchronously copying large video files during share; missing video thumbnails are generated as local SVG previews under `thumbnails/gallery/videos`. |
|
||||
| Admin gallery prompt moderation | `src/components/admin/gallery-management-tab.tsx`, `src/app/api/admin/gallery/works/route.ts`, `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts`, `src/lib/admin-gallery-works-pagination.ts`, `scripts/test-admin-gallery-prompt-service.mjs` | Console-only workflow for editing public gallery `works.prompt`. The management table uses page/pageSize pagination while the list API keeps limit/offset compatibility. Admins must send an email notification to the author; the service sends email before updating the prompt and logs metadata without storing full prompt text. |
|
||||
| History persistence | `src/app/api/creation-history/route.ts`, `src/lib/creation-history-store.ts` | User-private completed works, `thumbnailUrl`, stored width/height, and published state. Missing image thumbnails are queued for background backfill instead of blocking the history response. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
|
||||
|
||||
@@ -140,7 +140,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Storage adapter | `src/lib/local-storage.ts` | Uses stable `/api/local-storage/<key>` URLs while the backend can be `STORAGE_MODE=local`, `dual`, or `object`. Object mode uses S3-compatible `OBJECT_STORAGE_*` config; dual mode writes local disk first and mirrors to object storage for safe migration. |
|
||||
| Rainyun ROS object storage preparation | `scripts/rainyun-ros-prepare.mjs` | Uses the Rainyun control-plane API `POST /product/ros/bucket` to create a bucket from `RAINYUN_ROS_BUCKET_NAME` and `RAINYUN_ROS_INSTANCE_ID`, then writes a private `.env.rainyun-object.generated` file containing standard `OBJECT_STORAGE_*` variables. Do not use this control-plane API for runtime media reads/writes; runtime storage remains S3-compatible through `src/lib/local-storage.ts`. |
|
||||
| Local/object file API | `src/app/api/local-storage/[...path]/route.ts`, `src/proxy.ts` | Serves storage objects by key without changing existing frontend URLs. Thumbnail keys under `thumbnails/...` are read from local disk and use long immutable browser cache headers because the filename contains the thumbnail profile; `src/proxy.ts` must preserve those cache headers instead of applying global `/api` no-store. Originals redirect to short-lived object-storage signed URLs when configured. |
|
||||
| Download proxy | `src/app/api/download/route.ts` | Supports remote URL, same-origin URL, and `/api/local-storage/*`. |
|
||||
| Download proxy | `src/app/api/download/route.ts` | Supports remote URL, same-origin URL, and `/api/local-storage/*`. For object-backed local-storage files, it redirects to a short-lived signed object URL with content-disposition instead of buffering large videos through Next.js; frontend video buttons use `triggerDownloadFile(...)` so the browser starts the download immediately. |
|
||||
| Remote fetch guard | `src/lib/remote-fetch.ts` | Use for server-side external fetches. It blocks private/local network targets, sends browser-like public-resource headers by default, and exposes `fetchPublicHttpUrlWithRetry` for generated image/result URL downloads that may transiently return 403, 429, 5xx, or timeout. |
|
||||
|
||||
## Database And Persistence
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"test:custom-image-fallback": "tsx ./scripts/test-custom-image-fallback.mjs",
|
||||
"test:generation-credit-policy": "tsx ./scripts/test-generation-credit-policy.mjs",
|
||||
"test:creation-thumbnail-policy": "tsx ./scripts/test-creation-thumbnail-policy.mjs",
|
||||
"test:video-object-storage-actions": "tsx ./scripts/test-video-object-storage-actions.mjs",
|
||||
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
|
||||
"test:yuanjie-media-manifest-mapping": "tsx ./scripts/test-yuanjie-media-manifest-mapping.mjs",
|
||||
"test:yuanjie-image2-persistence": "tsx ./scripts/test-yuanjie-image2-persistence.mjs",
|
||||
|
||||
79
scripts/test-video-object-storage-actions.mjs
Normal file
79
scripts/test-video-object-storage-actions.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
await runTest('video generation persists generated videos as object-backed media under generated/videos', () => {
|
||||
const source = read('src/app/api/generate/video/route.ts');
|
||||
|
||||
assert.match(source, /uploadFileObjectOnly\(/);
|
||||
assert.match(source, /fileName:\s*`\$\{prefix\}\/\$\{suffix\}\.\$\{ext \|\| 'mp4'\}`/);
|
||||
assert.doesNotMatch(source, /uploadFromUrl\(\{\s*url,\s*timeout:\s*60000\s*\}\)/);
|
||||
});
|
||||
|
||||
await runTest('download route can redirect object-backed local-storage downloads without buffering full videos', () => {
|
||||
const source = read('src/app/api/download/route.ts');
|
||||
|
||||
assert.match(source, /objectFileExistsAsync\(key\)/);
|
||||
assert.match(source, /generateObjectReadUrl\(key,\s*300,/);
|
||||
assert.match(source, /NextResponse\.redirect\(objectUrl,\s*302\)/);
|
||||
});
|
||||
|
||||
await runTest('video result download buttons trigger a streaming browser download instead of fetching a blob first', () => {
|
||||
const utilsSource = read('src/lib/utils.ts');
|
||||
const textVideoSource = read('src/components/create/text-to-video.tsx');
|
||||
const imageVideoSource = read('src/components/create/image-to-video.tsx');
|
||||
|
||||
assert.match(utilsSource, /export function triggerDownloadFile\(/);
|
||||
assert.match(utilsSource, /link\.href = proxyUrl/);
|
||||
assert.doesNotMatch(utilsSource, /triggerDownloadFile[\s\S]*?response\.blob\(\)/);
|
||||
assert.match(textVideoSource, /triggerDownloadFile\(url,/);
|
||||
assert.match(imageVideoSource, /triggerDownloadFile\(url,/);
|
||||
});
|
||||
|
||||
await runTest('gallery publish reuses object-backed video URLs instead of synchronously copying large videos', () => {
|
||||
const source = read('src/app/api/gallery/publish/route.ts');
|
||||
|
||||
assert.match(source, /type === 'video' && resultUrl\.startsWith\('\/api\/local-storage\/'\)/);
|
||||
assert.match(source, /galleryResultUrl = resultUrl/);
|
||||
assert.match(source, /type === 'video'[\s\S]*?copyPublicUrlToFolder\(resultUrl,\s*folder,\s*\{\s*storageTarget:\s*'object'\s*\}/);
|
||||
});
|
||||
|
||||
await runTest('gallery video cards and detail use thumbnails until the user starts playback', () => {
|
||||
const source = read('src/app/gallery/page.tsx');
|
||||
|
||||
assert.match(source, /isVideoWork\(work\)/);
|
||||
assert.match(source, /const mediaPreviewUrl = work\.thumbnailUrl \|\| \(isVideoWork\(work\) \? getVideoFallbackThumbnail\(work\) : ''\)/);
|
||||
assert.match(source, /isVideoWork\(selectedWork\)/);
|
||||
assert.match(source, /activeVideoWorkId !== selectedWork\.id/);
|
||||
assert.match(source, /setActiveVideoWorkId\(selectedWork\.id\)/);
|
||||
assert.match(source, /下载\{isVideoWork\(selectedWork\) \? '视频' : '图片'\}/);
|
||||
});
|
||||
|
||||
await runTest('creation history de-duplicates repeated video records by URL', () => {
|
||||
const storeSource = read('src/lib/creation-history-store.ts');
|
||||
const routeSource = read('src/app/api/creation-history/route.ts');
|
||||
|
||||
assert.match(storeSource, /function dedupeCreationRecordsByUrl\(/);
|
||||
assert.match(storeSource, /dedupeCreationRecordsByUrl\(records\.slice\(0, MAX_RECORDS\)\)/);
|
||||
assert.match(routeSource, /function dedupeRowsByResultUrl\(/);
|
||||
assert.match(routeSource, /dedupeRowsByResultUrl\(result\.rows\)/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
@@ -1,7 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { ensureLocalImageThumbnail, isCurrentLocalImageThumbnail } from '@/lib/media-storage';
|
||||
import {
|
||||
ensureLocalImageThumbnail,
|
||||
ensureLocalVideoThumbnail,
|
||||
isCurrentLocalImageThumbnail,
|
||||
isCurrentLocalVideoThumbnail,
|
||||
} from '@/lib/media-storage';
|
||||
|
||||
const workThumbnailQueue = new Map<string, Record<string, unknown>>();
|
||||
let workThumbnailProcessing = false;
|
||||
@@ -24,6 +29,10 @@ function fromWorkType(type: string): 'image' | 'video' | 'reverse-prompt' {
|
||||
return type.includes('video') ? 'video' : 'image';
|
||||
}
|
||||
|
||||
function isVideoWorkType(type: string): boolean {
|
||||
return type === 'text2video' || type === 'img2video' || type === 'video';
|
||||
}
|
||||
|
||||
function mapWork(row: Record<string, unknown>) {
|
||||
const params = (row.params || {}) as Record<string, unknown>;
|
||||
return {
|
||||
@@ -57,8 +66,21 @@ function getPositiveInteger(value: unknown): number | null {
|
||||
}
|
||||
|
||||
async function ensureWorkThumbnail(client: Awaited<ReturnType<typeof getDbClient>>, row: Record<string, unknown>) {
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || typeof row.result_url !== 'string') return row;
|
||||
const type = String(row.type || '');
|
||||
if (typeof row.result_url !== 'string') return row;
|
||||
if (isVideoWorkType(type)) {
|
||||
if (isCurrentLocalVideoThumbnail(row.thumbnail_url)) return row;
|
||||
try {
|
||||
const thumbnailUrl = await ensureLocalVideoThumbnail(row.result_url, 'thumbnails/works/videos', String(row.prompt || 'Video'));
|
||||
if (!thumbnailUrl) return row;
|
||||
await client.query('UPDATE works SET thumbnail_url = $1 WHERE id = $2', [thumbnailUrl, row.id]);
|
||||
return { ...row, thumbnail_url: thumbnailUrl };
|
||||
} catch (error) {
|
||||
console.warn('[creation-history] video thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url)) return row;
|
||||
if (type !== 'text2img' && type !== 'img2img') return row;
|
||||
try {
|
||||
const thumbnailUrl = await ensureLocalImageThumbnail(row.result_url, 'thumbnails/works');
|
||||
@@ -72,9 +94,13 @@ async function ensureWorkThumbnail(client: Awaited<ReturnType<typeof getDbClient
|
||||
}
|
||||
|
||||
function scheduleWorkThumbnail(row: Record<string, unknown>) {
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || typeof row.result_url !== 'string') return;
|
||||
const type = String(row.type || '');
|
||||
if (type !== 'text2img' && type !== 'img2img') return;
|
||||
if (typeof row.result_url !== 'string') return;
|
||||
if (isVideoWorkType(type)) {
|
||||
if (isCurrentLocalVideoThumbnail(row.thumbnail_url)) return;
|
||||
} else {
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || (type !== 'text2img' && type !== 'img2img')) return;
|
||||
}
|
||||
const id = String(row.id || row.result_url);
|
||||
workThumbnailQueue.set(id, row);
|
||||
if (workThumbnailProcessing) return;
|
||||
@@ -100,6 +126,20 @@ function scheduleWorkThumbnail(row: Record<string, unknown>) {
|
||||
})();
|
||||
}
|
||||
|
||||
function dedupeRowsByResultUrl(rows: Record<string, unknown>[]) {
|
||||
const seen = new Set<string>();
|
||||
const deduped: Record<string, unknown>[] = [];
|
||||
for (const row of rows) {
|
||||
const key = typeof row.result_url === 'string' && row.result_url.trim()
|
||||
? row.result_url
|
||||
: String(row.id || '');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(row);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
@@ -113,8 +153,8 @@ export async function GET(request: NextRequest) {
|
||||
LIMIT 300`,
|
||||
[userId],
|
||||
);
|
||||
for (const row of result.rows) scheduleWorkThumbnail(row);
|
||||
const rows = result.rows;
|
||||
const rows = dedupeRowsByResultUrl(result.rows);
|
||||
for (const row of rows) scheduleWorkThumbnail(row);
|
||||
return NextResponse.json({ records: rows.map(mapWork) });
|
||||
} finally {
|
||||
client.release();
|
||||
@@ -148,6 +188,13 @@ export async function POST(request: NextRequest) {
|
||||
url = url && !url.startsWith('data:') ? url : `[reverse-prompt:${record.id || Date.now()}]`;
|
||||
}
|
||||
if (!url || url.startsWith('data:')) continue;
|
||||
if (!thumbnailUrl && isVideoWorkType(workType)) {
|
||||
try {
|
||||
thumbnailUrl = await ensureLocalVideoThumbnail(url, 'thumbnails/works/videos', String(record.prompt || 'Video'));
|
||||
} catch (error) {
|
||||
console.warn('[creation-history] video thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
}
|
||||
if (!thumbnailUrl && (workType === 'text2img' || workType === 'img2img')) {
|
||||
try {
|
||||
thumbnailUrl = await ensureLocalImageThumbnail(url, 'thumbnails/works');
|
||||
|
||||
@@ -105,12 +105,25 @@ function resolveDownloadUrl(url: string, origin: string): string | null {
|
||||
}
|
||||
|
||||
async function downloadLocalStorageFile(key: string, filename: string, disposition: 'attachment' | 'inline') {
|
||||
const contentType = getContentType(key);
|
||||
const shouldTryObjectRedirect = contentType.startsWith('video/') || !localStorage.fileExists(key);
|
||||
if (shouldTryObjectRedirect && await localStorage.objectFileExistsAsync(key)) {
|
||||
const objectUrl = localStorage.generateObjectReadUrl(key, 300, {
|
||||
contentDisposition: buildContentDisposition(disposition, filename),
|
||||
contentType,
|
||||
});
|
||||
if (objectUrl) {
|
||||
const response = NextResponse.redirect(objectUrl, 302);
|
||||
response.headers.set('Cache-Control', disposition === 'inline' ? 'private, max-age=60' : 'no-cache');
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
if (!await localStorage.fileExistsAsync(key)) {
|
||||
return NextResponse.json({ error: '文件不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = await localStorage.readFileAsync(key);
|
||||
const contentType = getContentType(key);
|
||||
|
||||
return buildDownloadResponse(
|
||||
fileBuffer.buffer.slice(
|
||||
@@ -135,7 +148,7 @@ function buildDownloadResponse(
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `${disposition}; filename="${filename}"`,
|
||||
'Content-Disposition': buildContentDisposition(disposition, filename),
|
||||
'Content-Length': String(length),
|
||||
'Cache-Control': disposition === 'inline'
|
||||
? 'public, max-age=86400, stale-while-revalidate=604800'
|
||||
@@ -148,6 +161,10 @@ function sanitizeFilename(filename: string): string {
|
||||
return path.basename(filename).replace(/[\r\n"]/g, '_') || 'download';
|
||||
}
|
||||
|
||||
function buildContentDisposition(disposition: 'attachment' | 'inline', filename: string): string {
|
||||
return `${disposition}; filename="${filename}"`;
|
||||
}
|
||||
|
||||
function getContentType(filePath: string): string {
|
||||
const extension = filePath.split('.').pop()?.toLowerCase();
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
@@ -156,6 +173,7 @@ function getContentType(filePath: string): string {
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
mp4: 'video/mp4',
|
||||
avi: 'video/x-msvideo',
|
||||
mov: 'video/quicktime',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { ensureLocalImageThumbnail } from '@/lib/media-storage';
|
||||
import { ensureLocalImageThumbnail, ensureLocalVideoThumbnail } from '@/lib/media-storage';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -60,12 +60,19 @@ export async function POST(request: NextRequest) {
|
||||
let galleryThumbnailUrl = thumbnailUrl || null;
|
||||
try {
|
||||
const folder = type === 'video' ? 'gallery/videos' : 'gallery/images';
|
||||
galleryResultUrl = await localStorage.copyPublicUrlToFolder(resultUrl, folder, { storageTarget: 'object' });
|
||||
if (type === 'video' && resultUrl.startsWith('/api/local-storage/')) {
|
||||
galleryResultUrl = resultUrl;
|
||||
} else {
|
||||
galleryResultUrl = await localStorage.copyPublicUrlToFolder(resultUrl, folder, { storageTarget: 'object' });
|
||||
}
|
||||
if (type === 'image') {
|
||||
galleryThumbnailUrl = await ensureLocalImageThumbnail(galleryResultUrl, 'thumbnails/gallery')
|
||||
|| galleryThumbnailUrl;
|
||||
} else if (thumbnailUrl) {
|
||||
} else if (type === 'video' && thumbnailUrl) {
|
||||
galleryThumbnailUrl = await localStorage.copyPublicUrlToFolder(thumbnailUrl, 'gallery/thumbnails', { storageTarget: 'local' });
|
||||
} else if (type === 'video') {
|
||||
galleryThumbnailUrl = await ensureLocalVideoThumbnail(galleryResultUrl, 'thumbnails/gallery/videos', String(prompt || 'Video'))
|
||||
|| galleryThumbnailUrl;
|
||||
}
|
||||
} catch (copyError) {
|
||||
console.warn('[gallery/publish] copy to gallery folder failed, using original URL:', copyError);
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { ensureLocalImageThumbnail, isCurrentLocalImageThumbnail } from '@/lib/media-storage';
|
||||
import {
|
||||
ensureLocalImageThumbnail,
|
||||
ensureLocalVideoThumbnail,
|
||||
isCurrentLocalImageThumbnail,
|
||||
isCurrentLocalVideoThumbnail,
|
||||
} from '@/lib/media-storage';
|
||||
import { MAX_PUBLIC_GALLERY_AVATAR_URL_LENGTH, toPublicGalleryWork } from '@/lib/gallery-response';
|
||||
|
||||
const galleryThumbnailQueue = new Map<string, Record<string, unknown>>();
|
||||
let galleryThumbnailProcessing = false;
|
||||
|
||||
async function ensureGalleryThumbnail(client: Awaited<ReturnType<typeof getDbClient>>, row: Record<string, unknown>) {
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || typeof row.result_url !== 'string') return row;
|
||||
const type = String(row.type || '');
|
||||
if (typeof row.result_url !== 'string') return row;
|
||||
if (type === 'text2video' || type === 'img2video') {
|
||||
if (isCurrentLocalVideoThumbnail(row.thumbnail_url)) return row;
|
||||
try {
|
||||
const thumbnailUrl = await ensureLocalVideoThumbnail(row.result_url, 'thumbnails/gallery/videos', String(row.prompt || 'Video'));
|
||||
if (!thumbnailUrl) return row;
|
||||
await client.query('UPDATE works SET thumbnail_url = $1 WHERE id = $2', [thumbnailUrl, row.id]);
|
||||
return { ...row, thumbnail_url: thumbnailUrl };
|
||||
} catch (error) {
|
||||
console.warn('[gallery] video thumbnail generation failed:', error instanceof Error ? error.message : error);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url)) return row;
|
||||
if (type !== 'text2img' && type !== 'img2img') return row;
|
||||
try {
|
||||
const thumbnailUrl = await ensureLocalImageThumbnail(row.result_url, 'thumbnails/gallery');
|
||||
@@ -23,9 +41,13 @@ async function ensureGalleryThumbnail(client: Awaited<ReturnType<typeof getDbCli
|
||||
}
|
||||
|
||||
function scheduleGalleryThumbnail(row: Record<string, unknown>) {
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || typeof row.result_url !== 'string') return;
|
||||
const type = String(row.type || '');
|
||||
if (type !== 'text2img' && type !== 'img2img') return;
|
||||
if (typeof row.result_url !== 'string') return;
|
||||
if (type === 'text2video' || type === 'img2video') {
|
||||
if (isCurrentLocalVideoThumbnail(row.thumbnail_url)) return;
|
||||
} else {
|
||||
if (isCurrentLocalImageThumbnail(row.thumbnail_url) || (type !== 'text2img' && type !== 'img2img')) return;
|
||||
}
|
||||
const id = String(row.id || row.result_url);
|
||||
galleryThumbnailQueue.set(id, row);
|
||||
if (galleryThumbnailProcessing) return;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
imageBufferToDataUrl,
|
||||
} from '@/lib/server-image-compression';
|
||||
import { executeUserApiManifest } from '@/lib/user-api-manifest-executor';
|
||||
import { fetchPublicHttpUrlWithRetry } from '@/lib/remote-fetch';
|
||||
|
||||
interface CustomApiConfig {
|
||||
apiUrl: string;
|
||||
@@ -34,69 +35,25 @@ export const runtime = 'nodejs';
|
||||
async function persistMediaToStorage(dataUrl: string, prefix: string): Promise<string> {
|
||||
if (!dataUrl.startsWith('data:')) return dataUrl;
|
||||
|
||||
try {
|
||||
const match = dataUrl.match(/^data:((?:image|video)\/[^;]+);base64,(.+)$/);
|
||||
if (!match) return dataUrl;
|
||||
const [, mimeType, base64Data] = match;
|
||||
const ext = mimeType.split('/')[1] || 'mp4';
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
const fileName = `${prefix}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
|
||||
const fileKey = await withTimeout(
|
||||
localStorage.uploadFile({ fileContent: buffer, fileName, contentType: mimeType }),
|
||||
45_000,
|
||||
'Local uploadFile (video)',
|
||||
);
|
||||
|
||||
if (!fileKey) {
|
||||
console.error('[Persist Video Media] uploadFile returned empty key');
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
const presignedUrl = await withTimeout(
|
||||
localStorage.generatePresignedUrl({ key: fileKey, expireTime: 2592000 }),
|
||||
10_000,
|
||||
'Local generatePresignedUrl (video)',
|
||||
);
|
||||
|
||||
if (presignedUrl) {
|
||||
console.log('[Persist Video Media] Success, key:', fileKey, 'size:', buffer.length, 'bytes');
|
||||
return presignedUrl;
|
||||
}
|
||||
|
||||
return dataUrl;
|
||||
} catch (err) {
|
||||
console.error('[Persist Video Media Error]', err instanceof Error ? err.message : err);
|
||||
return dataUrl;
|
||||
}
|
||||
const match = dataUrl.match(/^data:((?:image|video)\/[^;]+);base64,(.+)$/);
|
||||
if (!match) throw new Error('Invalid generated video data URL');
|
||||
const [, mimeType, base64Data] = match;
|
||||
const ext = getVideoExtension(mimeType);
|
||||
return persistVideoBufferToObjectStorage(Buffer.from(base64Data, 'base64'), mimeType, ext, prefix);
|
||||
}
|
||||
|
||||
async function persistRemoteUrlToStorage(url: string, prefix: string): Promise<string> {
|
||||
if (!url.startsWith('http')) return url;
|
||||
|
||||
try {
|
||||
const fileKey = await withTimeout(
|
||||
localStorage.uploadFromUrl({ url, timeout: 60000 }),
|
||||
60_000,
|
||||
'Local uploadFromUrl (video)',
|
||||
);
|
||||
if (!fileKey) return url;
|
||||
|
||||
const presignedUrl = await withTimeout(
|
||||
localStorage.generatePresignedUrl({ key: fileKey, expireTime: 2592000 }),
|
||||
10_000,
|
||||
'Local generatePresignedUrl (video remote)',
|
||||
);
|
||||
|
||||
if (presignedUrl) {
|
||||
console.log('[Persist Remote Video URL] Success, key:', fileKey);
|
||||
return presignedUrl;
|
||||
}
|
||||
return url;
|
||||
} catch (err) {
|
||||
console.warn('[Persist Remote Video URL] Failed, using original URL:', err instanceof Error ? err.message : err);
|
||||
return url;
|
||||
}
|
||||
const response = await fetchPublicHttpUrlWithRetry(
|
||||
url,
|
||||
{ headers: { Accept: 'video/mp4,video/webm,video/quicktime,video/*,*/*;q=0.8' } },
|
||||
{ attempts: 3, retryDelayMs: 800, timeoutMs: 90_000 },
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to fetch generated video: ${response.status}`);
|
||||
const mimeType = response.headers.get('content-type')?.split(';')[0] || getVideoMimeType(url);
|
||||
const ext = getVideoExtension(mimeType, url);
|
||||
return persistVideoBufferToObjectStorage(Buffer.from(await response.arrayBuffer()), mimeType, ext, prefix);
|
||||
}
|
||||
|
||||
/** Helper: wrap a promise with a timeout that rejects with a descriptive message */
|
||||
@@ -113,26 +70,58 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
|
||||
async function persistAllMediaUrls(urls: string[], prefix: string): Promise<string[]> {
|
||||
const MAX_DATA_URL_SIZE = 10 * 1024 * 1024; // 10MB limit for video data URLs
|
||||
const results = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
if (url.startsWith('data:')) {
|
||||
const result = await persistMediaToStorage(url, prefix);
|
||||
if (result.startsWith('data:') && result.length > MAX_DATA_URL_SIZE) {
|
||||
console.warn('[Persist Video] Data URL too large (' + Math.round(result.length / 1024 / 1024) + 'MB), skipping');
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
uniqueStrings(urls).map(async (url) => {
|
||||
if (url.startsWith('data:')) {
|
||||
if (url.length > MAX_DATA_URL_SIZE) {
|
||||
throw new Error('Generated video data URL is too large to persist');
|
||||
}
|
||||
if (url.startsWith('http')) return persistRemoteUrlToStorage(url, prefix);
|
||||
return url;
|
||||
} catch (err) {
|
||||
console.error('[persistAllMediaUrls video] Error:', err instanceof Error ? err.message : err);
|
||||
if (url.startsWith('data:') && url.length > MAX_DATA_URL_SIZE) return null;
|
||||
return url;
|
||||
return persistMediaToStorage(url, prefix);
|
||||
}
|
||||
if (url.startsWith('http')) return persistRemoteUrlToStorage(url, prefix);
|
||||
if (url.startsWith('/api/local-storage/')) return url;
|
||||
throw new Error('Generated video did not return a persistable URL');
|
||||
}),
|
||||
);
|
||||
return results.filter((u): u is string => u !== null);
|
||||
return uniqueStrings(results);
|
||||
}
|
||||
|
||||
async function persistVideoBufferToObjectStorage(buffer: Buffer, mimeType: string, ext: string, prefix: string): Promise<string> {
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const fileKey = await withTimeout(
|
||||
localStorage.uploadFileObjectOnly({
|
||||
fileContent: buffer,
|
||||
fileName: `${prefix}/${suffix}.${ext || 'mp4'}`,
|
||||
contentType: mimeType || 'video/mp4',
|
||||
}),
|
||||
90_000,
|
||||
'Local uploadFileObjectOnly (video)',
|
||||
);
|
||||
const publicUrl = await withTimeout(
|
||||
localStorage.generatePresignedUrl({ key: fileKey, expireTime: 2592000 }),
|
||||
10_000,
|
||||
'Local generatePresignedUrl (video)',
|
||||
);
|
||||
console.log('[Persist Video Media] Success, key:', fileKey, 'size:', buffer.length, 'bytes');
|
||||
return publicUrl;
|
||||
}
|
||||
|
||||
function getVideoMimeType(url: string): string {
|
||||
const ext = getVideoExtension('', url);
|
||||
if (ext === 'webm') return 'video/webm';
|
||||
if (ext === 'mov') return 'video/quicktime';
|
||||
if (ext === 'avi') return 'video/x-msvideo';
|
||||
return 'video/mp4';
|
||||
}
|
||||
|
||||
function getVideoExtension(mimeType: string, url = ''): string {
|
||||
const normalizedMime = mimeType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalizedMime === 'video/webm') return 'webm';
|
||||
if (normalizedMime === 'video/quicktime') return 'mov';
|
||||
if (normalizedMime === 'video/x-msvideo') return 'avi';
|
||||
if (normalizedMime === 'video/mp4') return 'mp4';
|
||||
const match = url.split('?')[0]?.match(/\.([a-z0-9]+)$/i);
|
||||
const ext = match?.[1]?.toLowerCase();
|
||||
return ext && /^(mp4|webm|mov|avi|m4v)$/i.test(ext) ? ext : 'mp4';
|
||||
}
|
||||
|
||||
async function uploadDataUrlAndGetPublicUrl(dataUrl: string): Promise<string | null> {
|
||||
|
||||
@@ -70,6 +70,7 @@ function getContentType(filePath: string): string {
|
||||
'png': 'image/png',
|
||||
'webp': 'image/webp',
|
||||
'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml',
|
||||
'mp4': 'video/mp4',
|
||||
'avi': 'video/x-msvideo',
|
||||
'mov': 'video/quicktime',
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
Trash2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { copyTextToClipboard, downloadFile } from '@/lib/utils';
|
||||
import { copyTextToClipboard, downloadFile, triggerDownloadFile } from '@/lib/utils';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import { FullscreenPreview } from '@/components/fullscreen-preview';
|
||||
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
|
||||
@@ -241,6 +241,43 @@ function getCategoryLabel(work: GalleryWork): string {
|
||||
return cat?.label ?? work.type;
|
||||
}
|
||||
|
||||
function isVideoWork(work: GalleryWork): boolean {
|
||||
return work.type === 'video' || work.type === 'text2video' || work.type === 'img2video' || Boolean(work.duration);
|
||||
}
|
||||
|
||||
function getVideoFallbackThumbnail(work: GalleryWork): string {
|
||||
const label = getCategoryLabel(work) || '视频';
|
||||
const title = (work.prompt || label).slice(0, 42);
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#111827"/>
|
||||
<stop offset="52%" stop-color="#334155"/>
|
||||
<stop offset="100%" stop-color="#f59e0b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1280" height="720" fill="url(#bg)"/>
|
||||
<circle cx="640" cy="330" r="92" fill="#fff" fill-opacity="0.9"/>
|
||||
<path d="M612 280 L612 380 L700 330 Z" fill="#111827"/>
|
||||
<text x="640" y="516" text-anchor="middle" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#fff">${escapeSvgText(label)}</text>
|
||||
<text x="640" y="568" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" fill="#fff" opacity="0.72">${escapeSvgText(title)}</text>
|
||||
</svg>`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
function escapeSvgText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function getDownloadFilename(work: GalleryWork): string {
|
||||
return `miaojing-${work.id}.${isVideoWork(work) ? 'mp4' : 'png'}`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
@@ -591,6 +628,7 @@ export default function GalleryPage() {
|
||||
const [category, setCategory] = useState('all');
|
||||
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
|
||||
const [selectedWork, setSelectedWork] = useState<GalleryWork | null>(null);
|
||||
const [activeVideoWorkId, setActiveVideoWorkId] = useState<string | null>(null);
|
||||
const [fullscreenSrc, setFullscreenSrc] = useState<string | null>(null);
|
||||
const [fullscreenFallbackSrc, setFullscreenFallbackSrc] = useState<string | null>(null);
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'popular'>('newest');
|
||||
@@ -609,6 +647,10 @@ export default function GalleryPage() {
|
||||
setFullscreenSrc(src);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveVideoWorkId(null);
|
||||
}, [selectedWork?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateColumnCount = () => {
|
||||
const width = window.innerWidth;
|
||||
@@ -816,6 +858,11 @@ export default function GalleryPage() {
|
||||
|
||||
const handleDownload = async (url: string, filename: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
if (/\.(mp4|webm|mov|avi)(?:$|\?)/i.test(filename) || /\.(mp4|webm|mov|avi)(?:$|\?)/i.test(url)) {
|
||||
triggerDownloadFile(url, filename);
|
||||
toast.success('已开始下载');
|
||||
return;
|
||||
}
|
||||
const result = await downloadFile(url, filename);
|
||||
if (!result.ok) {
|
||||
window.open(url, '_blank');
|
||||
@@ -987,7 +1034,7 @@ export default function GalleryPage() {
|
||||
{masonryColumns.map((columnWorks, columnIndex) => (
|
||||
<div key={columnIndex} className="flex min-w-0 flex-col gap-4">
|
||||
{columnWorks.map((work, columnItemIndex) => {
|
||||
const mediaPreviewUrl = work.thumbnailUrl || '';
|
||||
const mediaPreviewUrl = work.thumbnailUrl || (isVideoWork(work) ? getVideoFallbackThumbnail(work) : '');
|
||||
const shouldLoadEagerly = columnItemIndex < 2;
|
||||
return (
|
||||
<div
|
||||
@@ -1009,8 +1056,15 @@ export default function GalleryPage() {
|
||||
loading={shouldLoadEagerly ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
onLoad={(e) => handleCardImageLoad(work.id, e)}
|
||||
onClick={(e) => { e.stopPropagation(); openFullscreenPreview(work.url, work.thumbnailUrl); }}
|
||||
onContextMenu={(e) => openImageMenu(e, work.url)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isVideoWork(work)) setSelectedWork(work);
|
||||
else openFullscreenPreview(work.url, work.thumbnailUrl);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
if (isVideoWork(work)) return;
|
||||
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">
|
||||
@@ -1030,7 +1084,7 @@ export default function GalleryPage() {
|
||||
{selectedGalleryIds.has(work.id) ? '✓' : ''}
|
||||
</button>
|
||||
)}
|
||||
{(work.type === 'video' || work.type === 'text2video' || work.type === 'img2video') && (
|
||||
{isVideoWork(work) && (
|
||||
<Badge className={`absolute left-2 z-20 ${isAdmin && apiWorkIds.has(work.id) ? 'top-11' : 'top-2'}`} variant="secondary">
|
||||
<Film className="h-3 w-3 mr-1" />视频
|
||||
</Badge>
|
||||
@@ -1061,7 +1115,7 @@ export default function GalleryPage() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="gallery-work-action-button pointer-events-auto h-9 w-9 p-0"
|
||||
onClick={(e) => handleDownload(work.url, `miaojing-${work.id}.png`, e)}
|
||||
onClick={(e) => handleDownload(work.url, getDownloadFilename(work), e)}
|
||||
title="下载"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
@@ -1139,7 +1193,7 @@ export default function GalleryPage() {
|
||||
{selectedWork.url && !selectedWork.url.startsWith('data:') && (
|
||||
<>
|
||||
<img
|
||||
src={selectedWork.thumbnailUrl || selectedWork.url}
|
||||
src={selectedWork.thumbnailUrl || (isVideoWork(selectedWork) ? getVideoFallbackThumbnail(selectedWork) : selectedWork.url)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full scale-125 object-cover opacity-48 blur-[5px]"
|
||||
@@ -1150,12 +1204,32 @@ export default function GalleryPage() {
|
||||
)}
|
||||
{/* Left: Image/Video */}
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center overflow-hidden bg-black/22 light:bg-white/12">
|
||||
{selectedWork.type === 'video' || selectedWork.type === 'text2video' || selectedWork.type === 'img2video' ? (
|
||||
<video
|
||||
src={selectedWork.url}
|
||||
controls
|
||||
className="relative z-10 h-full w-full object-contain"
|
||||
/>
|
||||
{isVideoWork(selectedWork) ? (
|
||||
activeVideoWorkId !== selectedWork.id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="relative z-10 flex h-full w-full items-center justify-center bg-black/28"
|
||||
onClick={() => setActiveVideoWorkId(selectedWork.id)}
|
||||
aria-label="播放视频"
|
||||
>
|
||||
<img
|
||||
src={selectedWork.thumbnailUrl || getVideoFallbackThumbnail(selectedWork)}
|
||||
alt={(selectedWork.prompt || '视频预览').slice(0, 30)}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
/>
|
||||
<span className="relative z-10 flex h-20 w-20 items-center justify-center rounded-full bg-white/90 text-slate-950 shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
|
||||
<Film className="h-8 w-8" />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<video
|
||||
src={selectedWork.url}
|
||||
poster={selectedWork.thumbnailUrl || getVideoFallbackThumbnail(selectedWork)}
|
||||
controls
|
||||
autoPlay
|
||||
className="relative z-10 h-full w-full object-contain"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<img
|
||||
src={selectedWork.thumbnailUrl || selectedWork.url}
|
||||
@@ -1165,7 +1239,7 @@ export default function GalleryPage() {
|
||||
onContextMenu={(event) => openImageMenu(event, selectedWork.url)}
|
||||
/>
|
||||
)}
|
||||
{selectedWork.type !== 'video' && selectedWork.type !== 'text2video' && selectedWork.type !== 'img2video' && (
|
||||
{!isVideoWork(selectedWork) && (
|
||||
<ImageMetadataBadge
|
||||
src={selectedWork.url}
|
||||
width={selectedWork.width}
|
||||
@@ -1175,7 +1249,7 @@ export default function GalleryPage() {
|
||||
/>
|
||||
)}
|
||||
{/* Fullscreen button overlay */}
|
||||
{selectedWork.type !== 'video' && selectedWork.type !== 'text2video' && selectedWork.type !== 'img2video' && (
|
||||
{!isVideoWork(selectedWork) && (
|
||||
<button
|
||||
onClick={() => openFullscreenPreview(selectedWork.url, selectedWork.thumbnailUrl)}
|
||||
className="absolute bottom-4 right-4 z-20 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white shadow-lg backdrop-blur-md transition-colors hover:bg-black/70"
|
||||
@@ -1386,10 +1460,10 @@ export default function GalleryPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 min-w-[112px] gap-1.5 px-3 text-sm font-semibold"
|
||||
onClick={() => handleDownload(selectedWork.url, `miaojing-${selectedWork.id}.png`)}
|
||||
onClick={() => handleDownload(selectedWork.url, getDownloadFilename(selectedWork))}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
下载图片
|
||||
下载{isVideoWork(selectedWork) ? '视频' : '图片'}
|
||||
</Button>
|
||||
{isAdmin && apiWorkIds.has(selectedWork.id) && (
|
||||
<Button
|
||||
|
||||
@@ -23,7 +23,7 @@ import { GroupedModelSelectItems } from '@/components/create/grouped-model-selec
|
||||
import { ensureSelectedOption, getVideoCapabilityOptions, keepSelectedOptionVisible } from '@/lib/model-capabilities';
|
||||
import { Sparkles, Loader2, Download, Upload, Wand2, Film, History, ChevronDown, ChevronUp, Plus, X, KeyRound, Share2 } from 'lucide-react';
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { downloadFile } from '@/lib/utils';
|
||||
import { triggerDownloadFile } from '@/lib/utils';
|
||||
import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
@@ -444,10 +444,10 @@ export function ImageToVideoPanel() {
|
||||
finally { removeActiveTask(taskId); }
|
||||
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, resolution, cameraMovement, refImages, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, updateProfile, removeActiveTask, updateActiveTask]);
|
||||
|
||||
const handleDownload = useCallback(async (url: string, index: number) => {
|
||||
const result = await downloadFile(url, `miaojing-img2vid-${Date.now()}-${index}.mp4`);
|
||||
if (!result.ok) toast.error(result.error || '下载失败');
|
||||
}, []);
|
||||
const handleDownload = useCallback(async (url: string, index: number) => {
|
||||
triggerDownloadFile(url, `miaojing-img2vid-${Date.now()}-${index}.mp4`);
|
||||
toast.success('已开始下载');
|
||||
}, []);
|
||||
|
||||
const handleShareToGallery = useCallback((url: string) => {
|
||||
if (isUrlPublished(url)) {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { GroupedModelSelectItems } from '@/components/create/grouped-model-selec
|
||||
import { ensureSelectedOption, getVideoCapabilityOptions, keepSelectedOptionVisible } from '@/lib/model-capabilities';
|
||||
import { Sparkles, Loader2, Download, Wand2, Video, Film, History, ChevronDown, ChevronUp, KeyRound, Share2, Plus } from 'lucide-react';
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { downloadFile } from '@/lib/utils';
|
||||
import { triggerDownloadFile } from '@/lib/utils';
|
||||
import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
@@ -331,8 +331,8 @@ export function TextToVideoPanel() {
|
||||
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, resolution, cameraMovement, style, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, updateProfile, removeActiveTask, updateActiveTask]);
|
||||
|
||||
const handleDownload = useCallback(async (url: string, index: number) => {
|
||||
const result = await downloadFile(url, `miaojing-video-${Date.now()}-${index}.mp4`);
|
||||
if (!result.ok) toast.error(result.error || '下载失败');
|
||||
triggerDownloadFile(url, `miaojing-video-${Date.now()}-${index}.mp4`);
|
||||
toast.success('已开始下载');
|
||||
}, []);
|
||||
|
||||
const handleShareToGallery = useCallback((url: string) => {
|
||||
|
||||
@@ -97,7 +97,7 @@ function loadRecords(): CreationRecord[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as CreationRecord[];
|
||||
return dedupeCreationRecordsByUrl(JSON.parse(raw) as CreationRecord[]);
|
||||
} catch {
|
||||
// If parsing fails, clear corrupted data
|
||||
try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ }
|
||||
@@ -105,9 +105,21 @@ function loadRecords(): CreationRecord[] {
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeCreationRecordsByUrl(records: CreationRecord[]): CreationRecord[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: CreationRecord[] = [];
|
||||
for (const record of records) {
|
||||
const key = record.url && !record.url.startsWith('[') ? record.url : record.id;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(record);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function saveRecords(records: CreationRecord[], notify = true): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const trimmed = records.slice(0, MAX_RECORDS);
|
||||
const trimmed = dedupeCreationRecordsByUrl(records.slice(0, MAX_RECORDS));
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed));
|
||||
} catch {
|
||||
@@ -206,6 +218,18 @@ export function addCreationRecord(record: Omit<CreationRecord, 'id' | 'createdAt
|
||||
id: `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const existingIndex = records.findIndex(item => item.url === newRecord.url && !isPlaceholder(item.url));
|
||||
if (existingIndex !== -1) {
|
||||
records[existingIndex] = {
|
||||
...records[existingIndex],
|
||||
...newRecord,
|
||||
id: records[existingIndex].id,
|
||||
createdAt: records[existingIndex].createdAt,
|
||||
published: records[existingIndex].published || newRecord.published,
|
||||
};
|
||||
saveRecords(records);
|
||||
return records[existingIndex];
|
||||
}
|
||||
records.unshift(newRecord);
|
||||
|
||||
// Enforce count limit
|
||||
|
||||
@@ -350,7 +350,15 @@ class LocalStorage {
|
||||
return `/api/local-storage/${this.normalizeKey(key)}`;
|
||||
}
|
||||
|
||||
generateObjectReadUrl(key: string, expiresInSeconds = 300): string | null {
|
||||
async objectFileExistsAsync(key: string): Promise<boolean> {
|
||||
return this.objectExists(this.normalizeKey(key));
|
||||
}
|
||||
|
||||
generateObjectReadUrl(
|
||||
key: string,
|
||||
expiresInSeconds = 300,
|
||||
responseHeaders: { contentDisposition?: string; contentType?: string } = {},
|
||||
): string | null {
|
||||
if (!this.objectConfig?.endpoint || !this.objectConfig.accessKeyId || !this.objectConfig.secretAccessKey) return null;
|
||||
const normalized = this.normalizeKey(key);
|
||||
const endpoint = new URL(this.objectConfig.endpoint);
|
||||
@@ -372,6 +380,12 @@ class LocalStorage {
|
||||
'X-Amz-Expires': String(Math.max(1, Math.min(604800, Math.floor(expiresInSeconds)))),
|
||||
'X-Amz-SignedHeaders': 'host',
|
||||
};
|
||||
if (responseHeaders.contentDisposition) {
|
||||
params['response-content-disposition'] = responseHeaders.contentDisposition;
|
||||
}
|
||||
if (responseHeaders.contentType) {
|
||||
params['response-content-type'] = responseHeaders.contentType;
|
||||
}
|
||||
const canonicalQuery = Object.keys(params)
|
||||
.sort()
|
||||
.map(param => `${encodeQueryValue(param)}=${encodeQueryValue(params[param])}`)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fetchPublicHttpUrl, fetchPublicHttpUrlWithRetry } from '@/lib/remote-fe
|
||||
const THUMBNAIL_MAX_EDGE = Number(process.env.IMAGE_THUMBNAIL_MAX_EDGE || 1280);
|
||||
const THUMBNAIL_WEBP_QUALITY = Number(process.env.IMAGE_THUMBNAIL_WEBP_QUALITY || 86);
|
||||
const THUMBNAIL_PROFILE = `m${THUMBNAIL_MAX_EDGE}q${THUMBNAIL_WEBP_QUALITY}`;
|
||||
const VIDEO_THUMBNAIL_PROFILE = 'video-svg-v1';
|
||||
|
||||
export type PersistedImageMedia = {
|
||||
url: string;
|
||||
@@ -142,6 +143,37 @@ export function isCurrentLocalImageThumbnail(url: unknown): boolean {
|
||||
&& url.includes(`-${THUMBNAIL_PROFILE}.webp`);
|
||||
}
|
||||
|
||||
export async function ensureLocalVideoThumbnail(
|
||||
url: string,
|
||||
thumbnailPrefix = 'thumbnails/videos',
|
||||
label = 'Video',
|
||||
): Promise<string | null> {
|
||||
if (!url || url.startsWith('data:') || url.startsWith('[')) return null;
|
||||
const sourceKey = localStorage.getKeyFromPublicUrl(url) || url;
|
||||
const hash = crypto.createHash('sha256')
|
||||
.update(sourceKey)
|
||||
.update(label)
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
const key = `${thumbnailPrefix}/${hash}-${VIDEO_THUMBNAIL_PROFILE}.svg`;
|
||||
if (localStorage.localFileExistsOnly(key)) {
|
||||
return localStorage.generatePresignedUrl({ key, expireTime: 2592000 });
|
||||
}
|
||||
const svg = buildVideoThumbnailSvg(label);
|
||||
const savedKey = await localStorage.uploadFileLocalOnly({
|
||||
fileContent: Buffer.from(svg, 'utf8'),
|
||||
fileName: key,
|
||||
contentType: 'image/svg+xml',
|
||||
});
|
||||
return localStorage.generatePresignedUrl({ key: savedKey, expireTime: 2592000 });
|
||||
}
|
||||
|
||||
export function isCurrentLocalVideoThumbnail(url: unknown): boolean {
|
||||
return typeof url === 'string'
|
||||
&& url.includes('/api/local-storage/thumbnails/')
|
||||
&& url.includes(`-${VIDEO_THUMBNAIL_PROFILE}.svg`);
|
||||
}
|
||||
|
||||
async function createLocalImageThumbnail(input: {
|
||||
buffer: Buffer;
|
||||
sourceKey: string;
|
||||
@@ -189,3 +221,35 @@ function getImageMimeType(key: string): string {
|
||||
if (ext === 'gif') return 'image/gif';
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
function buildVideoThumbnailSvg(label: string): string {
|
||||
const safeLabel = escapeXml(label.trim().slice(0, 56) || 'Video');
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#111827"/>
|
||||
<stop offset="48%" stop-color="#334155"/>
|
||||
<stop offset="100%" stop-color="#f59e0b"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="50%" cy="44%" r="45%">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.28"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="1280" height="720" fill="url(#bg)"/>
|
||||
<rect width="1280" height="720" fill="url(#glow)"/>
|
||||
<circle cx="640" cy="330" r="96" fill="#ffffff" fill-opacity="0.90"/>
|
||||
<path d="M612 278 L612 382 L700 330 Z" fill="#111827"/>
|
||||
<text x="640" y="516" text-anchor="middle" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#ffffff">${safeLabel}</text>
|
||||
<text x="640" y="568" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" fill="#ffffff" opacity="0.72">MiaoJing Video</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ export async function downloadFile(
|
||||
filename: string,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
try {
|
||||
const proxyUrl = `/api/download?url=${encodeURIComponent(url)}&filename=${encodeURIComponent(filename)}`;
|
||||
const proxyUrl = getDownloadProxyUrl(url, filename);
|
||||
const response = await fetch(proxyUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -182,6 +182,21 @@ export async function downloadFile(
|
||||
}
|
||||
}
|
||||
|
||||
export function getDownloadProxyUrl(url: string, filename: string): string {
|
||||
return `/api/download?url=${encodeURIComponent(url)}&filename=${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
export function triggerDownloadFile(url: string, filename: string): void {
|
||||
const proxyUrl = getDownloadProxyUrl(url, filename);
|
||||
const link = document.createElement('a');
|
||||
link.href = proxyUrl;
|
||||
link.download = filename;
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse a fetch Response as JSON.
|
||||
* Handles empty bodies, HTML error pages, and non-JSON responses gracefully.
|
||||
|
||||
Reference in New Issue
Block a user