fix: sync generation persistence and multimodal flow
This commit is contained in:
@@ -58,7 +58,7 @@ Use this table before searching.
|
||||
| User auth/login/register/profile | `src/lib/session-auth.ts`, `src/lib/auth-store.ts` | `src/app/api/auth/*`, `src/app/api/profile/*` |
|
||||
| Admin console | `src/app/console/page.tsx`, `src/app/console/dashboard/page.tsx`, `src/modules/console/pages/*` | `src/components/admin/*`, `src/app/api/admin/*` |
|
||||
| Canvas (legacy, disabled in UI) | `src/app/canvas/page.tsx`, `src/components/canvas/infinite-canvas-workspace.tsx`, `src/components/canvas/react-flow-canvas.tsx` | `/canvas` intentionally returns 404 and navbar must not show `画布`; legacy source/API files remain only for future cleanup or explicit re-enable work. |
|
||||
| Gallery and creation history | `src/app/gallery/page.tsx`, `src/app/profile/page.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/components/image-metadata-badge.tsx` | `src/lib/creation-history-store.ts`, `src/lib/media-storage.ts`, `src/app/api/gallery/*`, `src/app/api/creation-history/route.ts`. Gallery is server-authoritative: do not merge browser localStorage published/history records into the public gallery feed and do not auto-sync historical local published records on gallery page load. The gallery page must not request the full gallery at once; it uses small `/api/gallery` pages, browser-visible lazy image loading, and an IntersectionObserver sentinel to append more works as the user scrolls. It keeps a bounded browser localStorage list cache for instant first paint, then revalidates page 0 in the background so new/deleted works replace cached rows quickly. Gallery/detail/history image previews show actual ratio and natural resolution in the upper-right badge and should render `thumbnailUrl || url`; fullscreen, download, copy, edit, share, and reuse actions keep using original `url`. Current thumbnails use the `m1280q86` WEBP profile, balancing smaller gallery payloads with clear detail previews, and fullscreen components should show thumbnail fallback while original object-storage images load. History also refreshes on `miaojing_auth_updated` after login/account switch. |
|
||||
| Gallery and creation history | `src/app/gallery/page.tsx`, `src/app/profile/page.tsx`, `src/components/profile/creation-history-tab.tsx`, `src/components/image-metadata-badge.tsx` | `src/lib/creation-history-store.ts`, `src/lib/media-storage.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/gallery/*`, `src/app/api/creation-history/route.ts`. Gallery is server-authoritative: do not merge browser localStorage published/history records into the public gallery feed and do not auto-sync historical local published records on gallery page load. The gallery page must not request the full gallery at once; it uses small `/api/gallery` pages, browser-visible lazy image loading, and an IntersectionObserver sentinel to append more works as the user scrolls. It keeps a bounded browser localStorage list cache for instant first paint, then revalidates page 0 in the background so new/deleted works replace cached rows quickly. Gallery/detail/history image previews show actual ratio and natural resolution in the upper-right badge and should render `thumbnailUrl || url`; fullscreen, download, copy, edit, share, and reuse actions keep using original `url`. Current thumbnails use the `m1280q86` WEBP profile, balancing smaller gallery payloads with clear detail previews, and fullscreen components should show thumbnail fallback while original object-storage images load. `/api/gallery/publish` must reuse stable `/api/local-storage/...` generated image/video originals instead of synchronously copying object-backed media during share; external URLs still copy into gallery storage before insertion. History also refreshes on `miaojing_auth_updated` after login/account switch. |
|
||||
| Local/object files/downloads | `src/lib/local-storage.ts`, `src/lib/media-storage.ts`, `src/app/api/local-storage/[...path]/route.ts` | `src/app/api/download/route.ts`, `src/proxy.ts`, `scripts/storage-sync-to-object.mjs`, `scripts/rainyun-ros-prepare.mjs`. Public URLs stay `/api/local-storage/<key>` while the backend can be `STORAGE_MODE=local`, `dual`, or `object`; new image originals can be written object-only, while compressed high-quality WEBP thumbnails are local-only under `thumbnails/...` and must be served from local disk directly. Thumbnail filenames include the resize/quality profile and can be served with long immutable browser cache headers; `src/proxy.ts` must not override thumbnail or gallery cache headers with global `/api` no-store. Object-backed originals should redirect to short-lived signed object-storage URLs. When syncing production source, exclude only repo-root `/local-storage/`, not broad `local-storage/`, or this source route can be skipped. Rainyun ROS API is a control-plane helper for bucket creation/config generation; runtime file IO still uses S3-compatible `OBJECT_STORAGE_*`. |
|
||||
| Email and policy pages | `src/lib/email-service.ts`, `src/components/site-policy-page.tsx` | `src/app/api/email/*`, `src/app/about/page.tsx`, `src/app/terms/page.tsx`, `src/app/privacy/page.tsx`, `src/app/help/page.tsx` |
|
||||
| Upgrade/deploy/backup | `scripts/*`, `ecosystem.config.cjs` | `src/app/api/admin/upgrade/route.ts`, `src/components/admin/system-upgrade-tab.tsx` |
|
||||
|
||||
@@ -77,8 +77,8 @@ All email sends route through `src/lib/email-service.ts`, which renders HTML and
|
||||
|
||||
| Method | Path | Auth | Source | Request | Response/Side Effects |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video", payload: {...} }` | Inserts `generation_jobs`, starts worker, increments selected image `styleLabel` usage, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. System-default image/video jobs preflight the selected `system_api_configs` price plus existing queued/running system-default jobs for the same user and return `402` when the available balance is insufficient. |
|
||||
| GET | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID | Job status/result/error/progress. Owner or admin only. |
|
||||
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video"|"reverse-prompt", payload: {...} }` | Inserts `generation_jobs`, starts worker, increments selected image `styleLabel` usage, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. System-default image/video jobs preflight the selected `system_api_configs` price plus existing queued/running system-default jobs for the same user and return `402` when the available balance is insufficient. Reverse-prompt now runs through the same job queue but does not deduct credits. |
|
||||
| GET | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID | Job status/result/error/progress. Owner or admin only. The create pages use this endpoint to resume jobs after refresh, auth change, or a new tab. |
|
||||
|
||||
## Admin Invitation Routes
|
||||
|
||||
@@ -87,8 +87,8 @@ 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 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`. |
|
||||
| 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. When the input image is a data URL, the route persists it under `reverse-prompt/reference-images/...` and sends the public `/api/local-storage/...` URL upstream when available so the multimodal model sees a normal fetchable image URL instead of a raw upload blob. This route sends a multimodal `chat/completions` payload with `image_url`, so 524 errors here reflect multimodal upstream latency/capability rather than image-generation sync behavior. |
|
||||
| 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`. This route also uses a multimodal `chat/completions` path, so 524 should be interpreted as a multimodal upstream timeout. |
|
||||
|
||||
Important generation helpers:
|
||||
|
||||
@@ -106,12 +106,12 @@ Important generation helpers:
|
||||
|
||||
| Method | Path | Auth | Source | Request | Response/Side Effects |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | None | Latest 300 completed user works as `records`, including optional `thumbnailUrl`, `width`, and `height`; missing image thumbnails and stale video thumbnails are lazily generated into local `thumbnails/works`. Video thumbnails prefer `ffmpeg-static` WEBP frame extraction and fall back to SVG only if extraction fails. |
|
||||
| POST | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Single record or `{ records: [...] }`; image records may include `thumbnailUrl`, `width`, and `height` | Inserts/deduplicates completed works into `works`, storing `thumbnail_url` and dimensions when supplied or generating thumbnails for image works and video works without thumbnails. |
|
||||
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | None | Latest 300 completed user works as `records`, including optional `thumbnailUrl`, `width`, `height`, `published`, and `publishedAt`; missing image thumbnails and stale video thumbnails are lazily generated into local `thumbnails/works`. Video thumbnails prefer `ffmpeg-static` WEBP frame extraction and fall back to SVG only if extraction fails. |
|
||||
| POST | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Single record or `{ records: [...] }`; image records may include `thumbnailUrl`, `width`, and `height` | Inserts/deduplicates completed works into `works`, storing `thumbnail_url` and dimensions when supplied or generating thumbnails for image works and video works without thumbnails. Imported/local records are only inserted as public when both `published` and `publishedAt` are present, so stale local published flags do not create or block gallery state. |
|
||||
| DELETE | `/api/creation-history?id=...` | User | `src/app/api/creation-history/route.ts` | Optional `id`; omit to delete all user history | Deletes user's private history rows by `id` and `user_id`. Creation detail deletion waits for this server delete before refreshing local history. |
|
||||
| GET | `/api/gallery` | Public | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | Query `type=image|video`, `category=text2img|img2img|text2video|img2video`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`; missing public image thumbnails and stale video thumbnails are lazily generated into local `thumbnails/gallery`. Video thumbnails prefer `ffmpeg-static` WEBP frame extraction and fall back to SVG only if extraction fails. Public list rows filter `data:` and oversized `publisherAvatarUrl` values to keep responses and browser caches small. Responses allow short private browser caching while the gallery page also keeps a bounded localStorage cache for instant first paint. |
|
||||
| GET | `/api/gallery` | Public | `src/app/api/gallery/route.ts`, `src/lib/gallery-response.ts` | Query `type=image|video`, `category=text2img|img2img|text2video|img2video`, `limit`, `offset`, `sort=newest|popular`, `q`/`search` | Public completed works with `thumbnailUrl`, `total`, `nextOffset`, and `hasMore`; missing public image thumbnails and stale video thumbnails are lazily generated into local `thumbnails/gallery`. Video thumbnails prefer `ffmpeg-static` WEBP frame extraction and fall back to SVG only if extraction fails; SVG fallback profiles such as `video-svg-v1` and `video-fallback-svg-v2` stay replaceable and do not count as current. Public list rows filter `data:` and oversized `publisherAvatarUrl` values to keep responses and browser caches small. Responses allow short private browser caching while the gallery page also keeps a bounded localStorage cache for instant first paint. |
|
||||
| DELETE | `/api/gallery` | Admin | `src/app/api/gallery/route.ts` | Query `id` or body `{ ids: [...] }` | Unpublishes up to 100 works by setting `is_public=false`. |
|
||||
| POST | `/api/gallery/publish` | User | `src/app/api/gallery/publish/route.ts` | Work metadata, `resultUrl`, optional thumbnail/reference/model fields | Copies image originals to object-backed gallery storage, reuses object-backed video URLs, ensures local gallery thumbnails, and inserts public completed work. Video publish thumbnails prefer WEBP frame extraction through `ffmpeg-static`, with SVG fallback only on failure. |
|
||||
| POST | `/api/gallery/publish` | User | `src/app/api/gallery/publish/route.ts`, `src/lib/gallery-publish-media.ts` | Work metadata, `resultUrl`, optional thumbnail/reference/model fields | Reuses stable `/api/local-storage/...` image and video originals instead of synchronously copying object-backed generated media during share. External image/video URLs are still copied into object-backed gallery storage before insertion. Existing image thumbnails are reused; gallery/history reads can lazily backfill missing or stale thumbnails. Video publish thumbnails prefer WEBP frame extraction through `ffmpeg-static`; a client-provided thumbnail is copied only after frame extraction fails. If media preparation fails, the route returns an error instead of inserting a public row that `/api/gallery` will filter out. Clients should show success and mark local history as shared only after this route returns 2xx. |
|
||||
| GET | `/api/admin/gallery/works` | Admin | `src/app/api/admin/gallery/works/route.ts` | Query `q`, `type=all|image|video|text2img|img2img|text2video|img2video`, `page`, `pageSize`, legacy `limit`, `offset`, `sort` | Admin gallery-management list of public completed works with author email/nickname, prompt, media URL, thumbnail, `total`, `page`, `pageSize`, `totalPages`, legacy `nextOffset`, and `hasMore`. |
|
||||
| PUT | `/api/admin/gallery/prompt` | Admin | `src/app/api/admin/gallery/prompt/route.ts`, `src/lib/admin-gallery-prompt-service.ts` | `{ workId, prompt, emailSubject, emailBody, reasonKey }` | Sends the author notification email first, then updates `works.prompt` only after email success, and writes a platform log without storing full prompt text. Missing/invalid author email, unchanged prompt, non-public work, or email failure blocks the update. |
|
||||
|
||||
@@ -181,6 +181,6 @@ Primary SQL tables touched directly in API routes include:
|
||||
|
||||
Yuanjie Manifest references use `$inputImages.urls` for provider-facing JSON fields. For image-to-image, `/api/generate/image` reads the primary `image` plus `extraImages` and sends all references to `src/lib/user-api-manifest-executor.ts`; the executor uploads data URL references into storage before rendering Yuanjie `params.images`, top-level `images`, or `base64Array`. Yuanjie video templates keep documented model-specific fields inside `src/lib/yuanjie-video-model-templates.ts`, including first/last reference fields and mode fields such as `input_reference`, `reference_urls`, `img_url`, `image_tail`, `ratio`, `size`, and `generation_mode`.
|
||||
|
||||
`src/lib/yuanjie-system-manifest.ts` provides the runtime bridge for existing admin system API rows that were created before Manifest-backed Yuanjie templates. It exposes built-in capabilities to `/api/model-config` even when `manifest_path` is empty, and when a known 元界 system API is resolved for generation it writes the missing `system-api-manifests/<systemApiId>.json`, normalizes `api_url` back to the 元界 base URL, and preserves the encrypted API key and administrator pricing.
|
||||
`src/lib/yuanjie-system-manifest.ts` provides the runtime bridge for existing admin system API rows that were created before Manifest-backed Yuanjie templates. It exposes built-in capabilities to `/api/model-config` even when `manifest_path` is empty, and when a known 元界 system API is resolved directly or as a default-model polling candidate it writes missing or stale `system-api-manifests/<systemApiId>.json`, normalizes `api_url` back to the 元界 base URL, and preserves the encrypted API key and administrator pricing.
|
||||
|
||||
Profile naming convention: `profiles.nickname` is the stable login username; `profiles.display_nickname` is the public nickname shown in navbar/gallery/profile UI. APIs return `username` plus `nickname`/`display_nickname` so older clients can keep reading `nickname` as the display name.
|
||||
|
||||
@@ -118,6 +118,7 @@ Create panel
|
||||
-> src/lib/generation-credit-service.ts deducts selected system API credits only after success
|
||||
-> generation_jobs updated with result/error/progress
|
||||
-> client polls GET /api/generation-jobs/[id]
|
||||
-> create panels can recover queued/running jobs from GET /api/generation-jobs after refresh, auth change, or tab switch
|
||||
-> history/gallery persistence via works APIs
|
||||
```
|
||||
|
||||
@@ -181,15 +182,15 @@ 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. 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.
|
||||
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 uses `src/lib/gallery-publish-media.ts`: stable `/api/local-storage/...` image and video originals are reused rather than synchronously copying object-backed generated media again, while external media URLs are copied into gallery storage before insertion. 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.
|
||||
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`. Sharing an already generated `/api/local-storage/...` image should reuse that original URL and existing thumbnail instead of copying the object into `gallery/images` or recompressing a gallery thumbnail synchronously; missing/stale thumbnails are backfilled later by `/api/gallery` reads. 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 local files under `thumbnails/works/videos` or `thumbnails/gallery/videos`, generated by `ensureLocalVideoThumbnail(...)` when history/gallery rows are written or read. The current preferred profile is a real video frame extracted by `ffmpeg-static` and stored as `video-frame-m1280q86-v1.webp`; the lightweight SVG profile is only a fallback when frame extraction fails. Old `video-svg-v1` rows are treated as stale so gallery/history reads can backfill real frame thumbnails in the background. 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.
|
||||
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 local files under `thumbnails/works/videos` or `thumbnails/gallery/videos`, generated by `ensureLocalVideoThumbnail(...)` when history/gallery rows are written or read. The current preferred profile is a real video frame extracted by `ffmpeg-static` and stored as `video-frame-m1280q86-v1.webp`; lightweight SVG profiles such as `video-svg-v1` and `video-fallback-svg-v2` are only fallbacks when frame extraction fails. SVG fallback rows are treated as stale so gallery/history reads can backfill real frame thumbnails in the background; publish also tries frame extraction before copying any client-provided thumbnail. Object-backed videos are streamed from the storage adapter into a bounded temporary local file before ffmpeg extraction, with retry around transient object-stream termination; this avoids passing signed object-storage URLs directly to the bundled `ffmpeg-static` binary, which can crash or return no stderr for some remote inputs. 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.
|
||||
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. Client sharing flows must call `/api/gallery/publish` first and only then mark local history as shared with `publishedAt`; stale local `published=true` without that confirmation must not disable retry. 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.
|
||||
|
||||
Admin gallery moderation is separate from the public gallery page. `src/components/admin/gallery-management-tab.tsx` lists public completed works through `/api/admin/gallery/works` with page/pageSize pagination; `src/lib/admin-gallery-works-pagination.ts` keeps the route compatible with older limit/offset callers. Prompt edits go through `/api/admin/gallery/prompt` and `src/lib/admin-gallery-prompt-service.ts`. The service enforces the moderation rule that the author notification email must send successfully before `works.prompt` is updated. Platform logs record the admin, work, author, reason key, prompt length changes, and notification result, but must not store the full original or edited prompt text.
|
||||
|
||||
|
||||
@@ -54,15 +54,16 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Wrong image size, aspect ratio, or custom API says returned resolution is lower than requested | `src/lib/model-config.ts`, `src/app/api/generate/image/route.ts` | `resolveImageSize`, `resolveCustomApiImageSize`, New API/DALL-E size normalization, prompt aspect hint, and custom API result qualification. Exact or larger generated images pass normally; lower-resolution images with matching aspect ratio and at least 60% of the requested dimensions are accepted as degraded upstream output instead of failing the job, while wrong-ratio or much smaller images are still rejected. |
|
||||
| Text-to-image or image-to-image says `请在提示词中写明画面比例` even after selecting a Yuanjie resolution such as `4K 竖版 (3:4)` | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/lib/yuanjie-image-model-templates.ts` | Some Yuanjie image templates set `supportsAspectRatio: false` and encode orientation in `resolution`/`size` options. Generation validation must derive the ratio from the selected resolution label or dimensions instead of requiring a separate aspect-ratio control. Image-to-image should also default count to `1` rather than requiring prompt inference for `生成数量`. |
|
||||
| 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. |
|
||||
| Custom API image-to-image logs `Failed to download reference image from URL`, sends a 56-character `/api/local-storage/...` reference, or all URL-based strategies fail | `src/app/api/generate/image/route.ts`, `src/lib/local-storage.ts`, `src/lib/remote-fetch.ts` | Custom API img2img should read existing `/api/local-storage/...` references through `localStorage.readFileAsync(...)` for the FormData `images/edits` strategy instead of fetching back through public HTTP. When a data URL reference is uploaded for URL-based strategies, return `localStorage.generateObjectReadUrl(...)` when object storage is configured; only fall back to an absolute `APP_BASE_URL + /api/local-storage/...` URL, never a relative URL. |
|
||||
| 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 WEBP frame thumbnails generated through `ffmpeg-static`, falling back to local SVG only if frame extraction fails. |
|
||||
| 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. |
|
||||
| Image preview cards load slowly, look blurry in detail, or fetch full originals | `src/lib/media-storage.ts`, `src/lib/local-storage.ts`, `src/lib/gallery-publish-media.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/gallery/publish` should reuse stable `/api/local-storage/...` generated image originals and existing thumbnails instead of synchronously reading the object, copying it to `gallery/images`, or recompressing a gallery thumbnail. `/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 `下载图片`, shows the generic play-card instead of a real thumbnail, 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`, `package.json` | Use `isVideoWork(...)` for labels and filenames. Video cards/details should render `thumbnailUrl` first and mount the original `<video>` only after the user clicks play. If thumbnails are missing or still use the old `video-svg-v1` profile, `/api/gallery` and publish should backfill local WEBP frame thumbnails under `thumbnails/gallery/videos` via `ffmpeg-static`; SVG is only the fallback when extraction fails. |
|
||||
| Gallery video detail says `下载图片`, shows the generic play-card instead of a real thumbnail, 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`, `package.json` | Use `isVideoWork(...)` for labels and filenames. Video cards/details should render `thumbnailUrl` first and mount the original `<video>` only after the user clicks play. If thumbnails are missing or still use `video-svg-v1` or `video-fallback-svg-v2`, `/api/gallery` and publish should backfill local WEBP frame thumbnails under `thumbnails/gallery/videos` via `ffmpeg-static`; only `video-frame-m1280q86-v1.webp` is the current video thumbnail profile. SVG is only the fallback when extraction fails and must remain replaceable later. If PM2 logs show `spawn /ROOT/node_modules/.../ffmpeg ENOENT`, check `src/lib/media-storage.ts` runtime cwd fallback for `ffmpeg-static`; bundled route contexts can resolve the package from a synthetic path even though `/opt/miaojingAI/node_modules/.../ffmpeg` exists. If ffmpeg exits with code `139`/`unknown` and no stderr for an object-backed video, verify the thumbnail path is streaming the object through `localStorage.openFileStreamAsync(...)` into a temporary local file before extraction, not passing a signed object URL directly to ffmpeg. If object-storage reads intermittently terminate mid-stream, `src/lib/media-storage.ts` should retry bounded temporary input writes before falling back to SVG. |
|
||||
| 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 +75,9 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| 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. |
|
||||
| Reverse prompt says `请先登录后再使用自定义 API` while the user is already logged in | `src/components/create/reverse-prompt-panel.tsx`, `src/lib/auth-store.ts`, `src/app/api/generate/reverse-prompt/route.ts`, `src/lib/server-api-config.ts` | The reverse-prompt fetch must send `Authorization: Bearer <accessToken>` from `readStoredAuth()`. The server resolves `customApiKeyId`/`systemApiId` through `getAuthenticatedUserId`, which reads the bearer token rather than browser localStorage. |
|
||||
| Prompt optimization fails | `src/app/api/generate/suggest-prompt/route.ts`, `src/lib/server-api-config.ts`, `src/lib/custom-api-fetch.ts` | Text-capable system/custom API, chat response shape, JSON parsing fallback. |
|
||||
| Reverse prompt keeps disappearing after refresh, relogin, or tab switch | `src/components/create/reverse-prompt-panel.tsx`, `src/lib/generation-job-client.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/app/api/generation-jobs/route.ts`, `src/app/api/creation-history/route.ts` | Reverse prompt now uses the shared generation job queue and should recover queued/running jobs from `/api/generation-jobs`, keep the loading state alive until the worker actually finishes, and rely on the normal creation-history writeback when the worker completes. |
|
||||
| Reverse prompt reaches login successfully but then times out on upstream `chat/completions` | `src/app/api/generate/reverse-prompt/route.ts`, `src/lib/local-storage.ts`, `src/lib/custom-api-fetch.ts` | If the input is a data URL, persist it first and send the public `/api/local-storage/...` URL upstream instead of the raw blob. Reverse-prompt is a multimodal chat/completions request, so a 524 here means the upstream multimodal endpoint or its latency is the problem, not frontend auth or image-generation routing. |
|
||||
| Prompt optimization fails | `src/app/api/generate/suggest-prompt/route.ts`, `src/lib/server-api-config.ts`, `src/lib/custom-api-fetch.ts` | Text-capable system/custom API, chat response shape, JSON parsing fallback. This route also uses a multimodal chat/completions path, so 524 should be read as a multimodal upstream timeout rather than a synchronous image-generation failure. |
|
||||
|
||||
## Models, Providers, API Keys
|
||||
|
||||
@@ -93,7 +96,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| 元界 AI 同步后出现大量接口/参数名模型或模型行反复显示 Key | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/components/admin/api-management-tab.tsx` | 元界不应再从 `/v1/skills` 或 `/v1/skills/guide` 猜模型,也不应在 `智能配置 API` 页面暴露内置模板安装/同步入口。检查安装路由是否使用内置图片/视频模板、是否只删除当前媒体类型的 `provider = '元界 AI'` 行、是否创建 inactive rows and per-model Manifest files, and whether admins configure Key/pricing/usage modes/enablement per model through the system-default-model management flow. The admin list should not show repeated imported key placeholders, and the create page should show only documented controls from the selected template capabilities. |
|
||||
| 元界任务在元界后台成功但妙境报模型繁忙或接口路径不存在 | `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Check whether the Manifest poll endpoint uses `path: "v1/media/status"` plus `query: { task_id: "{task_id}" }`. If the path is stored as `v1/media/status?task_id={task_id}`, the executor can encode the query string into the pathname and 元界 will return a not-found error even though the create request already produced a task. Also verify 元界 media templates use `finalPath: "is_final"`, `finalValues: [true]`, `statusPath: "state"`, `successValues: ["success"]`, and `failureValues: ["failed"]`; `status` / `status_group` are display fields only. |
|
||||
| 元界后台显示已生成图片但妙境任务失败,日志出现下载 403、timeout 或保存失败 | `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, `src/lib/remote-fetch.ts`, `src/lib/user-api-manifest-executor.ts` | 这通常不是元界提交或轮询失败,而是 Manifest 结果 URL 返回后,妙境下载外部图片或保存原图/缩略图失败。先查 PM2 日志中 `[User API Manifest Image] Failed to persist generated image`,区分 `下载图片失败: 403`、`fetch failed`、`Persist generated image media timed out`、对象存储/缩略图错误。外部生成图 URL 应通过 `fetchPublicHttpUrlWithRetry` 发送浏览器式 `User-Agent`/`Accept` 并有限重试;`/api/generate/image` 应返回“上游已返回生成结果,但平台下载或保存结果图片失败”,不要再误包装为“上游返回图片分辨率不符合”或泛化成模型繁忙。 |
|
||||
| 元界图生图提交后妙境报 `Manifest 未能从 ... 读取任务 ID` or generic `模型繁忙` while 元界 may have accepted the job | `src/lib/user-api-manifest-executor.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts` | The submit response can put the task identifier inside nested `result` objects. The executor must normalize `task_id`, `taskId`, `id`, and nested `data/result/output` objects before polling. Template `taskIdPath` should include `result.task_id`, `result.taskId`, and `result.id` before the broad `result` fallback. |
|
||||
| 元界图生图提交后妙境报 `Manifest 未能从 ... 读取任务 ID` or generic `模型繁忙` while 元界 may have accepted the job | `src/lib/server-api-config.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts` | The submit response can put the task identifier inside nested `result` objects. The executor must normalize `task_id`, `taskId`, `id`, and nested `data/result/output` objects before polling. Template `taskIdPath` should include `result.task_id`, `result.taskId`, and `result.id` before the broad `result` fallback. For system default polling, `resolveSystemApiPollingCandidates(...)` must also run `ensureYuanjieSystemApiManifest(...)`; otherwise stale production `system-api-manifests/<id>.json` files can keep old `$inputImages.dataUrls` and old task-id paths even when source templates are fixed. |
|
||||
| 视频系统模型出现在错误入口或缺少参数选项 | `src/lib/server-api-config.ts`, `src/components/admin/api-management-tab.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/model-capabilities.ts` | Check `system_api_configs.video_usage_modes`. 文生视频 should only show rows including `text-to-video`; 图生视频 should only show rows including `image-to-video`. Selected system video models should read Manifest `capabilities` for aspect ratio, duration, and resolution controls. |
|
||||
| 管理后台刷新后跳回仪表盘 | `src/modules/console/pages/console-dashboard-page.tsx` | The active view should be restored from `sessionStorage` on refresh and removed on logout. If it jumps to dashboard after a plain refresh, inspect the session key `miaojing_console_active_view` and whether the view is still allowed by the current membership/admin config. |
|
||||
| 兑换码无法生成、重复、兑换后积分或会员不到账,或可重复兑换 | `src/components/admin/redeem-code-management-tab.tsx`, `src/app/api/admin/redeem-codes/route.ts`, `src/components/profile/credits-tab.tsx`, `src/app/api/redeem-codes/redeem/route.ts`, `src/lib/redeem-code-service.ts` | Codes should be generated server-side with unique `normalized_code`. Redemption must use a DB transaction with `FOR UPDATE` locks on `redeem_codes` and `profiles`, then mark `used_by/used_at`. Credit codes update `profiles.credits_balance` and insert a `credit_transactions` row. Membership codes update `profiles.membership_tier` plus `membership_expires_at`; duration units are `day`, `month`, and `year`. If the profile page is stale, inspect `/api/profile` refresh and `/api/credit-transactions`. |
|
||||
@@ -107,9 +110,9 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
|
||||
| Symptom | Check Files | What To Verify |
|
||||
| --- | --- | --- |
|
||||
| History missing after generation or login/account switch | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, create panel component | History POST, `works` insert, URL not data URL except reverse prompt placeholder, and `miaojing_auth_updated` triggers a fresh server fetch. |
|
||||
| History missing after generation or login/account switch | `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, create panel component | History POST, `works` insert, URL not data URL except reverse prompt placeholder, and `miaojing_auth_updated` triggers a fresh server fetch. Create panels now also recover queued/running jobs from `/api/generation-jobs` so a refresh or re-login can reattach the live task before it finishes. |
|
||||
| Detail delete removes only local history, skips confirmation, or record reappears after refresh | `src/components/creation-detail-dialog.tsx`, `src/components/ui/alert-dialog.tsx`, `src/lib/creation-history-store.ts`, `src/app/api/creation-history/route.ts`, `src/components/profile/creation-history-tab.tsx` | The detail action is labeled `删除作品` and must open a confirmation dialog warning that deletion cannot be recovered. Logged-in deletion should call `DELETE /api/creation-history?id=...` first, then refresh local history from the server. Check bearer token availability and route ownership filter (`id` + `user_id`). |
|
||||
| Published work not in gallery | `src/lib/creation-history-store.ts`, `src/app/api/gallery/publish/route.ts`, `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | `is_public = true`, `status = completed`, media copied to gallery folder, filters. |
|
||||
| Published work not in gallery or share to gallery is slow | `src/lib/creation-history-store.ts`, `src/lib/gallery-publish-media.ts`, `src/app/api/gallery/publish/route.ts`, `src/app/api/gallery/route.ts`, `src/app/gallery/page.tsx` | `is_public = true`, `status = completed`, stable `/api/local-storage/...` `result_url`, media copied/reused into gallery storage, and current filters. New generated `/api/local-storage/...` image/video URLs should use the publish fast path in `gallery-publish-media` and must not synchronously copy object-backed originals during share; external URLs still need copying and should fail the publish request if media preparation fails. Also check whether the browser marked the work shared before `/api/gallery/publish` returned success; local `published=true` without `publishedAt` is stale and should not block retry. For older incidents, inspect server logs/API status for publish failures that the previous frontend swallowed. |
|
||||
| Imported gallery images do not render after production data import | `src/app/api/admin/data-export/route.ts`, `src/app/api/admin/data-import/route.ts`, `src/lib/local-storage.ts`, `src/app/api/local-storage/[...path]/route.ts`, DB `works.result_url` | New exports should include `_media`; import should persist media through the active storage adapter. If using an older export without `_media`, DB rows alone cannot recreate missing `/api/local-storage/*` files. For object migration, run `pnpm run storage:sync-object -- --verify-only` before switching to `STORAGE_MODE=object`. |
|
||||
| Rainyun ROS bucket created but object storage still fails | `scripts/rainyun-ros-prepare.mjs`, `.env.local`, `src/lib/local-storage.ts`, `scripts/storage-sync-to-object.mjs`, `/api/health` | The Rainyun API link is control-plane bucket creation, not the media upload path. Verify `.env.local` has reviewed `OBJECT_STORAGE_BUCKET`, `OBJECT_STORAGE_ENDPOINT`, `OBJECT_STORAGE_ACCESS_KEY_ID`, `OBJECT_STORAGE_SECRET_ACCESS_KEY`, `OBJECT_STORAGE_FORCE_PATH_STYLE=true`, and `STORAGE_MODE=dual`; then run `/api/health` and `pnpm run storage:sync-object -- --dry-run`. |
|
||||
| Gallery delete does not remove public item | `src/app/api/gallery/route.ts`, admin UI route using it | DELETE unpublishes by setting `is_public = false`, not hard delete. |
|
||||
|
||||
@@ -50,11 +50,11 @@ 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. On phones the mode switch is the single fixed icon row below the navbar; the page title and duplicate text mode strip are hidden. Mobile layout classes in this page and `src/app/globals.css` turn the create center into a chat-style flow: text-to-image sorts history from oldest to newest and auto-scrolls to the latest work above the fixed composer, hides the empty result placeholder until the user submits a prompt, renders generating tasks as the newest prompt-plus-progress message, and uses `src/components/create/mobile-creation-composer.tsx` as the fixed bottom composer with compact labeled ratio/resolution/count controls, optional style strip that expands the composer upward, prompt input, and right send button. |
|
||||
| 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. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 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. The mobile conversation history should only mount on mobile viewports; CSS-hidden mobile history still runs image effects if mounted on desktop. |
|
||||
| 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, active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 图生图 removes `自动` from ratio/resolution/count controls, defaults count to `1`, and derives ratio from Yuanjie size labels or dimensions when the selected model hides the separate ratio control. 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`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. 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, active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. 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` |
|
||||
| Text to image | `src/components/create/text-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.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. Model select items use `src/components/create/grouped-model-select-items.tsx` so admin global system models appear under `默认模型` and user-added keys appear under `自定义模型`. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 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. The mobile conversation history should only mount on mobile viewports; CSS-hidden mobile history still runs image effects if mounted on desktop. |
|
||||
| Image to image | `src/components/create/image-to-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`. Reference thumbnails single-click into a bare image overlay, active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. Selected model capabilities from `src/lib/model-capabilities.ts` can hide unsupported aspect ratio/resolution/format/quality controls as well as filter their options, which is required for built-in 元界 image templates such as GPT Image 2 where the docs expose `size` pixel values instead of a separate aspect-ratio control. 图生图 removes `自动` from ratio/resolution/count controls, defaults count to `1`, and derives ratio from Yuanjie size labels or dimensions when the selected model hides the separate ratio control. 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`, `src/components/create/use-generation-job-recovery.ts`. The create button remains usable while jobs are running; active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. 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`, `src/components/create/use-generation-job-recovery.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image, active jobs render through `src/components/create/generation-task-list.tsx`, and model select items use `src/components/create/grouped-model-select-items.tsx` for `默认模型` versus `自定义模型` grouping. 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`, `src/lib/generation-job-client.ts`, `src/components/create/use-generation-job-recovery.ts`. Reverse prompt now runs as a background job, survives refresh/auth change/tab switch, and writes the completed result back into the normal creation history flow instead of relying on an optimistic local-only row. |
|
||||
| Prompt textarea | `src/components/create/expandable-prompt-textarea.tsx` | Shared prompt input. |
|
||||
| Mobile creation composer | `src/components/create/mobile-creation-composer.tsx`, `src/app/globals.css` | Mobile-only fixed bottom composer used by text-to-image to match chat-style clients: top parameter strip with compact dropdown buttons for ratio/resolution/count, optional style strip, prompt input, and right send button. The mobile creation center uses one 16px UI font size across selected values, style chips, composer input, and conversation prompts. The mobile text-to-image parameter strip hides the `画面比例`/`分辨率`/`生成数量` labels and removes `自动` from ratio, resolution, and count choices, defaulting to explicit values instead. The mobile style strip shows only one horizontal row when collapsed and expands upward for search/more presets after tapping `展开`. Mode selection stays only in the sticky header tabs. Desktop creation forms remain the source for full advanced controls. |
|
||||
| Image count input/dropdown | `src/components/create/image-count-combobox.tsx` | Shared compact count control for manual image count entry and common dropdown options. |
|
||||
@@ -76,7 +76,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| 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. 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. |
|
||||
| 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 with missing or stale `manifest_path`, both direct system API resolution and default-model polling candidates can rewrite 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. |
|
||||
| Admin system API smart import | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts` | The console API management page has a separate `智能配置 API` section for admins, but this section is generic Manifest import only. It supports copy-to-chat-AI and paste-and-import Manifest flow, then creates one independent system API row and `system-api-manifests/<systemApiId>.json` file per imported profile/model. Imported rows resolve the visible API request URL from the Manifest profile/provider before save, and optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for the selected system model. Provider-specific built-in template management, including 元界 AI, belongs in the `系统默认模型` management flow and should not be exposed in the smart import UI. 元界价格/计费方式手动同步 uses `src/app/api/admin/system-apis/yuanjie-pricing/route.ts` and `src/lib/yuanjie-pricing-sync.ts`; it updates only existing 元界 image/video rows, tolerates provider spellings such as `元界AI`, and leaves mozheAPI/global smart-import configs untouched. |
|
||||
| Admin console active page persistence | `src/modules/console/pages/console-dashboard-page.tsx` | The console active view is stored in `sessionStorage`, so browser refresh keeps the current admin page/tab. Logout clears the value, and closing/reopening the console starts from the dashboard because `sessionStorage` is tab-scoped. |
|
||||
@@ -109,10 +109,10 @@ 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`. Video gallery cards and detail surfaces render `thumbnailUrl` first; `src/lib/media-storage.ts` should provide a local WEBP video-frame thumbnail when `ffmpeg-static` can extract one and only fall back to SVG when extraction fails. 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 plus old `video-svg-v1` video 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. 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 WEBP frame previews under `thumbnails/gallery/videos` via `ffmpeg-static`, with SVG fallback only on extraction failure. |
|
||||
| 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 plus stale video SVG fallback thumbnails for background backfill without delaying the response, admin DELETE unpublishes. For videos, only `video-frame-m1280q86-v1.webp` counts as a current thumbnail; `video-svg-v1` and `video-fallback-svg-v2` are temporary fallback assets and should not block a later real-frame backfill. 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`, `src/lib/gallery-publish-media.ts` | Inserts public work after resolving gallery media. Stable `/api/local-storage/...` image and video originals are reused instead of synchronously copying object-backed generated media during share; external media is still copied into gallery storage first. Existing image thumbnails are reused so image sharing does not block on object-storage reads or thumbnail recompression; `/api/gallery` can lazily backfill missing/stale thumbnails. Video publishing first tries to generate a local WEBP frame preview under `thumbnails/gallery/videos` via `ffmpeg-static`, and only copies a client-provided thumbnail when real-frame extraction fails. Client code must treat `/api/gallery/publish` as authoritative and mark local works as shared only after a 2xx response. |
|
||||
| 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 and old-profile video thumbnails are queued for background backfill instead of blocking the history response. Video history thumbnails use local WEBP frames when `ffmpeg-static` can extract one, with SVG as the failure fallback. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
|
||||
| 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 and old-profile video thumbnails are queued for background backfill instead of blocking the history response. Video history thumbnails use local WEBP frames when `ffmpeg-static` can extract one, with SVG as the failure fallback; fallback SVG files are not considered current thumbnails. Local sharing state requires `publishedAt`, which is set only after confirmed server publish, so stale `published=true` flags from older clients do not disable retrying a failed gallery share. Single-record deletion is server-first when logged in; detail dialogs call the same store path and then refresh local history. |
|
||||
|
||||
## Admin Console
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
"start": "bash ./scripts/start.sh",
|
||||
"test:admin-gallery-prompt": "node --no-warnings ./scripts/test-admin-gallery-prompt-service.mjs",
|
||||
"test:custom-image-fallback": "tsx ./scripts/test-custom-image-fallback.mjs",
|
||||
"test:custom-img2img-reference-url": "node --no-warnings ./scripts/test-custom-img2img-reference-url.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-publish-fast-path": "tsx ./scripts/test-gallery-publish-fast-path.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",
|
||||
|
||||
@@ -85,4 +85,11 @@ await runTest('text-to-image custom fetch enables one retry for 502 503 504 gate
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('multimodal 524 errors do not reuse image-generation timeout wording', () => {
|
||||
const message = parseCustomApiError(524, '<!DOCTYPE html><title>mozhevip.top | 524: A timeout occurred</title>', 'multimodal');
|
||||
|
||||
assert.match(message, /多模态模型同步请求超时/);
|
||||
assert.equal(message.includes('生图请求超时'), false);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
|
||||
30
scripts/test-custom-img2img-reference-url.mjs
Normal file
30
scripts/test-custom-img2img-reference-url.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const routeSource = fs.readFileSync('src/app/api/generate/image/route.ts', 'utf8');
|
||||
|
||||
assert.match(
|
||||
routeSource,
|
||||
/generateObjectReadUrl\(fileKey,\s*3600\)/,
|
||||
'img2img uploaded reference images should expose object signed URLs to upstream providers',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
routeSource,
|
||||
/toAbsolutePublicUrl\(publicUrl\)/,
|
||||
'img2img fallback public URLs should be absolute when object signed URLs are unavailable',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
routeSource,
|
||||
/localStorage\.getKeyFromPublicUrl\(image\)/,
|
||||
'img2img should detect stored /api/local-storage reference images before fetching over HTTP',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
routeSource,
|
||||
/localStorage\.readFileAsync\(storedReferenceKey\)/,
|
||||
'img2img should read stored reference images through the storage adapter for FormData uploads',
|
||||
);
|
||||
|
||||
console.log('custom img2img reference URL policy ok');
|
||||
103
scripts/test-gallery-publish-fast-path.mjs
Normal file
103
scripts/test-gallery-publish-fast-path.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const galleryPublishMediaModule = await import('../src/lib/gallery-publish-media.ts');
|
||||
const { resolveGalleryPublishMedia } = galleryPublishMediaModule.default || galleryPublishMediaModule;
|
||||
|
||||
function createDeps() {
|
||||
const calls = {
|
||||
copy: [],
|
||||
imageThumbnail: [],
|
||||
videoThumbnail: [],
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
copyPublicUrlToFolder: async (url, folder, options) => {
|
||||
calls.copy.push({ url, folder, options });
|
||||
return `/api/local-storage/${folder}/copied.png`;
|
||||
},
|
||||
ensureLocalImageThumbnail: async (url, prefix) => {
|
||||
calls.imageThumbnail.push({ url, prefix });
|
||||
return `/api/local-storage/${prefix}/generated-m1280q86.webp`;
|
||||
},
|
||||
ensureLocalVideoThumbnail: async (url, prefix) => {
|
||||
calls.videoThumbnail.push({ url, prefix });
|
||||
return `/api/local-storage/${prefix}/frame-video-frame-m1280q86-v1.webp`;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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('new object-backed image publishes reuse the existing local-storage URL and current thumbnail', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
const result = await resolveGalleryPublishMedia({
|
||||
type: 'image',
|
||||
resultUrl: '/api/local-storage/generated/images/source.png',
|
||||
thumbnailUrl: '/api/local-storage/thumbnails/generated/images/source-m1280q86.webp',
|
||||
prompt: 'image prompt',
|
||||
}, deps);
|
||||
|
||||
assert.equal(result.resultUrl, '/api/local-storage/generated/images/source.png');
|
||||
assert.equal(result.thumbnailUrl, '/api/local-storage/thumbnails/generated/images/source-m1280q86.webp');
|
||||
assert.deepEqual(calls.copy, []);
|
||||
assert.deepEqual(calls.imageThumbnail, []);
|
||||
});
|
||||
|
||||
await runTest('external image publishes still copy into gallery storage before thumbnailing', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
const result = await resolveGalleryPublishMedia({
|
||||
type: 'image',
|
||||
resultUrl: 'https://example.com/source.png',
|
||||
thumbnailUrl: null,
|
||||
prompt: 'image prompt',
|
||||
}, deps);
|
||||
|
||||
assert.equal(result.resultUrl, '/api/local-storage/gallery/images/copied.png');
|
||||
assert.equal(result.thumbnailUrl, '/api/local-storage/thumbnails/gallery/generated-m1280q86.webp');
|
||||
assert.deepEqual(calls.copy, [
|
||||
{
|
||||
url: 'https://example.com/source.png',
|
||||
folder: 'gallery/images',
|
||||
options: { storageTarget: 'object' },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(calls.imageThumbnail, [
|
||||
{
|
||||
url: '/api/local-storage/gallery/images/copied.png',
|
||||
prefix: 'thumbnails/gallery',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await runTest('object-backed video publishes keep reusing the existing local-storage URL', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
const result = await resolveGalleryPublishMedia({
|
||||
type: 'video',
|
||||
resultUrl: '/api/local-storage/generated/videos/source.mp4',
|
||||
thumbnailUrl: null,
|
||||
prompt: 'video prompt',
|
||||
}, deps);
|
||||
|
||||
assert.equal(result.resultUrl, '/api/local-storage/generated/videos/source.mp4');
|
||||
assert.equal(result.thumbnailUrl, '/api/local-storage/thumbnails/gallery/videos/frame-video-frame-m1280q86-v1.webp');
|
||||
assert.deepEqual(calls.copy, []);
|
||||
assert.deepEqual(calls.videoThumbnail, [
|
||||
{
|
||||
url: '/api/local-storage/generated/videos/source.mp4',
|
||||
prefix: 'thumbnails/gallery/videos',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
62
scripts/test-generation-job-persistence.mjs
Normal file
62
scripts/test-generation-job-persistence.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
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('generation job runner can dispatch reverse-prompt payloads to the reverse prompt route', () => {
|
||||
const source = read('src/lib/generation-job-runner.ts');
|
||||
assert.match(source, /type GenerationJobType = 'image' \| 'video' \| 'reverse-prompt';/);
|
||||
assert.match(source, /const endpoint = type === 'image' \? '\/api\/generate\/image' : type === 'video' \? '\/api\/generate\/video' : '\/api\/generate\/reverse-prompt';/);
|
||||
});
|
||||
|
||||
await runTest('generation jobs route can list active jobs and accept reverse-prompt submissions', () => {
|
||||
const source = read('src/app/api/generation-jobs/route.ts');
|
||||
assert.match(source, /export async function GET\(request: NextRequest\)/);
|
||||
assert.match(source, /status IN \('queued', 'running'\)/);
|
||||
assert.match(source, /type !== 'image' && type !== 'video' && type !== 'reverse-prompt'/);
|
||||
});
|
||||
|
||||
await runTest('creation history post accepts trusted internal generation requests', () => {
|
||||
const source = read('src/app/api/creation-history/route.ts');
|
||||
assert.match(source, /isTrustedInternalGenerationRequest/);
|
||||
assert.match(source, /x-miaojing-generation-user-id/);
|
||||
assert.match(source, /if \(!userId\) return NextResponse\.json\(\{ error: '请先登录' \}, \{ status: 401 \}\);/);
|
||||
});
|
||||
|
||||
await runTest('generation worker persists completed jobs back into creation history', () => {
|
||||
const source = read('src/lib/generation-job-worker.ts');
|
||||
assert.match(source, /\/api\/creation-history/);
|
||||
assert.match(source, /persistGenerationHistoryRecord|saveGenerationHistoryRecord|creation history/i);
|
||||
assert.match(source, /status: 'succeeded'/);
|
||||
});
|
||||
|
||||
await runTest('create panels restore active jobs from the server after reload or auth change', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/text-to-image.tsx',
|
||||
'src/components/create/image-to-image.tsx',
|
||||
'src/components/create/text-to-video.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
'src/components/create/reverse-prompt-panel.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /useGenerationJobRecovery|fetchActiveGenerationJobs|\/api\/generation-jobs\?status=queued%2Crunning|\/api\/generation-jobs\?status=queued,running/);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
21
scripts/test-reverse-prompt-upstream-image-url.mjs
Normal file
21
scripts/test-reverse-prompt-upstream-image-url.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const routePath = path.join(process.cwd(), 'src/app/api/generate/reverse-prompt/route.ts');
|
||||
const source = fs.readFileSync(routePath, 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/const upstreamImage\s*=\s*toPublicImageUrl\(persistedReferenceImage,\s*request\)\s*\|\|\s*image/,
|
||||
'reverse-prompt should prefer the public persisted platform URL for upstream image_url payloads',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/image_url:\s*\{\s*url:\s*upstreamImage\s*\}/,
|
||||
'reverse-prompt upstream chat payload should send upstreamImage instead of the raw upload data URL',
|
||||
);
|
||||
|
||||
console.log('reverse-prompt upstream image URL policy ok');
|
||||
@@ -48,11 +48,62 @@ await runTest('video result download buttons trigger a streaming browser downloa
|
||||
});
|
||||
|
||||
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');
|
||||
const routeSource = read('src/app/api/gallery/publish/route.ts');
|
||||
const source = read('src/lib/gallery-publish-media.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*\}/);
|
||||
assert.match(routeSource, /resolveGalleryPublishMedia\(\{/);
|
||||
assert.match(source, /if \(input\.type === 'video'\) \{/);
|
||||
assert.match(source, /if \(!isStableLocalStorageUrl\(input\.resultUrl\)\) \{[\s\S]*?copyPublicUrlToFolder\(input\.resultUrl,\s*'gallery\/videos',\s*\{\s*storageTarget:\s*'object'\s*\}/);
|
||||
assert.match(source, /let galleryResultUrl = input\.resultUrl/);
|
||||
});
|
||||
|
||||
await runTest('gallery publish prefers real video frame thumbnails over stale client SVG thumbnails', () => {
|
||||
const source = read('src/lib/gallery-publish-media.ts');
|
||||
|
||||
const videoThumbnailIndex = source.indexOf("type === 'video'");
|
||||
const ensureIndex = source.indexOf('ensureLocalVideoThumbnail(');
|
||||
const copyProvidedIndex = source.indexOf("copyPublicUrlToFolder(input.thumbnailUrl, 'gallery/thumbnails'");
|
||||
|
||||
assert.notEqual(ensureIndex, -1);
|
||||
assert.notEqual(copyProvidedIndex, -1);
|
||||
assert.ok(videoThumbnailIndex < ensureIndex);
|
||||
assert.ok(ensureIndex < copyProvidedIndex);
|
||||
assert.match(source, /thumbnailUrl: generatedVideoThumbnailUrl \|\| copiedVideoThumbnailUrl \|\| galleryThumbnailUrl/);
|
||||
});
|
||||
|
||||
await runTest('share to gallery surfaces server publish failures before marking a work as published', () => {
|
||||
const source = read('src/lib/creation-history-store.ts');
|
||||
|
||||
assert.match(source, /if \(!res\.ok\) \{/);
|
||||
assert.match(source, /throw new Error\(typeof data\.error === 'string' \? data\.error : '分享失败,请重试'\)/);
|
||||
assert.doesNotMatch(source, /catch \{\s*\/\/ Non-critical/);
|
||||
|
||||
const fetchIndex = source.indexOf("fetch('/api/gallery/publish'");
|
||||
const markIndex = source.indexOf('markRecordAsPublished(options.url)');
|
||||
assert.notEqual(fetchIndex, -1);
|
||||
assert.notEqual(markIndex, -1);
|
||||
assert.ok(fetchIndex < markIndex);
|
||||
});
|
||||
|
||||
await runTest('share buttons wait for confirmed server publish and ignore stale local published flags', () => {
|
||||
const storeSource = read('src/lib/creation-history-store.ts');
|
||||
const detailSource = read('src/components/creation-detail-dialog.tsx');
|
||||
const createSources = [
|
||||
read('src/components/create/text-to-image.tsx'),
|
||||
read('src/components/create/image-to-image.tsx'),
|
||||
read('src/components/create/text-to-video.tsx'),
|
||||
read('src/components/create/image-to-video.tsx'),
|
||||
];
|
||||
|
||||
assert.match(storeSource, /publishedAt\?: string/);
|
||||
assert.match(storeSource, /r\.url === url && r\.published && r\.publishedAt/);
|
||||
assert.doesNotMatch(detailSource, /record\.published \|\| isUrlPublished\(record\.url\)/);
|
||||
|
||||
for (const source of createSources) {
|
||||
assert.match(source, /const handleShareToGallery = useCallback\(async \(url: string\) => \{/);
|
||||
assert.match(source, /await shareToGallery\(\{/);
|
||||
assert.match(source, /catch \(error\) \{/);
|
||||
}
|
||||
});
|
||||
|
||||
await runTest('gallery video cards and detail use thumbnails until the user starts playback', () => {
|
||||
@@ -77,6 +128,34 @@ await runTest('video thumbnails extract a real video frame before falling back t
|
||||
assert.doesNotMatch(source, /const VIDEO_THUMBNAIL_PROFILE = 'video-svg-v1'/);
|
||||
});
|
||||
|
||||
await runTest('object-backed video thumbnails stream to a temporary local file before ffmpeg extraction', () => {
|
||||
const source = read('src/lib/media-storage.ts');
|
||||
const resolveStart = source.indexOf('async function resolveVideoThumbnailInput(');
|
||||
const resolveEnd = source.indexOf('async function fetchTemporaryVideoInput(', resolveStart);
|
||||
const resolveSource = source.slice(resolveStart, resolveEnd);
|
||||
|
||||
assert.notEqual(resolveStart, -1);
|
||||
assert.notEqual(resolveEnd, -1);
|
||||
assert.match(resolveSource, /writeStoredTemporaryVideoInput\(existingKey,\s*sourceKey\)/);
|
||||
assert.match(resolveSource, /generateObjectReadUrl\(existingKey,\s*300\)/);
|
||||
assert.match(resolveSource, /fetchTemporaryVideoInput\(objectReadUrl,\s*sourceKey\)/);
|
||||
assert.doesNotMatch(resolveSource, /fileExistsAsync\(existingKey\)[\s\S]*?openFileStreamAsync\(existingKey\)/);
|
||||
assert.match(source, /const VIDEO_THUMBNAIL_INPUT_ATTEMPTS/);
|
||||
assert.match(source, /openFileStreamAsync\(existingKey\)/);
|
||||
assert.match(source, /writeTemporaryVideoInputFromStream\(storedFile\.body/);
|
||||
assert.match(source, /VIDEO_THUMBNAIL_MAX_INPUT_BYTES/);
|
||||
assert.doesNotMatch(source, /return \{ input: objectReadUrl \}/);
|
||||
});
|
||||
|
||||
await runTest('ffmpeg path resolution falls back to the runtime cwd when bundled route context is synthetic', () => {
|
||||
const source = read('src/lib/media-storage.ts');
|
||||
|
||||
assert.match(source, /existsSync\(/);
|
||||
assert.match(source, /createRequire\(path\.join\(process\.cwd\(\), 'package\.json'\)\)/);
|
||||
assert.match(source, /getExistingFfmpegPath\(cwdRequire\('ffmpeg-static'\)\)/);
|
||||
assert.doesNotMatch(source, /return typeof binaryPath === 'string' && binaryPath \? binaryPath : null/);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -115,6 +115,15 @@ await runTest('yuanjie system API rows without manifest still expose built-in vi
|
||||
assert.equal(mozheCapabilities, undefined);
|
||||
});
|
||||
|
||||
await runTest('system default polling candidates repair stale yuanjie manifests before generation', () => {
|
||||
const source = read('src/lib/server-api-config.ts');
|
||||
const pollingFunction = source.match(/export async function resolveSystemApiPollingCandidates[\s\S]*?^}/m)?.[0] || '';
|
||||
|
||||
assert.match(pollingFunction, /ensureYuanjieSystemApiManifest\(client,\s*row\)/);
|
||||
assert.match(pollingFunction, /yuanjieManifest\?\.manifestPath/);
|
||||
assert.match(pollingFunction, /yuanjieManifest\?\.apiUrl/);
|
||||
});
|
||||
|
||||
await runTest('video route passes negative prompt through Manifest params for providers that document it', () => {
|
||||
const source = read('src/app/api/generate/video/route.ts');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { isTrustedInternalGenerationRequest, isUuid } from '@/lib/server-api-config';
|
||||
import {
|
||||
ensureLocalImageThumbnail,
|
||||
ensureLocalVideoThumbnail,
|
||||
@@ -56,6 +57,7 @@ function mapWork(row: Record<string, unknown>) {
|
||||
: undefined,
|
||||
creditsCost: Number(row.credits_cost || 0),
|
||||
published: row.is_public === true,
|
||||
publishedAt: row.is_public === true ? row.created_at : undefined,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
@@ -162,7 +164,13 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
const trustedInternalRequest = isTrustedInternalGenerationRequest(request);
|
||||
const trustedUserId = trustedInternalRequest
|
||||
? request.headers.get('x-miaojing-generation-user-id')
|
||||
: null;
|
||||
const userId = isUuid(trustedUserId)
|
||||
? trustedUserId
|
||||
: await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
const body = await request.json();
|
||||
const records = Array.isArray(body.records) ? body.records : [body];
|
||||
@@ -241,7 +249,7 @@ export async function POST(request: NextRequest) {
|
||||
thumbnailUrl,
|
||||
width,
|
||||
height,
|
||||
Boolean(record.published),
|
||||
Boolean(record.published && record.publishedAt),
|
||||
Number(record.creditsCost || 0),
|
||||
record.createdAt || null,
|
||||
],
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { ensureLocalImageThumbnail, ensureLocalVideoThumbnail } from '@/lib/media-storage';
|
||||
import { resolveGalleryPublishMedia } from '@/lib/gallery-publish-media';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -59,23 +58,17 @@ export async function POST(request: NextRequest) {
|
||||
let galleryResultUrl = resultUrl;
|
||||
let galleryThumbnailUrl = thumbnailUrl || null;
|
||||
try {
|
||||
const folder = type === 'video' ? 'gallery/videos' : 'gallery/images';
|
||||
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 (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;
|
||||
}
|
||||
const media = await resolveGalleryPublishMedia({
|
||||
type,
|
||||
resultUrl,
|
||||
thumbnailUrl,
|
||||
prompt,
|
||||
});
|
||||
galleryResultUrl = media.resultUrl;
|
||||
galleryThumbnailUrl = media.thumbnailUrl;
|
||||
} catch (copyError) {
|
||||
console.warn('[gallery/publish] copy to gallery folder failed, using original URL:', copyError);
|
||||
console.warn('[gallery/publish] prepare gallery media failed:', copyError);
|
||||
return NextResponse.json({ error: '发布作品媒体处理失败,请重试' }, { status: 502 });
|
||||
}
|
||||
|
||||
await client.query(
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
resolveImageSize,
|
||||
} from '@/lib/model-config';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { fetchPublicHttpUrl } from '@/lib/remote-fetch';
|
||||
import { fetchPublicHttpUrl, fetchPublicHttpUrlWithRetry } from '@/lib/remote-fetch';
|
||||
import {
|
||||
isTrustedInternalGenerationRequest,
|
||||
isUuid,
|
||||
@@ -83,6 +83,16 @@ function syncFallbackConfirmationError(message: string): string {
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function publicAppBaseUrl(): string {
|
||||
return (process.env.APP_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function toAbsolutePublicUrl(url: string): string {
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
const baseUrl = publicAppBaseUrl();
|
||||
return baseUrl && url.startsWith('/') ? `${baseUrl}${url}` : url;
|
||||
}
|
||||
|
||||
function parseImageSize(size: string | undefined): TargetImageSize | null {
|
||||
const match = size?.match(/^(\d{2,5})x(\d{2,5})$/i);
|
||||
if (!match) return null;
|
||||
@@ -193,6 +203,22 @@ function parseImageDataUrl(dataUrl: string): { buffer: Buffer; mimeType: string;
|
||||
return parseMediaImageDataUrl(dataUrl);
|
||||
}
|
||||
|
||||
function getStoredImageMimeType(key: string): string {
|
||||
const extension = key.split('?')[0]?.split('.').pop()?.toLowerCase();
|
||||
if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg';
|
||||
if (extension === 'webp') return 'image/webp';
|
||||
if (extension === 'gif') return 'image/gif';
|
||||
if (extension === 'png') return 'image/png';
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
async function getReferenceImagePublicUrlFromKey(fileKey: string): Promise<string> {
|
||||
const objectReadUrl = localStorage.generateObjectReadUrl(fileKey, 3600);
|
||||
if (objectReadUrl) return objectReadUrl;
|
||||
const publicUrl = await localStorage.generatePresignedUrl({ key: fileKey, expireTime: 3600 });
|
||||
return toAbsolutePublicUrl(publicUrl);
|
||||
}
|
||||
|
||||
async function persistImageWithMetadata(url: string, prefix: string): Promise<PersistedImageResult | null> {
|
||||
const source = await readImageBufferFromUrl(url);
|
||||
if (!source) return null;
|
||||
@@ -455,10 +481,7 @@ async function uploadDataUrlAndGetPublicUrl(dataUrl: string): Promise<string | n
|
||||
return null;
|
||||
}
|
||||
|
||||
const presignedUrl = await localStorage.generatePresignedUrl({
|
||||
key: fileKey,
|
||||
expireTime: 3600,
|
||||
});
|
||||
const presignedUrl = await getReferenceImagePublicUrlFromKey(fileKey);
|
||||
|
||||
console.log('[Upload Ref Image] Success, key:', fileKey, 'url length:', presignedUrl?.length);
|
||||
return presignedUrl || null;
|
||||
@@ -764,7 +787,8 @@ async function customApiImageToImage(
|
||||
return NextResponse.json({ error: '自定义API未配置模型名称,请在设置中填写模型名称(如 gpt-image-2)' }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedImage = image;
|
||||
const normalizedImage = image.trim();
|
||||
const storedReferenceKey = localStorage.getKeyFromPublicUrl(normalizedImage);
|
||||
|
||||
// Prepare image buffer for FormData upload
|
||||
let imageBuffer: Buffer | null = null;
|
||||
@@ -775,10 +799,21 @@ async function customApiImageToImage(
|
||||
imageMimeType = parsedImage.mimeType;
|
||||
imageBuffer = parsedImage.buffer;
|
||||
}
|
||||
} else if (storedReferenceKey) {
|
||||
try {
|
||||
imageBuffer = await localStorage.readFileAsync(storedReferenceKey);
|
||||
imageMimeType = getStoredImageMimeType(storedReferenceKey);
|
||||
} catch (e) {
|
||||
console.warn('[Custom API img2img] Failed to read stored reference image:', e);
|
||||
}
|
||||
} else {
|
||||
// It's a URL - download it first
|
||||
try {
|
||||
const imgRes = await fetchPublicHttpUrl(image);
|
||||
const imgRes = await fetchPublicHttpUrlWithRetry(
|
||||
toAbsolutePublicUrl(normalizedImage),
|
||||
{},
|
||||
{ attempts: 3, retryDelayMs: 500, timeoutMs: 45_000 },
|
||||
);
|
||||
if (imgRes.ok) {
|
||||
const contentType = imgRes.headers.get('content-type') || 'image/png';
|
||||
imageMimeType = contentType.split(';')[0];
|
||||
@@ -795,7 +830,9 @@ async function customApiImageToImage(
|
||||
}
|
||||
|
||||
// Upload reference image to S3 to get a public URL (for strategies that use URL instead of file upload)
|
||||
let imageUrl = normalizedImage;
|
||||
let imageUrl = storedReferenceKey
|
||||
? await getReferenceImagePublicUrlFromKey(storedReferenceKey)
|
||||
: toAbsolutePublicUrl(normalizedImage);
|
||||
if (normalizedImage.startsWith('data:')) {
|
||||
console.log('[Custom API img2img] Uploading reference image to S3 to reduce payload...');
|
||||
const uploadedUrl = await uploadDataUrlAndGetPublicUrl(normalizedImage);
|
||||
@@ -1283,7 +1320,10 @@ export async function POST(request: NextRequest) {
|
||||
console.warn('[Image Gen] Failed to upload reference image, skipping');
|
||||
}
|
||||
} else {
|
||||
generateRequest.image = image;
|
||||
const storedReferenceKey = localStorage.getKeyFromPublicUrl(image);
|
||||
generateRequest.image = storedReferenceKey
|
||||
? await getReferenceImagePublicUrlFromKey(storedReferenceKey)
|
||||
: toAbsolutePublicUrl(image);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildCustomApiHeaders, fetchWithRetry, parseCustomApiError } from '@/lib/custom-api-fetch';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { resolveServerApiConfig } from '@/lib/server-api-config';
|
||||
import { isTrustedInternalGenerationRequest, isUuid, resolveServerApiConfig } from '@/lib/server-api-config';
|
||||
import { updateGenerationJobProgress } from '@/lib/generation-job-estimates';
|
||||
|
||||
interface CustomApiConfig {
|
||||
apiUrl: string;
|
||||
@@ -71,6 +72,20 @@ async function persistReferenceImage(image: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPublicAppBaseUrl(request: NextRequest): string {
|
||||
return (process.env.APP_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function toPublicImageUrl(imageUrl: string | null, request: NextRequest): string | null {
|
||||
const value = imageUrl?.trim();
|
||||
if (!value) return null;
|
||||
if (/^https?:\/\//i.test(value)) return value;
|
||||
if (value.startsWith('/')) return `${getPublicAppBaseUrl(request)}${value}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseReversePrompt(content: string): ReversePromptResult {
|
||||
const trimmed = content.trim();
|
||||
const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
|
||||
@@ -194,12 +209,49 @@ export async function POST(request: NextRequest) {
|
||||
if (isDataImage && image.length > MAX_IMAGE_DATA_URL_LENGTH) {
|
||||
return NextResponse.json({ error: '图片过大,请压缩后再上传' }, { status: 400 });
|
||||
}
|
||||
const resolvedCustomApiConfig = await resolveServerApiConfig(request, customApiConfig);
|
||||
const trustedInternalRequest = isTrustedInternalGenerationRequest(request);
|
||||
const trustedUserId = trustedInternalRequest
|
||||
? request.headers.get('x-miaojing-generation-user-id')
|
||||
: null;
|
||||
const generationJobId = trustedInternalRequest
|
||||
? request.headers.get('x-miaojing-generation-job-id')
|
||||
: null;
|
||||
const handleUpstreamProgress = (progress: Record<string, unknown>) => updateGenerationJobProgress(
|
||||
isUuid(generationJobId) ? generationJobId : null,
|
||||
progress,
|
||||
);
|
||||
await handleUpstreamProgress({
|
||||
percent: 10,
|
||||
message: '正在解析参考图片并准备反推提示词',
|
||||
});
|
||||
|
||||
const resolvedCustomApiConfig = await resolveServerApiConfig(
|
||||
request,
|
||||
customApiConfig,
|
||||
isUuid(trustedUserId) ? trustedUserId : null,
|
||||
);
|
||||
if (!resolvedCustomApiConfig?.apiKey || !resolvedCustomApiConfig.apiUrl || !resolvedCustomApiConfig.modelName) {
|
||||
return NextResponse.json({ error: '未配置可用的多模态模型,请先在 API 设置中添加支持图片理解的多模态模型' }, { status: 400 });
|
||||
}
|
||||
console.log(
|
||||
'[Reverse Prompt] Using multimodal model:',
|
||||
resolvedCustomApiConfig.modelName,
|
||||
'| provider:',
|
||||
resolvedCustomApiConfig.provider || 'unknown',
|
||||
'| customApiKeyId:',
|
||||
resolvedCustomApiConfig.customApiKeyId || '',
|
||||
'| systemApiId:',
|
||||
resolvedCustomApiConfig.systemApiId || '',
|
||||
'| apiUrl:',
|
||||
resolvedCustomApiConfig.apiUrl,
|
||||
);
|
||||
const resolvedApiKey = resolvedCustomApiConfig.apiKey;
|
||||
const persistedReferenceImage = await persistReferenceImage(image);
|
||||
const upstreamImage = toPublicImageUrl(persistedReferenceImage, request) || image;
|
||||
await handleUpstreamProgress({
|
||||
percent: 30,
|
||||
message: '已准备图片,正在请求多模态模型',
|
||||
});
|
||||
|
||||
const chatBody = {
|
||||
model: resolvedCustomApiConfig.modelName,
|
||||
@@ -215,7 +267,7 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: image },
|
||||
image_url: { url: upstreamImage },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -236,12 +288,16 @@ export async function POST(request: NextRequest) {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return NextResponse.json(
|
||||
{ error: parseCustomApiError(response.status, errorText) },
|
||||
{ error: parseCustomApiError(response.status, errorText, 'multimodal') },
|
||||
{ status: response.status >= 500 ? 502 : response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
await handleUpstreamProgress({
|
||||
percent: 85,
|
||||
message: '模型已返回,正在整理提示词',
|
||||
});
|
||||
const choices = (data as Record<string, unknown>).choices as Array<Record<string, unknown>> | undefined;
|
||||
const message = choices?.[0]?.message as Record<string, unknown> | undefined;
|
||||
const content = message?.content;
|
||||
|
||||
@@ -118,7 +118,7 @@ export async function POST(request: NextRequest) {
|
||||
const errorText = await response.text();
|
||||
console.error('[Suggest Prompt] API error:', response.status, errorText.slice(0, 200));
|
||||
return NextResponse.json(
|
||||
{ error: parseCustomApiError(response.status, errorText) },
|
||||
{ error: parseCustomApiError(response.status, errorText, 'multimodal') },
|
||||
{ status: response.status >= 500 ? 502 : response.status }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,89 @@ import { writePlatformLog } from '@/lib/platform-logs';
|
||||
import { incrementImageStylePresetUsage } from '@/lib/style-preset-store';
|
||||
import { ensureGenerationCreditsAvailable } from '@/lib/generation-credit-service';
|
||||
|
||||
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running']);
|
||||
|
||||
function parseStatusFilter(value: string | null): string[] {
|
||||
if (!value) return ['queued', 'running'];
|
||||
const statuses = value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
.filter(item => ACTIVE_JOB_STATUSES.has(item));
|
||||
return statuses.length > 0 ? statuses : ['queued', 'running'];
|
||||
}
|
||||
|
||||
function parseTypeFilter(value: string | null): GenerationJobType[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter((item): item is GenerationJobType => item === 'image' || item === 'video' || item === 'reverse-prompt');
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
void markStaleRunningJobs();
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const statuses = parseStatusFilter(request.nextUrl.searchParams.get('status'));
|
||||
const types = parseTypeFilter(request.nextUrl.searchParams.get('type'));
|
||||
const limitParam = Number(request.nextUrl.searchParams.get('limit') || 30);
|
||||
const limit = Number.isFinite(limitParam) ? Math.min(100, Math.max(1, Math.floor(limitParam))) : 30;
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureGenerationJobRuntimeSchema(client);
|
||||
const params: unknown[] = [userId, statuses, limit];
|
||||
let typeClause = '';
|
||||
if (types.length > 0) {
|
||||
params.push(types);
|
||||
typeClause = `AND type = ANY($${params.length}::text[])`;
|
||||
}
|
||||
const result = await client.query(
|
||||
`SELECT id, type, status, result, error, payload, provider, model_name, api_url, progress,
|
||||
created_at, started_at, finished_at, updated_at,
|
||||
CASE
|
||||
WHEN started_at IS NOT NULL
|
||||
THEN FLOOR(EXTRACT(EPOCH FROM (COALESCE(finished_at, NOW()) - started_at)))::int
|
||||
ELSE 0
|
||||
END AS elapsed_seconds
|
||||
FROM generation_jobs
|
||||
WHERE user_id = $1
|
||||
AND status = ANY($2::text[])
|
||||
${typeClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3`,
|
||||
params,
|
||||
);
|
||||
const jobs = result.rows.map(row => {
|
||||
const progress = row.progress && typeof row.progress === 'object' ? row.progress : {};
|
||||
const estimateSeconds = Number(progress.estimateSeconds || progress.etaSeconds || 0)
|
||||
|| (row.type === 'video' ? 300 : row.type === 'reverse-prompt' ? 60 : 90);
|
||||
return {
|
||||
...row,
|
||||
jobId: row.id,
|
||||
estimateSeconds,
|
||||
eta: {
|
||||
estimateSeconds,
|
||||
source: typeof progress.source === 'string' ? progress.source : 'default',
|
||||
sampleCount: Number(progress.sampleCount || 0),
|
||||
windowDays: progress.windowDays ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
return NextResponse.json({ jobs });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[generation-jobs] GET error:', err);
|
||||
return NextResponse.json({ error: '查询生成任务失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
void markStaleRunningJobs();
|
||||
@@ -28,7 +111,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '请先登录后再创建生成任务' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (type !== 'image' && type !== 'video') {
|
||||
if (type !== 'image' && type !== 'video' && type !== 'reverse-prompt') {
|
||||
return NextResponse.json({ error: '不支持的任务类型' }, { status: 400 });
|
||||
}
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
@@ -50,7 +133,9 @@ export async function POST(request: NextRequest) {
|
||||
const identity = await resolveGenerationJobIdentity(client, userId, payload);
|
||||
jobIdentity = identity;
|
||||
try {
|
||||
await ensureGenerationCreditsAvailable(client, userId, { type, payload });
|
||||
if (type === 'image' || type === 'video') {
|
||||
await ensureGenerationCreditsAvailable(client, userId, { type, payload });
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
transactionStarted = false;
|
||||
@@ -128,7 +213,7 @@ export async function POST(request: NextRequest) {
|
||||
type: 'generation',
|
||||
level: 'info',
|
||||
action: 'generation_job_created',
|
||||
message: `用户创建${type === 'image' ? '图片' : '视频'}生成任务`,
|
||||
message: `用户创建${type === 'image' ? '图片' : type === 'video' ? '视频' : '反推提示词'}生成任务`,
|
||||
userId,
|
||||
targetType: 'generation_job',
|
||||
targetId: jobId,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
|
||||
export type ActiveGenerationTask = {
|
||||
id: string;
|
||||
jobId?: string;
|
||||
title: string;
|
||||
startedAt: number;
|
||||
estimateSeconds: number;
|
||||
|
||||
@@ -45,6 +45,7 @@ import { ImageCountCombobox } from '@/components/create/image-count-combobox';
|
||||
import { StylePresetSelector } from '@/components/create/style-preset-selector';
|
||||
import { useImageStylePresets } from '@/lib/style-presets-client';
|
||||
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
|
||||
import { useGenerationJobRecovery } from '@/components/create/use-generation-job-recovery';
|
||||
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';
|
||||
@@ -134,6 +135,10 @@ export function ImageToImagePanel() {
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
|
||||
const generating = activeTasks.length > 0;
|
||||
const activeJobIds = useMemo(
|
||||
() => activeTasks.map(task => task.jobId || task.id).filter((id): id is string => Boolean(id)),
|
||||
[activeTasks],
|
||||
);
|
||||
|
||||
// History
|
||||
const { records, add: addRecord, remove: removeRecord } = useCreationHistory();
|
||||
@@ -483,6 +488,46 @@ export function ImageToImagePanel() {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
}, []);
|
||||
|
||||
useGenerationJobRecovery({
|
||||
types: ['image'],
|
||||
knownJobIds: activeJobIds,
|
||||
onTaskRecovered: task => {
|
||||
setActiveTasks(prev => prev.some(item => item.id === task.id) ? prev : [...prev, task]);
|
||||
},
|
||||
onTaskFinished: (taskId, job) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
const result = job.result as {
|
||||
images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; creditsCost?: number; creditsBalance?: number;
|
||||
} | undefined;
|
||||
const images = Array.isArray(result?.images) ? result!.images : [];
|
||||
if (images.length > 0) {
|
||||
const thumbnails = Object.fromEntries(images.map((url, imageIndex) => [
|
||||
url,
|
||||
result?.thumbnails?.[url] || result?.thumbnailUrls?.[imageIndex] || url,
|
||||
]));
|
||||
setResults(prev => [...images, ...prev]);
|
||||
setResultThumbnails(prev => ({ ...prev, ...thumbnails }));
|
||||
if (result?.dimensions) setResultDimensions(prev => ({ ...prev, ...result.dimensions! }));
|
||||
const creditsCost = Math.max(0, Number(result?.creditsCost || 0));
|
||||
const creditsPerImage = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, images.length)) : 0;
|
||||
if (creditsPerImage > 0) {
|
||||
setResultCredits(prev => Object.fromEntries([
|
||||
...Object.entries(prev),
|
||||
...images.map(url => [url, creditsPerImage] as const),
|
||||
]));
|
||||
}
|
||||
if (typeof result?.creditsBalance === 'number') {
|
||||
updateProfile({ creditsBalance: result.creditsBalance });
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('creation-history-updated'));
|
||||
},
|
||||
onTaskFailed: (taskId, error) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
setGenerationError(createGenerationError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const requestSyncConfirmation = useCallback((taskId: string, message: string) => new Promise<boolean>((resolve) => {
|
||||
syncConfirmationResolversRef.current.set(taskId, resolve);
|
||||
updateActiveTask(taskId, {
|
||||
@@ -577,7 +622,7 @@ export function ImageToImagePanel() {
|
||||
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'image',
|
||||
payload,
|
||||
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
|
||||
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status, jobId: status.jobId || undefined }) },
|
||||
);
|
||||
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number };
|
||||
try {
|
||||
@@ -673,27 +718,31 @@ export function ImageToImagePanel() {
|
||||
if (!result.ok) toast.error(result.error || '下载失败');
|
||||
}, []);
|
||||
|
||||
const handleShareToGallery = useCallback((url: string) => {
|
||||
if (isUrlPublished(url)) {
|
||||
toast.info('该作品已分享到画廊');
|
||||
return;
|
||||
}
|
||||
shareToGallery({
|
||||
type: 'image',
|
||||
url,
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
creditsCost: resultCredits[url] || 0,
|
||||
thumbnailUrl: resultThumbnails[url],
|
||||
width: resultDimensions[url]?.width,
|
||||
height: resultDimensions[url]?.height,
|
||||
params: {
|
||||
creationMode: 'img2img',
|
||||
styleLabel: selectedStylePreset?.label,
|
||||
},
|
||||
});
|
||||
toast.success('已分享到画廊');
|
||||
const handleShareToGallery = useCallback(async (url: string) => {
|
||||
if (isUrlPublished(url)) {
|
||||
toast.info('该作品已分享到画廊');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await shareToGallery({
|
||||
type: 'image',
|
||||
url,
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
creditsCost: resultCredits[url] || 0,
|
||||
thumbnailUrl: resultThumbnails[url],
|
||||
width: resultDimensions[url]?.width,
|
||||
height: resultDimensions[url]?.height,
|
||||
params: {
|
||||
creationMode: 'img2img',
|
||||
styleLabel: selectedStylePreset?.label,
|
||||
},
|
||||
});
|
||||
toast.success('已分享到画廊');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '分享失败,请重试');
|
||||
}
|
||||
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel, resultCredits, resultDimensions, resultThumbnails]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Sparkles, Loader2, Download, Upload, Wand2, Film, History, ChevronDown,
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { triggerDownloadFile } from '@/lib/utils';
|
||||
import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { toast } from 'sonner';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||
import { GenerationErrorPanel, createGenerationError, type GenerationErrorState } from '@/components/create/generation-error-panel';
|
||||
@@ -33,6 +33,7 @@ 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 { useGenerationJobRecovery } from '@/components/create/use-generation-job-recovery';
|
||||
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';
|
||||
|
||||
@@ -73,6 +74,10 @@ export function ImageToVideoPanel() {
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const [referencePreviewSrc, setReferencePreviewSrc] = useState<string | null>(null);
|
||||
const generating = activeTasks.length > 0;
|
||||
const activeJobIds = useMemo(
|
||||
() => activeTasks.map(task => task.jobId || task.id).filter((id): id is string => Boolean(id)),
|
||||
[activeTasks],
|
||||
);
|
||||
|
||||
const { records, add: addRecord } = useCreationHistory();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
@@ -360,6 +365,47 @@ export function ImageToVideoPanel() {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
}, []);
|
||||
|
||||
useGenerationJobRecovery({
|
||||
types: ['video'],
|
||||
knownJobIds: activeJobIds,
|
||||
onTaskRecovered: task => {
|
||||
setActiveTasks(prev => prev.some(item => item.id === task.id) ? prev : [...prev, task]);
|
||||
},
|
||||
onTaskFinished: (taskId, job) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
const result = job.result as { videos?: string[]; creditsCost?: number; creditsBalance?: number } | undefined;
|
||||
if (Array.isArray(result?.videos) && result.videos.length > 0) {
|
||||
const primaryImage = refImages[0]?.dataUrl;
|
||||
setResults(prev => [...result.videos!, ...prev]);
|
||||
setGenerationError(null);
|
||||
const creditsCost = Math.max(0, Number(result?.creditsCost || 0));
|
||||
const creditsPerVideo = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, result.videos.length)) : 0;
|
||||
if (typeof result?.creditsBalance === 'number') {
|
||||
updateProfile({ creditsBalance: result.creditsBalance });
|
||||
}
|
||||
for (const url of result.videos) {
|
||||
addRecord({
|
||||
type: 'video', url, prompt: prompt.trim(),
|
||||
negativePrompt: negativePrompt.trim() || undefined,
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
isCustomModel: isCustomModel(selectedModel) || isSystemModel(selectedModel),
|
||||
referenceImage: primaryImage,
|
||||
referenceImages: refImages.map(img => img.dataUrl),
|
||||
params: { creationMode: 'img2video', aspectRatio, duration, cameraMovement, refImageCount: refImages.length },
|
||||
creditsCost: creditsPerVideo,
|
||||
});
|
||||
}
|
||||
toast.success('视频生成成功');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('creation-history-updated'));
|
||||
},
|
||||
onTaskFailed: (taskId, error) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
setGenerationError(createGenerationError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!user) { toast.error('请先登录'); return; }
|
||||
if (refImages.length === 0 && !prompt.trim()) { toast.error('请上传参考图片或输入视频描述'); return; }
|
||||
@@ -406,7 +452,7 @@ export function ImageToVideoPanel() {
|
||||
const data = await runGenerationJob<{ videos?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'video',
|
||||
requestBody,
|
||||
{ timeoutMs: 600_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
|
||||
{ timeoutMs: 600_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status, jobId: status.jobId || undefined }) },
|
||||
);
|
||||
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
@@ -449,20 +495,24 @@ export function ImageToVideoPanel() {
|
||||
toast.success('已开始下载');
|
||||
}, []);
|
||||
|
||||
const handleShareToGallery = useCallback((url: string) => {
|
||||
if (isUrlPublished(url)) {
|
||||
toast.info('该作品已分享到画廊');
|
||||
return;
|
||||
}
|
||||
shareToGallery({
|
||||
type: 'video',
|
||||
url,
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
});
|
||||
toast.success('已分享到画廊');
|
||||
}, [prompt, selectedModel, getCurrentModelLabel]);
|
||||
const handleShareToGallery = useCallback(async (url: string) => {
|
||||
if (isUrlPublished(url)) {
|
||||
toast.info('该作品已分享到画廊');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await shareToGallery({
|
||||
type: 'video',
|
||||
url,
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
});
|
||||
toast.success('已分享到画廊');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '分享失败,请重试');
|
||||
}
|
||||
}, [prompt, selectedModel, getCurrentModelLabel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useCustomApiKeys } from '@/lib/custom-api-store';
|
||||
import { useManagedSystemApis } from '@/lib/managed-model-store';
|
||||
import { readStoredAuth } from '@/lib/auth-store';
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { toast } from 'sonner';
|
||||
import { ChevronDown, ChevronUp, Copy, FileSearch, Grid3X3, History, Image as ImageIcon, Loader2, Sparkles, Wand2, X } from 'lucide-react';
|
||||
@@ -15,6 +14,8 @@ import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||
import { copyTextToClipboard } from '@/lib/utils';
|
||||
import { compressImageFileForUpload } from '@/lib/browser-image-compression';
|
||||
import { IMAGE_TO_IMAGE_DRAFT_EVENT, IMAGE_TO_IMAGE_DRAFT_KEY, TEXT_TO_IMAGE_DRAFT_EVENT, TEXT_TO_IMAGE_DRAFT_KEY } from '@/lib/creation-reuse';
|
||||
import { GenerationJobStillRunningError, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { useGenerationJobRecovery } from '@/components/create/use-generation-job-recovery';
|
||||
|
||||
type ReversePromptResult = {
|
||||
generalPrompt: string;
|
||||
@@ -70,7 +71,7 @@ interface ReversePromptPanelProps {
|
||||
export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageToImage }: ReversePromptPanelProps) {
|
||||
const { textKeys } = useCustomApiKeys();
|
||||
const managedSystemApis = useManagedSystemApis();
|
||||
const { records, add: addRecord } = useCreationHistory();
|
||||
const { records } = useCreationHistory();
|
||||
|
||||
const [promptMode, setPromptMode] = useState<(typeof promptModes)[number]['value']>('structured');
|
||||
const [reverseImage, setReverseImage] = useState<string | null>(null);
|
||||
@@ -81,6 +82,7 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
|
||||
const [resultView, setResultView] = useState<'components' | 'full'>('components');
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [selectedHistoryRecord, setSelectedHistoryRecord] = useState<CreationRecord | null>(null);
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const systemTextApis = managedSystemApis.filter(api => api.type === 'text' && api.isActive);
|
||||
@@ -106,6 +108,43 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
|
||||
const selectedOutputMode = promptMode;
|
||||
const hasInput = !!reverseImage;
|
||||
|
||||
useGenerationJobRecovery({
|
||||
types: ['reverse-prompt'],
|
||||
knownJobIds: activeJobId ? [activeJobId] : [],
|
||||
onTaskRecovered: (task, job) => {
|
||||
setActiveJobId(task.jobId || task.id);
|
||||
setLoading(true);
|
||||
setGenerationStartedAt(task.startedAt);
|
||||
const outputMode = String((job.payload || {}).outputMode || promptMode);
|
||||
if (outputMode === 'general' || outputMode === 'structured' || outputMode === 'pixel') {
|
||||
setPromptMode(outputMode);
|
||||
}
|
||||
},
|
||||
onTaskFinished: (_taskId, job) => {
|
||||
const resultData = (job.result || {}) as ReversePromptResult & { referenceImage?: string };
|
||||
const next: ReversePromptResult = {
|
||||
generalPrompt: String(resultData.generalPrompt || resultData.structuredPrompt || '').trim(),
|
||||
structuredPrompt: String(resultData.structuredPrompt || resultData.generalPrompt || '').trim(),
|
||||
negativePrompt: String(resultData.negativePrompt || '').trim(),
|
||||
structuredSections: resultData.structuredSections || undefined,
|
||||
};
|
||||
setResult(next);
|
||||
const outputMode = String((job.payload || {}).outputMode || promptMode);
|
||||
if (outputMode === 'general' || outputMode === 'structured' || outputMode === 'pixel') {
|
||||
setPromptMode(outputMode);
|
||||
setResultView(outputMode === 'structured' ? 'components' : 'full');
|
||||
}
|
||||
setLoading(false);
|
||||
setActiveJobId(null);
|
||||
window.dispatchEvent(new CustomEvent('creation-history-updated'));
|
||||
},
|
||||
onTaskFailed: (_taskId, error) => {
|
||||
setLoading(false);
|
||||
setActiveJobId(null);
|
||||
toast.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
const applyImageFile = useCallback(async (file: File) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
@@ -183,18 +222,21 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
|
||||
setResult(null);
|
||||
setGenerationStartedAt(Date.now());
|
||||
setLoading(true);
|
||||
let keepTaskPending = false;
|
||||
try {
|
||||
const accessToken = readStoredAuth().accessToken;
|
||||
const response = await fetch('/api/generate/reverse-prompt', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
const data = await runGenerationJob<ReversePromptResult & { referenceImage?: string }>(
|
||||
'reverse-prompt',
|
||||
{ image: reverseImage, outputMode: selectedOutputMode, language, customApiConfig: selectedConfig },
|
||||
{
|
||||
timeoutMs: 300_000,
|
||||
onStatus: (status: GenerationJobStatus) => {
|
||||
if (status.status === 'running' || status.status === 'queued') {
|
||||
setGenerationStartedAt(prev => prev || Date.now());
|
||||
}
|
||||
if (status.jobId) setActiveJobId(status.jobId);
|
||||
},
|
||||
},
|
||||
body: JSON.stringify({ image: reverseImage, outputMode: selectedOutputMode, language, customApiConfig: selectedConfig }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || '生成提示词失败');
|
||||
);
|
||||
const next: ReversePromptResult = {
|
||||
generalPrompt: String(data.generalPrompt || data.structuredPrompt || '').trim(),
|
||||
structuredPrompt: String(data.structuredPrompt || data.generalPrompt || '').trim(),
|
||||
@@ -203,36 +245,23 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
|
||||
};
|
||||
setResult(next);
|
||||
setResultView(promptMode === 'structured' ? 'components' : 'full');
|
||||
const promptText = (promptMode === 'general' ? next.generalPrompt : next.structuredPrompt).trim();
|
||||
const persistedReferenceImage = typeof data.referenceImage === 'string' && data.referenceImage.trim()
|
||||
? data.referenceImage.trim()
|
||||
: (reverseImage && !reverseImage.startsWith('data:') ? reverseImage : undefined);
|
||||
addRecord({
|
||||
type: 'reverse-prompt',
|
||||
url: persistedReferenceImage || '',
|
||||
prompt: promptText,
|
||||
negativePrompt: next.negativePrompt || undefined,
|
||||
model: selectedConfig.modelName,
|
||||
modelLabel: selectedConfig.modelName || '多模态模型',
|
||||
isCustomModel: true,
|
||||
referenceImage: persistedReferenceImage,
|
||||
params: {
|
||||
creationMode: 'reverse-prompt',
|
||||
outputMode: promptMode,
|
||||
language,
|
||||
generalPrompt: next.generalPrompt,
|
||||
structuredPrompt: next.structuredPrompt,
|
||||
structuredSections: next.structuredSections,
|
||||
sourceImagePersisted: Boolean(persistedReferenceImage),
|
||||
},
|
||||
});
|
||||
toast.success('提示词生成完成');
|
||||
} catch (error) {
|
||||
if (error instanceof GenerationJobStillRunningError) {
|
||||
keepTaskPending = true;
|
||||
setLoading(true);
|
||||
setActiveJobId(null);
|
||||
toast.info('反推任务仍在执行,可稍后返回查看结果');
|
||||
return;
|
||||
}
|
||||
toast.error(error instanceof Error ? error.message : '生成提示词失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!keepTaskPending) {
|
||||
setLoading(false);
|
||||
setActiveJobId(null);
|
||||
}
|
||||
}
|
||||
}, [addRecord, hasInput, language, promptMode, reverseImage, selectedConfig, selectedOutputMode]);
|
||||
}, [hasInput, language, promptMode, reverseImage, selectedConfig, selectedOutputMode]);
|
||||
|
||||
const fullPrompt = result
|
||||
? promptMode === 'general'
|
||||
|
||||
@@ -43,6 +43,7 @@ import { ImageCountCombobox } from '@/components/create/image-count-combobox';
|
||||
import { StylePresetSelector } from '@/components/create/style-preset-selector';
|
||||
import { useImageStylePresets } from '@/lib/style-presets-client';
|
||||
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
|
||||
import { useGenerationJobRecovery } from '@/components/create/use-generation-job-recovery';
|
||||
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';
|
||||
@@ -129,6 +130,10 @@ export function TextToImagePanel() {
|
||||
const activeSubmissionSignaturesRef = useRef(new Set<string>());
|
||||
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
|
||||
const generating = activeTasks.length > 0;
|
||||
const activeJobIds = useMemo(
|
||||
() => activeTasks.map(task => task.jobId || task.id).filter((id): id is string => Boolean(id)),
|
||||
[activeTasks],
|
||||
);
|
||||
|
||||
// History state
|
||||
const { records, add: addRecord, remove: removeRecord } = useCreationHistory();
|
||||
@@ -386,6 +391,52 @@ export function TextToImagePanel() {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
}, []);
|
||||
|
||||
useGenerationJobRecovery({
|
||||
types: ['image'],
|
||||
knownJobIds: activeJobIds,
|
||||
onTaskRecovered: task => {
|
||||
setActiveTasks(prev => prev.some(item => item.id === task.id) ? prev : [...prev, task]);
|
||||
},
|
||||
onTaskFinished: (taskId, job) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
const result = job.result as {
|
||||
images?: string[];
|
||||
thumbnails?: Record<string, string>;
|
||||
thumbnailUrls?: string[];
|
||||
dimensions?: Record<string, { width: number; height: number }>;
|
||||
creditsCost?: number;
|
||||
creditsBalance?: number;
|
||||
} | undefined;
|
||||
const images = Array.isArray(result?.images) ? result!.images : [];
|
||||
if (images.length > 0) {
|
||||
const thumbnails = Object.fromEntries(images.map((url, imageIndex) => [
|
||||
url,
|
||||
result?.thumbnails?.[url] || result?.thumbnailUrls?.[imageIndex] || url,
|
||||
]));
|
||||
setResults(prev => [...images, ...prev]);
|
||||
setResultThumbnails(prev => ({ ...prev, ...thumbnails }));
|
||||
if (result?.dimensions) setResultDimensions(prev => ({ ...prev, ...result.dimensions! }));
|
||||
const creditsCost = Math.max(0, Number(result?.creditsCost || 0));
|
||||
const creditsPerImage = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, images.length)) : 0;
|
||||
if (creditsPerImage > 0) {
|
||||
setResultCredits(prev => Object.fromEntries([
|
||||
...Object.entries(prev),
|
||||
...images.map(url => [url, creditsPerImage] as const),
|
||||
]));
|
||||
}
|
||||
if (typeof result?.creditsBalance === 'number') {
|
||||
updateProfile({ creditsBalance: result.creditsBalance });
|
||||
}
|
||||
setResultPrompt(activeGenerationPrompt || prompt.trim());
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('creation-history-updated'));
|
||||
},
|
||||
onTaskFailed: (taskId, error) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
setGenerationError(createGenerationError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const requestSyncConfirmation = useCallback((taskId: string, message: string) => new Promise<boolean>((resolve) => {
|
||||
syncConfirmationResolversRef.current.set(taskId, resolve);
|
||||
updateActiveTask(taskId, {
|
||||
@@ -497,11 +548,11 @@ export function TextToImagePanel() {
|
||||
|
||||
const runSingleTask = async (taskId: string, index: number) => {
|
||||
try {
|
||||
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'image',
|
||||
payload,
|
||||
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
|
||||
);
|
||||
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'image',
|
||||
payload,
|
||||
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status, jobId: status.jobId || undefined }) },
|
||||
);
|
||||
let data: { images?: string[]; thumbnails?: Record<string, string>; thumbnailUrls?: string[]; dimensions?: Record<string, { width: number; height: number }>; error?: string; creditsCost?: number; creditsBalance?: number };
|
||||
try {
|
||||
data = await runJob({ ...requestBodyBase, count: 1, clientRequestId: `${batchId}-${index + 1}`, stream: true });
|
||||
@@ -620,27 +671,31 @@ export function TextToImagePanel() {
|
||||
if (!result.ok) toast.error(result.error || '下载失败');
|
||||
}, []);
|
||||
|
||||
const handleShareToGallery = useCallback((url: string) => {
|
||||
const handleShareToGallery = useCallback(async (url: string) => {
|
||||
if (isUrlPublished(url)) {
|
||||
toast.info('该作品已分享到画廊');
|
||||
return;
|
||||
}
|
||||
shareToGallery({
|
||||
type: 'image',
|
||||
url,
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
creditsCost: resultCredits[url] || 0,
|
||||
thumbnailUrl: resultThumbnails[url],
|
||||
width: resultDimensions[url]?.width,
|
||||
height: resultDimensions[url]?.height,
|
||||
params: {
|
||||
creationMode: 'text2img',
|
||||
styleLabel: selectedStylePreset?.label,
|
||||
},
|
||||
});
|
||||
toast.success('已分享到画廊');
|
||||
try {
|
||||
await shareToGallery({
|
||||
type: 'image',
|
||||
url,
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
creditsCost: resultCredits[url] || 0,
|
||||
thumbnailUrl: resultThumbnails[url],
|
||||
width: resultDimensions[url]?.width,
|
||||
height: resultDimensions[url]?.height,
|
||||
params: {
|
||||
creationMode: 'text2img',
|
||||
styleLabel: selectedStylePreset?.label,
|
||||
},
|
||||
});
|
||||
toast.success('已分享到画廊');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '分享失败,请重试');
|
||||
}
|
||||
}, [prompt, selectedModel, selectedStylePreset, getCurrentModelLabel, resultCredits, resultDimensions, resultThumbnails]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,6 +33,7 @@ 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 { useGenerationJobRecovery } from '@/components/create/use-generation-job-recovery';
|
||||
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';
|
||||
|
||||
@@ -66,6 +67,10 @@ export function TextToVideoPanel() {
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [inspirationOpen, setInspirationOpen] = useState(false);
|
||||
const generating = activeTasks.length > 0;
|
||||
const activeJobIds = useMemo(
|
||||
() => activeTasks.map(task => task.jobId || task.id).filter((id): id is string => Boolean(id)),
|
||||
[activeTasks],
|
||||
);
|
||||
|
||||
const { records, add: addRecord } = useCreationHistory();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
@@ -252,6 +257,44 @@ export function TextToVideoPanel() {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
}, []);
|
||||
|
||||
useGenerationJobRecovery({
|
||||
types: ['video'],
|
||||
knownJobIds: activeJobIds,
|
||||
onTaskRecovered: task => {
|
||||
setActiveTasks(prev => prev.some(item => item.id === task.id) ? prev : [...prev, task]);
|
||||
},
|
||||
onTaskFinished: (taskId, job) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
const result = job.result as { videos?: string[]; creditsCost?: number; creditsBalance?: number } | undefined;
|
||||
if (Array.isArray(result?.videos) && result.videos.length > 0) {
|
||||
setResults(prev => [...result.videos!, ...prev]);
|
||||
setGenerationError(null);
|
||||
const creditsCost = Math.max(0, Number(result?.creditsCost || 0));
|
||||
const creditsPerVideo = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, result.videos.length)) : 0;
|
||||
if (typeof result?.creditsBalance === 'number') {
|
||||
updateProfile({ creditsBalance: result.creditsBalance });
|
||||
}
|
||||
for (const url of result.videos) {
|
||||
addRecord({
|
||||
type: 'video', url, prompt: prompt.trim(),
|
||||
negativePrompt: negativePrompt.trim() || undefined,
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
isCustomModel: isCustomModel(selectedModel) || isSystemModel(selectedModel),
|
||||
params: { creationMode: 'text2video', aspectRatio, duration, cameraMovement, style },
|
||||
creditsCost: creditsPerVideo,
|
||||
});
|
||||
}
|
||||
toast.success('视频生成成功');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('creation-history-updated'));
|
||||
},
|
||||
onTaskFailed: (taskId, error) => {
|
||||
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
|
||||
setGenerationError(createGenerationError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!prompt.trim()) { toast.error('请输入视频描述'); return; }
|
||||
if (!user) { toast.error('请先登录'); return; }
|
||||
@@ -294,7 +337,7 @@ export function TextToVideoPanel() {
|
||||
const data = await runGenerationJob<{ videos?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'video',
|
||||
requestBody,
|
||||
{ timeoutMs: 600_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
|
||||
{ timeoutMs: 600_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status, jobId: status.jobId || undefined }) },
|
||||
);
|
||||
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
@@ -335,19 +378,23 @@ export function TextToVideoPanel() {
|
||||
toast.success('已开始下载');
|
||||
}, []);
|
||||
|
||||
const handleShareToGallery = useCallback((url: string) => {
|
||||
const handleShareToGallery = useCallback(async (url: string) => {
|
||||
if (isUrlPublished(url)) {
|
||||
toast.info('该作品已分享到画廊');
|
||||
return;
|
||||
}
|
||||
shareToGallery({
|
||||
type: 'video',
|
||||
url,
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
});
|
||||
toast.success('已分享到画廊');
|
||||
try {
|
||||
await shareToGallery({
|
||||
type: 'video',
|
||||
url,
|
||||
prompt: prompt.trim(),
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
});
|
||||
toast.success('已分享到画廊');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '分享失败,请重试');
|
||||
}
|
||||
}, [prompt, selectedModel, getCurrentModelLabel]);
|
||||
|
||||
return (
|
||||
|
||||
158
src/components/create/use-generation-job-recovery.ts
Normal file
158
src/components/create/use-generation-job-recovery.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import type { ActiveGenerationTask } from '@/components/create/generation-task-list';
|
||||
import {
|
||||
GenerationJobStillRunningError,
|
||||
continueGenerationJob,
|
||||
fetchActiveGenerationJobs,
|
||||
type GenerationJobStatus,
|
||||
type GenerationJobType,
|
||||
} from '@/lib/generation-job-client';
|
||||
|
||||
type RecoverGenerationTaskOptions = {
|
||||
types: GenerationJobType[];
|
||||
knownJobIds?: string[];
|
||||
onTaskRecovered: (task: ActiveGenerationTask, job: GenerationJobStatus) => void;
|
||||
onTaskFinished: (taskId: string, job: GenerationJobStatus) => void;
|
||||
onTaskFailed: (taskId: string, error: string, job?: GenerationJobStatus | null) => void;
|
||||
isEnabled?: boolean;
|
||||
};
|
||||
|
||||
function toJobTaskTitle(type: GenerationJobType): string {
|
||||
if (type === 'video') return '正在生成视频';
|
||||
if (type === 'reverse-prompt') return '正在反推提示词';
|
||||
return '正在生成图片';
|
||||
}
|
||||
|
||||
function toTaskEstimateSeconds(job: GenerationJobStatus): number {
|
||||
if (typeof job.estimateSeconds === 'number' && Number.isFinite(job.estimateSeconds) && job.estimateSeconds > 0) {
|
||||
return Math.ceil(job.estimateSeconds);
|
||||
}
|
||||
if (job.type === 'video') return 300;
|
||||
if (job.type === 'reverse-prompt') return 60;
|
||||
return 90;
|
||||
}
|
||||
|
||||
function normalizeJobTask(job: GenerationJobStatus): ActiveGenerationTask | null {
|
||||
const id = String(job.jobId || job.id || '');
|
||||
if (!id) return null;
|
||||
const type = job.type || 'image';
|
||||
return {
|
||||
id,
|
||||
jobId: id,
|
||||
title: toJobTaskTitle(type),
|
||||
startedAt: job.started_at ? new Date(job.started_at).getTime() : Date.now(),
|
||||
estimateSeconds: toTaskEstimateSeconds(job),
|
||||
jobStatus: job,
|
||||
finalCountdownSeconds: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function useGenerationJobRecovery({
|
||||
types,
|
||||
knownJobIds = [],
|
||||
onTaskRecovered,
|
||||
onTaskFinished,
|
||||
onTaskFailed,
|
||||
isEnabled = true,
|
||||
}: RecoverGenerationTaskOptions) {
|
||||
const activeJobIdsRef = useRef(new Set<string>());
|
||||
const inFlightRecoveryRef = useRef(false);
|
||||
const onTaskRecoveredRef = useRef(onTaskRecovered);
|
||||
const onTaskFinishedRef = useRef(onTaskFinished);
|
||||
const onTaskFailedRef = useRef(onTaskFailed);
|
||||
const normalizedTypes = useMemo(() => types.slice().sort().join(','), [types]);
|
||||
const normalizedKnownJobIds = useMemo(
|
||||
() => new Set(knownJobIds.map(id => id.trim()).filter(Boolean)),
|
||||
[knownJobIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onTaskRecoveredRef.current = onTaskRecovered;
|
||||
}, [onTaskRecovered]);
|
||||
useEffect(() => {
|
||||
onTaskFinishedRef.current = onTaskFinished;
|
||||
}, [onTaskFinished]);
|
||||
useEffect(() => {
|
||||
onTaskFailedRef.current = onTaskFailed;
|
||||
}, [onTaskFailed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
let cancelled = false;
|
||||
|
||||
const recover = async () => {
|
||||
if (inFlightRecoveryRef.current) return;
|
||||
inFlightRecoveryRef.current = true;
|
||||
try {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 800));
|
||||
if (cancelled) return;
|
||||
const jobs = await fetchActiveGenerationJobs(types);
|
||||
if (cancelled) return;
|
||||
for (const job of jobs) {
|
||||
const task = normalizeJobTask(job);
|
||||
if (!task || activeJobIdsRef.current.has(task.id) || normalizedKnownJobIds.has(task.id)) continue;
|
||||
activeJobIdsRef.current.add(task.id);
|
||||
onTaskRecoveredRef.current(task, job);
|
||||
void (async () => {
|
||||
const timeoutMs = job.type === 'video' ? 600_000 : job.type === 'reverse-prompt' ? 300_000 : 900_000;
|
||||
const onStatus = (status: GenerationJobStatus) => {
|
||||
if (cancelled) return;
|
||||
if (status.status === 'failed') {
|
||||
activeJobIdsRef.current.delete(task.id);
|
||||
onTaskFailedRef.current(task.id, status.error || '生成任务失败', status);
|
||||
return;
|
||||
}
|
||||
if (status.status === 'succeeded') {
|
||||
activeJobIdsRef.current.delete(task.id);
|
||||
onTaskFinishedRef.current(task.id, status);
|
||||
}
|
||||
};
|
||||
|
||||
while (!cancelled && activeJobIdsRef.current.has(task.id)) {
|
||||
try {
|
||||
await continueGenerationJob(task.id, {
|
||||
timeoutMs,
|
||||
onStatus,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
if (error instanceof GenerationJobStillRunningError) {
|
||||
await sleep(3000);
|
||||
continue;
|
||||
}
|
||||
console.warn('[generation-job-recovery] polling retry after error:', error);
|
||||
await sleep(5000);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
window.setTimeout(() => {
|
||||
if (!cancelled) void recover();
|
||||
}, 5000);
|
||||
}
|
||||
} finally {
|
||||
inFlightRecoveryRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void recover();
|
||||
|
||||
const handleAuthUpdated = () => {
|
||||
void recover();
|
||||
};
|
||||
window.addEventListener('miaojing_auth_updated', handleAuthUpdated);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('miaojing_auth_updated', handleAuthUpdated);
|
||||
};
|
||||
}, [isEnabled, normalizedKnownJobIds, normalizedTypes, types]);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
|
||||
useEffect(() => {
|
||||
if (record) {
|
||||
setIsPublished(record.published || isUrlPublished(record.url));
|
||||
setIsPublished(isUrlPublished(record.url));
|
||||
}
|
||||
}, [record]);
|
||||
|
||||
@@ -252,8 +252,8 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
setIsPublished(true);
|
||||
onPublishChange?.();
|
||||
toast.success('已分享到画廊');
|
||||
} catch {
|
||||
toast.error('分享失败,请重试');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '分享失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface CreationRecord {
|
||||
params: Record<string, unknown>;
|
||||
createdAt: string; // ISO date string
|
||||
published?: boolean; // Whether this work is published to the gallery
|
||||
publishedAt?: string; // Set only after a confirmed gallery publish
|
||||
referenceImage?: string; // For img2img: the reference image URL
|
||||
referenceImages?: string[]; // Optional multiple reference image URLs
|
||||
publisherNickname?: string; // Set when publishing
|
||||
@@ -226,6 +227,7 @@ export function addCreationRecord(record: Omit<CreationRecord, 'id' | 'createdAt
|
||||
id: records[existingIndex].id,
|
||||
createdAt: records[existingIndex].createdAt,
|
||||
published: records[existingIndex].published || newRecord.published,
|
||||
publishedAt: records[existingIndex].publishedAt || newRecord.publishedAt,
|
||||
};
|
||||
saveRecords(records);
|
||||
return records[existingIndex];
|
||||
@@ -369,6 +371,7 @@ export function publishWork(
|
||||
const idx = records.findIndex(r => r.id === record.id);
|
||||
if (idx !== -1) {
|
||||
records[idx].published = true;
|
||||
records[idx].publishedAt = new Date().toISOString();
|
||||
records[idx].publisherNickname = publisherNickname;
|
||||
saveRecords(records);
|
||||
}
|
||||
@@ -403,6 +406,7 @@ export function unpublishWork(id: string): void {
|
||||
const idx = records.findIndex(r => r.id === id);
|
||||
if (idx !== -1) {
|
||||
records[idx].published = false;
|
||||
delete records[idx].publishedAt;
|
||||
saveRecords(records);
|
||||
}
|
||||
const works = loadPublished().filter(w => w.id !== id);
|
||||
@@ -427,13 +431,43 @@ export async function shareToGallery(options: {
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}): Promise<void> {
|
||||
// Save to localStorage for immediate local display
|
||||
const works = loadPublished();
|
||||
// Prevent duplicates by URL
|
||||
if (works.some(w => w.url === options.url)) return;
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
throw new Error('请先登录后再分享作品');
|
||||
}
|
||||
|
||||
works.unshift({
|
||||
id: `pub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
const res = await fetch('/api/gallery/publish', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: options.publisherId,
|
||||
type: options.type,
|
||||
prompt: options.prompt,
|
||||
negativePrompt: options.negativePrompt,
|
||||
resultUrl: options.url,
|
||||
thumbnailUrl: options.thumbnailUrl,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
model: options.model,
|
||||
modelLabel: options.modelLabel,
|
||||
referenceImage: options.referenceImage,
|
||||
referenceImages: options.referenceImages,
|
||||
params: options.params,
|
||||
creditsCost: options.creditsCost,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(typeof data.error === 'string' ? data.error : '分享失败,请重试');
|
||||
}
|
||||
|
||||
const works = loadPublished();
|
||||
const existingIndex = works.findIndex(w => w.url === options.url);
|
||||
const publishedWork: PublishedWork = {
|
||||
id: existingIndex >= 0 ? works[existingIndex].id : `pub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: options.type,
|
||||
url: options.url,
|
||||
thumbnailUrl: options.thumbnailUrl,
|
||||
@@ -450,43 +484,17 @@ export async function shareToGallery(options: {
|
||||
publisherId: options.publisherId || 'anonymous',
|
||||
publisherNickname: options.publisherNickname || '匿名用户',
|
||||
publishedAt: new Date().toISOString(),
|
||||
likes: 0,
|
||||
likes: existingIndex >= 0 ? works[existingIndex].likes : 0,
|
||||
creditsCost: options.creditsCost,
|
||||
});
|
||||
};
|
||||
if (existingIndex >= 0) {
|
||||
works[existingIndex] = publishedWork;
|
||||
} else {
|
||||
works.unshift(publishedWork);
|
||||
}
|
||||
savePublished(works);
|
||||
window.dispatchEvent(new CustomEvent('creation-history-updated'));
|
||||
|
||||
// Also persist to Supabase
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
const res = await fetch('/api/gallery/publish', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: options.publisherId,
|
||||
type: options.type,
|
||||
prompt: options.prompt,
|
||||
negativePrompt: options.negativePrompt,
|
||||
resultUrl: options.url,
|
||||
thumbnailUrl: options.thumbnailUrl,
|
||||
width: options.width,
|
||||
height: options.height,
|
||||
model: options.model,
|
||||
modelLabel: options.modelLabel,
|
||||
referenceImage: options.referenceImage,
|
||||
referenceImages: options.referenceImages,
|
||||
params: options.params,
|
||||
creditsCost: options.creditsCost,
|
||||
}),
|
||||
});
|
||||
await res.json().catch(() => ({}));
|
||||
} catch {
|
||||
// Non-critical — localStorage version is already saved
|
||||
}
|
||||
|
||||
// Mark the corresponding creation record as published
|
||||
markRecordAsPublished(options.url);
|
||||
}
|
||||
@@ -495,8 +503,9 @@ export async function shareToGallery(options: {
|
||||
export function markRecordAsPublished(url: string): void {
|
||||
const records = loadRecords();
|
||||
const idx = records.findIndex(r => r.url === url);
|
||||
if (idx !== -1 && !records[idx].published) {
|
||||
if (idx !== -1) {
|
||||
records[idx].published = true;
|
||||
records[idx].publishedAt = new Date().toISOString();
|
||||
saveRecords(records);
|
||||
window.dispatchEvent(new CustomEvent('creation-history-updated'));
|
||||
}
|
||||
@@ -506,10 +515,7 @@ export function markRecordAsPublished(url: string): void {
|
||||
export function isUrlPublished(url: string): boolean {
|
||||
// Check creation records
|
||||
const records = loadRecords();
|
||||
if (records.some(r => r.url === url && r.published)) return true;
|
||||
// Check published works
|
||||
const published = loadPublished();
|
||||
if (published.some(w => w.url === url)) return true;
|
||||
if (records.some(r => r.url === url && r.published && r.publishedAt)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ const STREAM_EVENTS_FIELD = '__streamEvents';
|
||||
const STREAM_TEXT_FIELD = '__streamText';
|
||||
export { STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX };
|
||||
|
||||
export type CustomApiErrorContext = 'image' | 'multimodal';
|
||||
|
||||
/**
|
||||
* Default headers that mimic a browser-like HTTP client.
|
||||
*
|
||||
@@ -281,7 +283,7 @@ export async function parseCustomApiJsonWithProgress(
|
||||
throw new Error('上游接口未返回可解析的结果数据');
|
||||
}
|
||||
|
||||
export function parseCustomApiError(status: number, rawBody: string): string {
|
||||
export function parseCustomApiError(status: number, rawBody: string, context: CustomApiErrorContext = 'image'): string {
|
||||
const trimmed = rawBody.trim();
|
||||
if (trimmed.startsWith(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX)) return trimmed;
|
||||
if (
|
||||
@@ -294,7 +296,9 @@ export function parseCustomApiError(status: number, rawBody: string): string {
|
||||
return '参考图请求体过大,上游模型服务拒绝接收。平台不会压缩用户图片;请更换更小的参考图,或让 API 供应商提高图生图上传限制。';
|
||||
}
|
||||
if (status === 524 || /cloudflare|error code 524|a timeout occurred|origin web server timed out/i.test(trimmed)) {
|
||||
return '上游 API 同步生图请求超时(Cloudflare 524)。请确认该供应商已开启流式生图或异步任务接口;高分辨率生图不要走会长时间无响应的同步接口。';
|
||||
return context === 'multimodal'
|
||||
? '上游多模态模型同步请求超时(Cloudflare 524)。请确认该供应商已开启支持图片输入的多模态接口或异步任务接口;多模态请求不要走会长时间无响应的同步接口。'
|
||||
: '上游 API 同步生图请求超时(Cloudflare 524)。请确认该供应商已开启流式生图或异步任务接口;高分辨率生图不要走会长时间无响应的同步接口。';
|
||||
}
|
||||
if (
|
||||
[502, 503, 504].includes(status)
|
||||
|
||||
77
src/lib/gallery-publish-media.ts
Normal file
77
src/lib/gallery-publish-media.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import {
|
||||
ensureLocalImageThumbnail,
|
||||
ensureLocalVideoThumbnail,
|
||||
} from '@/lib/media-storage';
|
||||
|
||||
type GalleryPublishType = 'image' | 'video' | string;
|
||||
|
||||
type GalleryPublishMediaInput = {
|
||||
type: GalleryPublishType;
|
||||
resultUrl: string;
|
||||
thumbnailUrl?: string | null;
|
||||
prompt?: string | null;
|
||||
};
|
||||
|
||||
export type GalleryPublishMediaResult = {
|
||||
resultUrl: string;
|
||||
thumbnailUrl: string | null;
|
||||
};
|
||||
|
||||
export type GalleryPublishMediaDeps = {
|
||||
copyPublicUrlToFolder: (
|
||||
url: string,
|
||||
folder: string,
|
||||
options: { storageTarget?: 'default' | 'local' | 'object' },
|
||||
) => Promise<string>;
|
||||
ensureLocalImageThumbnail: (url: string, thumbnailPrefix: string) => Promise<string | null>;
|
||||
ensureLocalVideoThumbnail: (url: string, thumbnailPrefix: string, label?: string) => Promise<string | null>;
|
||||
};
|
||||
|
||||
const defaultDeps: GalleryPublishMediaDeps = {
|
||||
copyPublicUrlToFolder: (url, folder, options) => localStorage.copyPublicUrlToFolder(url, folder, options),
|
||||
ensureLocalImageThumbnail,
|
||||
ensureLocalVideoThumbnail,
|
||||
};
|
||||
|
||||
function isStableLocalStorageUrl(url: string): boolean {
|
||||
return url.startsWith('/api/local-storage/');
|
||||
}
|
||||
|
||||
export async function resolveGalleryPublishMedia(
|
||||
input: GalleryPublishMediaInput,
|
||||
deps: GalleryPublishMediaDeps = defaultDeps,
|
||||
): Promise<GalleryPublishMediaResult> {
|
||||
let galleryResultUrl = input.resultUrl;
|
||||
let galleryThumbnailUrl = input.thumbnailUrl || null;
|
||||
|
||||
if (input.type === 'video') {
|
||||
if (!isStableLocalStorageUrl(input.resultUrl)) {
|
||||
galleryResultUrl = await deps.copyPublicUrlToFolder(input.resultUrl, 'gallery/videos', { storageTarget: 'object' });
|
||||
}
|
||||
const generatedVideoThumbnailUrl = await deps.ensureLocalVideoThumbnail(
|
||||
galleryResultUrl,
|
||||
'thumbnails/gallery/videos',
|
||||
String(input.prompt || 'Video'),
|
||||
);
|
||||
let copiedVideoThumbnailUrl: string | null = null;
|
||||
if (!generatedVideoThumbnailUrl && input.thumbnailUrl) {
|
||||
copiedVideoThumbnailUrl = await deps.copyPublicUrlToFolder(input.thumbnailUrl, 'gallery/thumbnails', { storageTarget: 'local' });
|
||||
}
|
||||
return {
|
||||
resultUrl: galleryResultUrl,
|
||||
thumbnailUrl: generatedVideoThumbnailUrl || copiedVideoThumbnailUrl || galleryThumbnailUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isStableLocalStorageUrl(input.resultUrl)) {
|
||||
galleryResultUrl = await deps.copyPublicUrlToFolder(input.resultUrl, 'gallery/images', { storageTarget: 'object' });
|
||||
galleryThumbnailUrl = await deps.ensureLocalImageThumbnail(galleryResultUrl, 'thumbnails/gallery')
|
||||
|| galleryThumbnailUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
resultUrl: galleryResultUrl,
|
||||
thumbnailUrl: galleryThumbnailUrl,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
type GenerationJobType = 'image' | 'video';
|
||||
export type GenerationJobType = 'image' | 'video' | 'reverse-prompt';
|
||||
|
||||
export type GenerationJobStatus = {
|
||||
id?: string;
|
||||
@@ -7,6 +7,7 @@ export type GenerationJobStatus = {
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
result?: Record<string, unknown>;
|
||||
error?: string | null;
|
||||
payload?: Record<string, unknown>;
|
||||
estimateSeconds?: number;
|
||||
elapsed_seconds?: number;
|
||||
progress?: Record<string, unknown>;
|
||||
@@ -28,6 +29,10 @@ type GenerationJobOptions = {
|
||||
onStatus?: (status: GenerationJobStatus) => void;
|
||||
};
|
||||
|
||||
type PollGenerationJobOptions = GenerationJobOptions & {
|
||||
jobId: string;
|
||||
};
|
||||
|
||||
export class GenerationJobStillRunningError extends Error {
|
||||
status: GenerationJobStatus | null;
|
||||
|
||||
@@ -63,6 +68,81 @@ export async function runGenerationFinalCountdown(
|
||||
onTick(0);
|
||||
}
|
||||
|
||||
async function pollGenerationJob<T extends Record<string, unknown>>(
|
||||
options: PollGenerationJobOptions,
|
||||
): Promise<T> {
|
||||
const timeoutMs = options.timeoutMs ?? 900_000;
|
||||
const intervalMs = options.intervalMs ?? 2_000;
|
||||
const startedAt = Date.now();
|
||||
let lastStatus: GenerationJobStatus | null = null;
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await sleep(intervalMs);
|
||||
|
||||
const statusRes = await fetch(`/api/generation-jobs/${encodeURIComponent(options.jobId)}`, {
|
||||
headers: {
|
||||
...(getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}),
|
||||
},
|
||||
});
|
||||
const statusData = await statusRes.json().catch(() => ({}));
|
||||
if (!statusRes.ok) {
|
||||
throw new Error(statusData.error || `任务查询失败 (${statusRes.status})`);
|
||||
}
|
||||
options.onStatus?.(statusData as GenerationJobStatus);
|
||||
lastStatus = statusData as GenerationJobStatus;
|
||||
|
||||
if (statusData.status === 'succeeded') {
|
||||
return (statusData.result || {}) as T;
|
||||
}
|
||||
if (statusData.status === 'failed') {
|
||||
throw new Error(statusData.error || '生成任务失败');
|
||||
}
|
||||
}
|
||||
|
||||
throw new GenerationJobStillRunningError(lastStatus);
|
||||
}
|
||||
|
||||
export async function continueGenerationJob<T extends Record<string, unknown>>(
|
||||
jobId: string,
|
||||
options: GenerationJobOptions = {},
|
||||
): Promise<T> {
|
||||
return pollGenerationJob<T>({ ...options, jobId });
|
||||
}
|
||||
|
||||
export async function continueGenerationJobUntilSettled<T extends Record<string, unknown>>(
|
||||
jobId: string,
|
||||
options: GenerationJobOptions & { retryDelayMs?: number } = {},
|
||||
): Promise<T> {
|
||||
const retryDelayMs = options.retryDelayMs ?? 3000;
|
||||
while (true) {
|
||||
try {
|
||||
return await continueGenerationJob<T>(jobId, options);
|
||||
} catch (error) {
|
||||
if (error instanceof GenerationJobStillRunningError) {
|
||||
await sleep(retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchActiveGenerationJobs(types?: GenerationJobType[]): Promise<GenerationJobStatus[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('status', 'queued,running');
|
||||
if (types && types.length > 0) params.set('type', types.join(','));
|
||||
const res = await fetch(`/api/generation-jobs?${params.toString()}`, {
|
||||
headers: {
|
||||
...(getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}),
|
||||
},
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `任务列表查询失败 (${res.status})`);
|
||||
}
|
||||
return Array.isArray(data.jobs) ? data.jobs as GenerationJobStatus[] : [];
|
||||
}
|
||||
|
||||
export async function runGenerationJob<T extends Record<string, unknown>>(
|
||||
type: GenerationJobType,
|
||||
payload: Record<string, unknown>,
|
||||
@@ -86,33 +166,9 @@ export async function runGenerationJob<T extends Record<string, unknown>>(
|
||||
status: 'queued',
|
||||
} as GenerationJobStatus);
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? (type === 'video' ? 600_000 : 900_000);
|
||||
const intervalMs = options.intervalMs ?? 2_000;
|
||||
const startedAt = Date.now();
|
||||
let lastStatus: GenerationJobStatus | null = null;
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await sleep(intervalMs);
|
||||
|
||||
const statusRes = await fetch(`/api/generation-jobs/${encodeURIComponent(createData.jobId)}`, {
|
||||
headers: {
|
||||
...(getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}),
|
||||
},
|
||||
});
|
||||
const statusData = await statusRes.json().catch(() => ({}));
|
||||
if (!statusRes.ok) {
|
||||
throw new Error(statusData.error || `任务查询失败 (${statusRes.status})`);
|
||||
}
|
||||
options.onStatus?.(statusData as GenerationJobStatus);
|
||||
lastStatus = statusData as GenerationJobStatus;
|
||||
|
||||
if (statusData.status === 'succeeded') {
|
||||
return (statusData.result || {}) as T;
|
||||
}
|
||||
if (statusData.status === 'failed') {
|
||||
throw new Error(statusData.error || '生成任务失败');
|
||||
}
|
||||
}
|
||||
|
||||
throw new GenerationJobStillRunningError(lastStatus);
|
||||
return pollGenerationJob<T>({
|
||||
...options,
|
||||
timeoutMs: options.timeoutMs ?? (type === 'video' ? 600_000 : 900_000),
|
||||
jobId: createData.jobId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@ export interface GenerationJobEstimate {
|
||||
const DEFAULT_ESTIMATES: Record<GenerationJobType, number> = {
|
||||
image: 90,
|
||||
video: 300,
|
||||
'reverse-prompt': 60,
|
||||
};
|
||||
|
||||
const ESTIMATE_LIMITS: Record<GenerationJobType, { min: number; max: number }> = {
|
||||
image: { min: 20, max: 900 },
|
||||
video: { min: 60, max: 1800 },
|
||||
'reverse-prompt': { min: 10, max: 300 },
|
||||
};
|
||||
|
||||
let generationJobSchemaReady = false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getInternalGenerationHeaders } from '@/lib/server-api-config';
|
||||
|
||||
export type GenerationJobType = 'image' | 'video';
|
||||
export type GenerationJobType = 'image' | 'video' | 'reverse-prompt';
|
||||
|
||||
export async function runGenerationPayload(
|
||||
type: GenerationJobType,
|
||||
@@ -9,7 +9,7 @@ export async function runGenerationPayload(
|
||||
) {
|
||||
const port = process.env.PORT || process.env.DEPLOY_RUN_PORT || '5000';
|
||||
const baseUrl = process.env.GENERATION_INTERNAL_BASE_URL || `http://127.0.0.1:${port}`;
|
||||
const endpoint = type === 'image' ? '/api/generate/image' : '/api/generate/video';
|
||||
const endpoint = type === 'image' ? '/api/generate/image' : type === 'video' ? '/api/generate/video' : '/api/generate/reverse-prompt';
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...getInternalGenerationHeaders(),
|
||||
|
||||
@@ -6,12 +6,216 @@ import {
|
||||
import { ensureGenerationJobRuntimeSchema } from '@/lib/generation-job-estimates';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
import { chargeGenerationCredits } from '@/lib/generation-credit-service';
|
||||
import { getInternalGenerationHeaders } from '@/lib/server-api-config';
|
||||
|
||||
const POLL_INTERVAL_MS = Number(process.env.GENERATION_WORKER_INTERVAL_MS || 5000);
|
||||
const STALE_RUNNING_MINUTES = Number(process.env.GENERATION_JOB_TIMEOUT_MINUTES || 30);
|
||||
|
||||
let processing = false;
|
||||
|
||||
function getInternalBaseUrl() {
|
||||
const port = process.env.PORT || process.env.DEPLOY_RUN_PORT || '5000';
|
||||
return process.env.GENERATION_INTERNAL_BASE_URL || `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
function safeString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function safePublicUrl(value: unknown): string | undefined {
|
||||
const text = safeString(value);
|
||||
if (!text || text.startsWith('data:') || text.startsWith('[')) return undefined;
|
||||
return text;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function getPayloadConfig(payload: Record<string, unknown>) {
|
||||
return asRecord(payload.customApiConfig);
|
||||
}
|
||||
|
||||
function getModelName(payload: Record<string, unknown>): string {
|
||||
const config = getPayloadConfig(payload);
|
||||
return safeString(config.modelName) || safeString(payload.modelName) || safeString(payload.model);
|
||||
}
|
||||
|
||||
function getSafeReferenceImages(payload: Record<string, unknown>): string[] {
|
||||
const references = [
|
||||
safePublicUrl(payload.image),
|
||||
...(Array.isArray(payload.images) ? payload.images.map(safePublicUrl) : []),
|
||||
...(Array.isArray(payload.extraImages) ? payload.extraImages.map(safePublicUrl) : []),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
return Array.from(new Set(references));
|
||||
}
|
||||
|
||||
function countReferenceInputs(payload: Record<string, unknown>): number {
|
||||
if (typeof payload.image === 'string' && payload.image.trim()) return 1;
|
||||
if (Array.isArray(payload.images)) return payload.images.length;
|
||||
if (Array.isArray(payload.extraImages)) return payload.extraImages.length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sanitizeHistoryParams(payload: Record<string, unknown>, extra: Record<string, unknown> = {}) {
|
||||
const rest = { ...payload };
|
||||
delete rest.image;
|
||||
delete rest.images;
|
||||
delete rest.extraImages;
|
||||
delete rest.customApiConfig;
|
||||
const config = getPayloadConfig(payload);
|
||||
const references = getSafeReferenceImages(payload);
|
||||
return {
|
||||
...rest,
|
||||
...extra,
|
||||
model: getModelName(payload),
|
||||
modelLabel: safeString(config.modelName) || safeString(payload.modelLabel) || getModelName(payload),
|
||||
isCustomModel: Boolean(config.customApiKeyId || config.systemApiId),
|
||||
referenceImage: references[0],
|
||||
referenceImages: references.length > 0 ? references : undefined,
|
||||
refImageCount: references.length || countReferenceInputs(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function buildImageHistoryRecords(
|
||||
payload: Record<string, unknown>,
|
||||
result: Record<string, unknown>,
|
||||
) {
|
||||
const images = Array.isArray(result.images) ? result.images.filter((url): url is string => typeof url === 'string' && url.trim().length > 0) : [];
|
||||
const thumbnails = asRecord(result.thumbnails);
|
||||
const thumbnailUrls = Array.isArray(result.thumbnailUrls) ? result.thumbnailUrls : [];
|
||||
const dimensions = asRecord(result.dimensions);
|
||||
const references = getSafeReferenceImages(payload);
|
||||
const creditsCost = Math.max(0, Number(result.creditsCost || 0));
|
||||
const creditsPerItem = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, images.length)) : 0;
|
||||
const params = sanitizeHistoryParams(payload, {
|
||||
creationMode: references.length > 0 || Boolean(payload.image) || Boolean(payload.images) || Boolean(payload.extraImages) ? 'img2img' : 'text2img',
|
||||
});
|
||||
|
||||
return images.map((url, index) => {
|
||||
const size = asRecord(dimensions[url]);
|
||||
return {
|
||||
type: 'image',
|
||||
url,
|
||||
thumbnailUrl: safeString(thumbnails[url]) || safeString(thumbnailUrls[index]) || undefined,
|
||||
width: Number(size.width) || undefined,
|
||||
height: Number(size.height) || undefined,
|
||||
prompt: safeString(payload.prompt),
|
||||
negativePrompt: safeString(payload.negativePrompt) || undefined,
|
||||
model: getModelName(payload),
|
||||
modelLabel: safeString(params.modelLabel) || getModelName(payload),
|
||||
isCustomModel: Boolean(params.isCustomModel),
|
||||
referenceImage: references[0],
|
||||
referenceImages: references.length > 0 ? references : undefined,
|
||||
params,
|
||||
creditsCost: creditsPerItem,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildVideoHistoryRecords(
|
||||
payload: Record<string, unknown>,
|
||||
result: Record<string, unknown>,
|
||||
) {
|
||||
const videos = Array.isArray(result.videos) ? result.videos.filter((url): url is string => typeof url === 'string' && url.trim().length > 0) : [];
|
||||
const references = getSafeReferenceImages(payload);
|
||||
const creditsCost = Math.max(0, Number(result.creditsCost || 0));
|
||||
const creditsPerItem = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, videos.length)) : 0;
|
||||
const params = sanitizeHistoryParams(payload, {
|
||||
creationMode: references.length > 0 || Boolean(payload.image) || Boolean(payload.images) || Boolean(payload.extraImages) ? 'img2video' : 'text2video',
|
||||
});
|
||||
|
||||
return videos.map(url => ({
|
||||
type: 'video',
|
||||
url,
|
||||
prompt: safeString(payload.prompt),
|
||||
negativePrompt: safeString(payload.negativePrompt) || undefined,
|
||||
model: getModelName(payload),
|
||||
modelLabel: safeString(params.modelLabel) || getModelName(payload),
|
||||
isCustomModel: Boolean(params.isCustomModel),
|
||||
referenceImage: references[0],
|
||||
referenceImages: references.length > 0 ? references : undefined,
|
||||
params,
|
||||
creditsCost: creditsPerItem,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildReversePromptHistoryRecord(
|
||||
jobId: string,
|
||||
payload: Record<string, unknown>,
|
||||
result: Record<string, unknown>,
|
||||
) {
|
||||
const outputMode = safeString(payload.outputMode) || 'structured';
|
||||
const generalPrompt = safeString(result.generalPrompt);
|
||||
const structuredPrompt = safeString(result.structuredPrompt);
|
||||
const negativePrompt = safeString(result.negativePrompt);
|
||||
const prompt = outputMode === 'general'
|
||||
? generalPrompt || structuredPrompt
|
||||
: structuredPrompt || generalPrompt;
|
||||
const referenceImage = safePublicUrl(result.referenceImage) || safePublicUrl(payload.image);
|
||||
const params = sanitizeHistoryParams(payload, {
|
||||
creationMode: 'reverse-prompt',
|
||||
outputMode,
|
||||
language: safeString(payload.language) || 'zh',
|
||||
generalPrompt,
|
||||
structuredPrompt,
|
||||
structuredSections: asRecord(result.structuredSections),
|
||||
sourceImagePersisted: Boolean(referenceImage),
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'reverse-prompt',
|
||||
url: `[reverse-prompt:${jobId}]`,
|
||||
prompt,
|
||||
negativePrompt: negativePrompt || undefined,
|
||||
model: getModelName(payload),
|
||||
modelLabel: getModelName(payload) || 'Multimodal model',
|
||||
isCustomModel: true,
|
||||
referenceImage,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function buildGenerationHistoryRecords(
|
||||
jobId: string,
|
||||
type: GenerationJobType,
|
||||
payload: Record<string, unknown>,
|
||||
result: Record<string, unknown>,
|
||||
) {
|
||||
if (type === 'image') return buildImageHistoryRecords(payload, result);
|
||||
if (type === 'video') return buildVideoHistoryRecords(payload, result);
|
||||
return [buildReversePromptHistoryRecord(jobId, payload, result)].filter(record => Boolean(record.prompt));
|
||||
}
|
||||
|
||||
async function persistGenerationHistoryRecord(input: {
|
||||
jobId: string;
|
||||
userId: string | null;
|
||||
type: GenerationJobType;
|
||||
payload: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
}) {
|
||||
if (!input.userId) return;
|
||||
const records = buildGenerationHistoryRecords(input.jobId, input.type, input.payload, input.result);
|
||||
if (records.length === 0) return;
|
||||
|
||||
const res = await fetch(`${getInternalBaseUrl()}/api/creation-history`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getInternalGenerationHeaders(),
|
||||
'x-miaojing-generation-user-id': input.userId,
|
||||
'x-miaojing-generation-job-id': input.jobId,
|
||||
},
|
||||
body: JSON.stringify({ records }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(typeof data.error === 'string' ? data.error : `creation history persistence failed (${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateJob(
|
||||
jobId: string,
|
||||
fields: {
|
||||
@@ -115,7 +319,7 @@ async function claimNextJob() {
|
||||
|
||||
async function settleJobCredits(input: {
|
||||
userId: string | null;
|
||||
type: GenerationJobType;
|
||||
type: 'image' | 'video';
|
||||
payload: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
}) {
|
||||
@@ -147,12 +351,14 @@ export async function processNextGenerationJob() {
|
||||
userId: job.user_id,
|
||||
jobId: job.id,
|
||||
}) as Record<string, unknown>;
|
||||
const creditCharge = await settleJobCredits({
|
||||
userId: job.user_id,
|
||||
type: job.type,
|
||||
payload: job.payload || {},
|
||||
result,
|
||||
});
|
||||
const creditCharge = job.type === 'image' || job.type === 'video'
|
||||
? await settleJobCredits({
|
||||
userId: job.user_id,
|
||||
type: job.type,
|
||||
payload: job.payload || {},
|
||||
result,
|
||||
})
|
||||
: null;
|
||||
const finalResult = creditCharge
|
||||
? {
|
||||
...result,
|
||||
@@ -161,6 +367,15 @@ export async function processNextGenerationJob() {
|
||||
creditDescription: creditCharge.description,
|
||||
}
|
||||
: result;
|
||||
await persistGenerationHistoryRecord({
|
||||
jobId: job.id,
|
||||
userId: job.user_id,
|
||||
type: job.type,
|
||||
payload: job.payload || {},
|
||||
result: finalResult,
|
||||
}).catch(error => {
|
||||
console.warn('[generation-worker] creation history persistence failed:', error instanceof Error ? error.message : error);
|
||||
});
|
||||
await updateJob(job.id, {
|
||||
status: 'succeeded',
|
||||
result: finalResult,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from 'crypto';
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
@@ -14,7 +15,9 @@ const THUMBNAIL_PROFILE = `m${THUMBNAIL_MAX_EDGE}q${THUMBNAIL_WEBP_QUALITY}`;
|
||||
const VIDEO_FRAME_THUMBNAIL_PROFILE = `video-frame-${THUMBNAIL_PROFILE}-v1`;
|
||||
const VIDEO_FALLBACK_THUMBNAIL_PROFILE = 'video-fallback-svg-v2';
|
||||
const VIDEO_THUMBNAIL_TIMEOUT_MS = Number(process.env.VIDEO_THUMBNAIL_TIMEOUT_MS || 45_000);
|
||||
const VIDEO_THUMBNAIL_MAX_INPUT_BYTES = Number(process.env.VIDEO_THUMBNAIL_MAX_INPUT_BYTES || 512 * 1024 * 1024);
|
||||
const VIDEO_THUMBNAIL_MAX_OUTPUT_BYTES = Number(process.env.VIDEO_THUMBNAIL_MAX_OUTPUT_BYTES || 25 * 1024 * 1024);
|
||||
const VIDEO_THUMBNAIL_INPUT_ATTEMPTS = Number(process.env.VIDEO_THUMBNAIL_INPUT_ATTEMPTS || 3);
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
|
||||
export type PersistedImageMedia = {
|
||||
@@ -202,10 +205,7 @@ export async function ensureLocalVideoThumbnail(
|
||||
export function isCurrentLocalVideoThumbnail(url: unknown): boolean {
|
||||
return typeof url === 'string'
|
||||
&& url.includes('/api/local-storage/thumbnails/')
|
||||
&& (
|
||||
url.includes(`-${VIDEO_FRAME_THUMBNAIL_PROFILE}.webp`)
|
||||
|| url.includes(`-${VIDEO_FALLBACK_THUMBNAIL_PROFILE}.svg`)
|
||||
);
|
||||
&& url.includes(`-${VIDEO_FRAME_THUMBNAIL_PROFILE}.webp`);
|
||||
}
|
||||
|
||||
async function createLocalImageThumbnail(input: {
|
||||
@@ -302,46 +302,148 @@ async function resolveVideoThumbnailInput(url: string, sourceKey: string): Promi
|
||||
return { input: localStorage.getFilePath(existingKey) };
|
||||
}
|
||||
|
||||
const objectReadUrl = existingKey ? localStorage.generateObjectReadUrl(existingKey, 300) : null;
|
||||
if (objectReadUrl) {
|
||||
return { input: objectReadUrl };
|
||||
}
|
||||
|
||||
if (existingKey && await localStorage.fileExistsAsync(existingKey)) {
|
||||
return writeTemporaryVideoInput(await localStorage.readFileAsync(existingKey), getVideoExtension(sourceKey));
|
||||
if (existingKey) {
|
||||
try {
|
||||
return await writeStoredTemporaryVideoInput(existingKey, sourceKey);
|
||||
} catch (error) {
|
||||
const objectReadUrl = localStorage.generateObjectReadUrl(existingKey, 300);
|
||||
if (!objectReadUrl) throw error;
|
||||
try {
|
||||
return await fetchTemporaryVideoInput(objectReadUrl, sourceKey);
|
||||
} catch {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!url.startsWith('http')) return null;
|
||||
const response = await fetchPublicHttpUrlWithRetry(
|
||||
url,
|
||||
{ headers: { Accept: 'video/mp4,video/webm,video/quicktime,video/*,*/*;q=0.8' } },
|
||||
{ attempts: 2, retryDelayMs: 500, timeoutMs: 45_000 },
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to fetch video for thumbnail: ${response.status}`);
|
||||
return writeTemporaryVideoInput(Buffer.from(await response.arrayBuffer()), getVideoExtension(url));
|
||||
return fetchTemporaryVideoInput(url, url);
|
||||
}
|
||||
|
||||
async function writeTemporaryVideoInput(buffer: Buffer, ext: string): Promise<VideoThumbnailInput> {
|
||||
const filePath = path.join(
|
||||
os.tmpdir(),
|
||||
`miaojing-video-thumbnail-${crypto.randomUUID()}.${ext || 'mp4'}`,
|
||||
);
|
||||
await fs.writeFile(filePath, buffer);
|
||||
async function writeStoredTemporaryVideoInput(existingKey: string, sourceKey: string): Promise<VideoThumbnailInput> {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= VIDEO_THUMBNAIL_INPUT_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const storedFile = await localStorage.openFileStreamAsync(existingKey);
|
||||
return await writeTemporaryVideoInputFromStream(storedFile.body, getVideoExtension(sourceKey), storedFile.contentLength);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < VIDEO_THUMBNAIL_INPUT_ATTEMPTS) {
|
||||
await delay(350 * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('Failed to read stored video input');
|
||||
}
|
||||
|
||||
async function fetchTemporaryVideoInput(url: string, sourceKey: string): Promise<VideoThumbnailInput> {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= VIDEO_THUMBNAIL_INPUT_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const response = await fetchPublicHttpUrlWithRetry(
|
||||
url,
|
||||
{ headers: { Accept: 'video/mp4,video/webm,video/quicktime,video/*,*/*;q=0.8' } },
|
||||
{ attempts: 2, retryDelayMs: 500, timeoutMs: 45_000 },
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to fetch video for thumbnail: ${response.status}`);
|
||||
return await writeTemporaryVideoInputFromStream(response.body, getVideoExtension(sourceKey), Number(response.headers.get('content-length')) || undefined);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < VIDEO_THUMBNAIL_INPUT_ATTEMPTS) {
|
||||
await delay(350 * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('Failed to fetch video input');
|
||||
}
|
||||
|
||||
async function writeTemporaryVideoInputFromStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
ext: string,
|
||||
contentLength?: number,
|
||||
): Promise<VideoThumbnailInput> {
|
||||
if (contentLength && contentLength > VIDEO_THUMBNAIL_MAX_INPUT_BYTES) {
|
||||
throw new Error(`Video input exceeds thumbnail extraction limit: ${contentLength} bytes`);
|
||||
}
|
||||
if (!stream) {
|
||||
throw new Error('Video input stream is not available');
|
||||
}
|
||||
|
||||
const filePath = getTemporaryVideoInputPath(ext);
|
||||
const handle = await fs.open(filePath, 'w');
|
||||
let written = 0;
|
||||
try {
|
||||
const reader = stream.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value?.byteLength) continue;
|
||||
written += value.byteLength;
|
||||
if (written > VIDEO_THUMBNAIL_MAX_INPUT_BYTES) {
|
||||
throw new Error(`Video input exceeds thumbnail extraction limit: ${written} bytes`);
|
||||
}
|
||||
await handle.write(Buffer.from(value));
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.rm(filePath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
if (written === 0) {
|
||||
await fs.rm(filePath, { force: true }).catch(() => undefined);
|
||||
throw new Error('Video input stream is empty');
|
||||
}
|
||||
|
||||
return {
|
||||
input: filePath,
|
||||
cleanup: () => fs.rm(filePath, { force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function getTemporaryVideoInputPath(ext: string): string {
|
||||
return path.join(
|
||||
os.tmpdir(),
|
||||
`miaojing-video-thumbnail-${crypto.randomUUID()}.${ext || 'mp4'}`,
|
||||
);
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getFfmpegPath(): string | null {
|
||||
const envPath = process.env.FFMPEG_PATH?.trim();
|
||||
if (envPath) return envPath;
|
||||
const envCandidate = getExistingFfmpegPath(envPath);
|
||||
if (envCandidate) return envCandidate;
|
||||
try {
|
||||
const binaryPath = nodeRequire('ffmpeg-static') as unknown;
|
||||
return typeof binaryPath === 'string' && binaryPath ? binaryPath : null;
|
||||
const moduleCandidate = getExistingFfmpegPath(nodeRequire('ffmpeg-static'));
|
||||
if (moduleCandidate) return moduleCandidate;
|
||||
} catch {
|
||||
// Try the runtime cwd below. Bundled route modules can have synthetic paths.
|
||||
}
|
||||
try {
|
||||
const cwdRequire = createRequire(path.join(process.cwd(), 'package.json'));
|
||||
const cwdCandidate = getExistingFfmpegPath(cwdRequire('ffmpeg-static'));
|
||||
if (cwdCandidate) return cwdCandidate;
|
||||
} catch {
|
||||
// Fall through to PATH-based candidates.
|
||||
}
|
||||
return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
|
||||
}
|
||||
|
||||
function getExistingFfmpegPath(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const candidate = value.trim();
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
export function resolveRuntimeFfmpegPathForTest(): string | null {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return null;
|
||||
}
|
||||
return getFfmpegPath();
|
||||
}
|
||||
|
||||
function runFfmpegFrameExtract(ffmpegPath: string, input: string, seekTime: string): Promise<Buffer> {
|
||||
|
||||
@@ -360,7 +360,8 @@ export async function resolveSystemApiPollingCandidates(
|
||||
}
|
||||
|
||||
const candidatesResult = await client.query(
|
||||
`SELECT id, provider, name, api_url, model_name, manifest_path, api_key_encrypted,
|
||||
`SELECT id, provider, name, api_url, model_name, model_group, manifest_path, api_key_encrypted,
|
||||
type,
|
||||
allowed_membership_tiers, polling_mode, polling_order, sort_order, created_at
|
||||
FROM system_api_configs
|
||||
WHERE type = $1
|
||||
@@ -375,13 +376,16 @@ export async function resolveSystemApiPollingCandidates(
|
||||
if (allowedRows.length === 0) throw new Error('当前会员等级无权使用该系统 API');
|
||||
|
||||
const mode = normalizeSystemApiPollingMode(selected.polling_mode || allowedRows[0]?.polling_mode);
|
||||
return sortSystemApiPollingRows(allowedRows, mode).map(row => ({
|
||||
provider: String(row.provider || row.name || 'system'),
|
||||
apiUrl: String(row.api_url || input.apiUrl || ''),
|
||||
modelName: String(row.model_name || input.modelName || ''),
|
||||
apiKey: decryptSecret(String(row.api_key_encrypted || '')) || '',
|
||||
manifestPath: String(row.manifest_path || ''),
|
||||
systemApiId: String(row.id || ''),
|
||||
return Promise.all(sortSystemApiPollingRows(allowedRows, mode).map(async row => {
|
||||
const yuanjieManifest = await ensureYuanjieSystemApiManifest(client, row);
|
||||
return {
|
||||
provider: String(row.provider || row.name || 'system'),
|
||||
apiUrl: yuanjieManifest?.apiUrl || String(row.api_url || input.apiUrl || ''),
|
||||
modelName: String(row.model_name || input.modelName || ''),
|
||||
apiKey: decryptSecret(String(row.api_key_encrypted || '')) || '',
|
||||
manifestPath: yuanjieManifest?.manifestPath || String(row.manifest_path || ''),
|
||||
systemApiId: String(row.id || ''),
|
||||
};
|
||||
}));
|
||||
} finally {
|
||||
client.release();
|
||||
|
||||
Reference in New Issue
Block a user