fix: persist reference images and speed previews
This commit is contained in:
@@ -107,8 +107,8 @@ Important generation helpers:
|
||||
|
||||
| Method | Path | Auth | Source | Request | Response/Side Effects |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Optional query `mode=text2img|img2img|text2video|img2video|reverse-prompt`, `limit` up to 300 | Latest completed user works as `records`, including optional `thumbnailUrl`, `width`, `height`, `published`, and `publishedAt`. Without query params it returns the latest 300 for the profile history tab. Create panels should pass their current mode plus a small limit so page navigation does not compete with multi-MB full-history responses. The mode filter checks stored work type, explicit mode params, and legacy reference-image inference. 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. |
|
||||
| GET | `/api/creation-history` | User | `src/app/api/creation-history/route.ts` | Optional query `mode=text2img|img2img|text2video|img2video|reverse-prompt`, `limit` up to 300 | Latest completed user works as `records`, including optional `thumbnailUrl`, `width`, `height`, `referenceImages`, `referenceImageThumbnails`, `published`, and `publishedAt`. Without query params it returns the latest 300 for the profile history tab. Create panels should pass their current mode plus a small limit so page navigation does not compete with multi-MB full-history responses. The mode filter checks stored work type, explicit mode params, and legacy reference-image inference. 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`, `height`, `referenceImage`, and `referenceImages` | Inserts/deduplicates completed works into `works`, storing `thumbnail_url` and dimensions when supplied or generating thumbnails for image works and video works without thumbnails. Data URL or remote reference images are persisted into stable `/api/local-storage/works/references/...` URLs with local thumbnails under `thumbnails/works/references`; the route writes `params.referenceImages` and `params.referenceImageThumbnails`, and can patch an existing same-URL row whose first insert came from the background worker before frontend reference metadata arrived. 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; 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`. |
|
||||
|
||||
@@ -60,6 +60,7 @@ 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. |
|
||||
| 图生图/图生视频参考图或创作历史详情参考图加载慢,或详情不显示参考图 | `src/components/create/image-to-image.tsx`, `src/components/create/image-to-video.tsx`, `src/components/reference-preview-image.tsx`, `src/components/creation-detail-dialog.tsx`, `src/app/api/creation-history/route.ts`, `src/lib/reference-image-storage.ts`, `src/lib/generation-job-worker.ts` | Upload/reference cards should render `ReferencePreviewImage` so large uploaded data URLs or remote URLs are downsampled for the card while click/fullscreen still uses the original. `/api/creation-history` should persist data URL or remote reference images into stable `/api/local-storage/works/references/...` URLs, write `params.referenceImages` and `params.referenceImageThumbnails`, and patch existing same-URL rows that were first inserted by the background worker without references. The generation worker must pass data URL reference inputs through to creation-history persistence instead of filtering them out before the server can store them. Creation detail should prefer `referenceImageThumbnails[index]` for the small grid and should not expose reference-image downloads. |
|
||||
| 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. |
|
||||
|
||||
@@ -51,9 +51,9 @@ Use this document to jump directly to code before broad searching.
|
||||
| --- | --- | --- |
|
||||
| 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. Keep the five primary creation panels statically imported in this page: production users switch between these modes constantly, and `ssr:false` dynamic splitting adds visible chunk waits and fallback flashes on direct web access. 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`, `src/components/create/use-generation-job-recovery.ts`. The create button remains available while other active tasks run; duplicate in-flight submissions are still blocked by `activeSubmissionSignaturesRef`. Active jobs render through `src/components/create/generation-task-list.tsx` inside the results column and expose a cancel action that calls `PATCH /api/generation-jobs/[id]`. 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 create-panel history hook must stay scoped, e.g. `useCreationHistory({ mode: 'text2img', limit: 60 })`, so opening `/create` does not download the user's full history payload. 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/components/create/reference-image-mention-controls.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`. Reference thumbnails single-click into a bare image overlay, active jobs render through `src/components/create/generation-task-list.tsx`, and the create button remains available while active tasks exist; identical in-flight submissions are still deduped. 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. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt;分享到画廊会携带所有参考图和标注。 |
|
||||
| Image to image | `src/components/create/image-to-image.tsx`, `src/components/create/reference-image-mention-controls.tsx`, `src/components/reference-preview-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/image/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`, `src/lib/reference-image-storage.ts`. Reference thumbnails single-click into a bare image overlay and use lightweight local preview rendering instead of painting the full uploaded data URL in every card. Active jobs render through `src/components/create/generation-task-list.tsx`, and the create button remains available while active tasks exist; identical in-flight submissions are still deduped. 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. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt;创作历史会把 data URL/远程参考图持久化到 `works/references` 并写入 `referenceImageThumbnails`,分享到画廊会携带所有参考图和标注。 |
|
||||
| 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 available while active tasks exist, active jobs render through `src/components/create/generation-task-list.tsx`, running tasks can be cancelled, 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/components/create/reference-image-mention-controls.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.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`, the create button remains available while active tasks exist, and running tasks can be cancelled. 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. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt;分享到画廊会携带所有参考图和标注。 |
|
||||
| Image to video | `src/components/create/image-to-video.tsx`, `src/components/create/reference-image-mention-controls.tsx`, `src/components/reference-preview-image.tsx` | `src/app/api/generation-jobs/route.ts`, `src/app/api/generate/video/route.ts`, `src/components/create/use-generation-job-recovery.ts`, `src/lib/reference-image-prompt.ts`, `src/lib/reference-image-storage.ts`. Uploaded reference thumbnails single-click into the same bare image overlay used by image-to-image and use lightweight local preview rendering. Active jobs render through `src/components/create/generation-task-list.tsx`, the create button remains available while active tasks exist, and running tasks can be cancelled. 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. 多参考图会显示 `@参考图1` 等标签,提示词输入框输入 `@` 可选择参考图,提交时发送 `referenceImageAnnotations`,后端把 token 与上传顺序、文件名、尺寸写入上游 prompt;创作历史会把 data URL/远程参考图持久化到 `works/references` 并写入 `referenceImageThumbnails`,分享到画廊会携带所有参考图和标注。 |
|
||||
| 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. |
|
||||
|
||||
138
scripts/backfill-work-reference-images.mjs
Normal file
138
scripts/backfill-work-reference-images.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import pg from 'pg';
|
||||
import referenceImageStorage from '../src/lib/reference-image-storage.ts';
|
||||
|
||||
const { persistReferenceImages } = referenceImageStorage;
|
||||
|
||||
loadEnvFile(path.join(process.cwd(), '.env.local'));
|
||||
|
||||
const { Client } = pg;
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const dryRun = args.has('--dry-run');
|
||||
if (args.has('--check-import')) {
|
||||
if (typeof persistReferenceImages !== 'function') {
|
||||
throw new Error('persistReferenceImages import failed');
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, import: 'persistReferenceImages' }));
|
||||
process.exit(0);
|
||||
}
|
||||
const limitArg = [...args].find(arg => arg.startsWith('--limit='));
|
||||
const limit = Math.max(1, Math.min(5000, Number(limitArg?.split('=')[1] || 500)));
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
const [, key, rawValue] = match;
|
||||
if (process.env[key] !== undefined) continue;
|
||||
process.env[key] = rawValue.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getReferenceInputs(params) {
|
||||
const values = [
|
||||
params.referenceImage,
|
||||
...(Array.isArray(params.referenceImages) ? params.referenceImages : []),
|
||||
params.image,
|
||||
...(Array.isArray(params.images) ? params.images : []),
|
||||
...(Array.isArray(params.extraImages) ? params.extraImages : []),
|
||||
params.sourceImage,
|
||||
params.source_image,
|
||||
params.inputImage,
|
||||
params.input_image,
|
||||
];
|
||||
return [...new Set(values.map(normalizeString).filter(value => value && !value.startsWith('[')))];
|
||||
}
|
||||
|
||||
function shouldBackfill(params) {
|
||||
const references = getReferenceInputs(params);
|
||||
const hasThumbnails = Array.isArray(params.referenceImageThumbnails) && params.referenceImageThumbnails.length > 0;
|
||||
return references.some(value => value.startsWith('data:image/') || /^https?:\/\//i.test(value) || value.startsWith('/api/local-storage/'))
|
||||
&& (!Array.isArray(params.referenceImages) || params.referenceImages.length === 0 || !hasThumbnails || references.some(value => value.startsWith('data:image/')));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const connectionString = process.env.LOCAL_DB_URL || process.env.DATABASE_URL;
|
||||
if (!connectionString) throw new Error('LOCAL_DB_URL or DATABASE_URL is required');
|
||||
const client = new Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT id, params
|
||||
FROM works
|
||||
WHERE status = 'completed'
|
||||
AND (
|
||||
type IN ('img2img', 'img2video')
|
||||
OR params->>'creationMode' IN ('img2img', 'img2video')
|
||||
OR params->>'workType' IN ('img2img', 'img2video')
|
||||
OR params->>'referenceImage' IS NOT NULL
|
||||
OR jsonb_typeof(params->'referenceImages') = 'array'
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1`,
|
||||
[limit],
|
||||
);
|
||||
|
||||
let candidates = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
for (const row of result.rows) {
|
||||
const params = row.params || {};
|
||||
if (!shouldBackfill(params)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
candidates += 1;
|
||||
if (dryRun) continue;
|
||||
const persisted = await persistReferenceImages(getReferenceInputs(params));
|
||||
if (persisted.length === 0) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const referenceImages = persisted.map(item => item.url);
|
||||
const referenceImageThumbnails = persisted.map(item => item.thumbnailUrl || item.url);
|
||||
await client.query(
|
||||
`UPDATE works
|
||||
SET params = $2::jsonb
|
||||
WHERE id = $1`,
|
||||
[
|
||||
row.id,
|
||||
JSON.stringify({
|
||||
...params,
|
||||
referenceImage: referenceImages[0],
|
||||
referenceImages,
|
||||
referenceImageThumbnails,
|
||||
refImageCount: Math.max(Number(params.refImageCount || 0), referenceImages.length),
|
||||
}),
|
||||
],
|
||||
);
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
dryRun,
|
||||
scanned: result.rowCount,
|
||||
candidates,
|
||||
updated,
|
||||
skipped,
|
||||
limit,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
74
scripts/test-reference-image-history-preview.mjs
Normal file
74
scripts/test-reference-image-history-preview.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
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('creation history persists and backfills reference image URLs and thumbnails', () => {
|
||||
const route = read('src/app/api/creation-history/route.ts');
|
||||
|
||||
assert.match(route, /persistReferenceImages/);
|
||||
assert.match(route, /getReferenceImageInputs/);
|
||||
assert.match(route, /referenceImageThumbnails/);
|
||||
assert.match(route, /shouldPatchReferences/);
|
||||
assert.match(route, /mergeWorkRowMetadata/);
|
||||
assert.match(route, /hasReferenceMetadata/);
|
||||
assert.match(route, /params = \$4::jsonb/);
|
||||
});
|
||||
|
||||
await runTest('reference image backfill script can persist old data-url history rows', () => {
|
||||
const script = read('scripts/backfill-work-reference-images.mjs');
|
||||
|
||||
assert.match(script, /--dry-run/);
|
||||
assert.match(script, /persistReferenceImages/);
|
||||
assert.match(script, /referenceImageThumbnails/);
|
||||
assert.match(script, /params->>'creationMode' IN \('img2img', 'img2video'\)/);
|
||||
});
|
||||
|
||||
await runTest('generation worker keeps data-url reference inputs for server-side persistence', () => {
|
||||
const worker = read('src/lib/generation-job-worker.ts');
|
||||
|
||||
assert.match(worker, /function safeReferenceInput/);
|
||||
assert.match(worker, /function getReferenceInputs/);
|
||||
assert.match(worker, /const references = getReferenceInputs\(payload\)/);
|
||||
assert.doesNotMatch(worker, /const references = getSafeReferenceImages\(payload\);\n return \{/);
|
||||
});
|
||||
|
||||
await runTest('reference previews use lightweight thumbnails and do not expose downloads in detail', () => {
|
||||
const detail = read('src/components/creation-detail-dialog.tsx');
|
||||
const imageToImage = read('src/components/create/image-to-image.tsx');
|
||||
const imageToVideo = read('src/components/create/image-to-video.tsx');
|
||||
const preview = read('src/components/reference-preview-image.tsx');
|
||||
|
||||
assert.match(detail, /ReferencePreviewImage/);
|
||||
assert.match(detail, /thumbnailSrc=\{referenceImageThumbnails\[index\]\}/);
|
||||
assert.doesNotMatch(detail, /miaojing-reference-\$\{record\.id\}/);
|
||||
assert.match(imageToImage, /<ReferencePreviewImage src=\{img\.dataUrl\}/);
|
||||
assert.match(imageToVideo, /<ReferencePreviewImage src=\{img\.dataUrl\}/);
|
||||
assert.match(preview, /const MAX_EDGE = 360/);
|
||||
assert.match(preview, /canvas\.toDataURL\('image\/webp', QUALITY\)/);
|
||||
});
|
||||
|
||||
await runTest('image-to-video history cards avoid eager original video metadata loads', () => {
|
||||
const source = read('src/components/create/image-to-video.tsx');
|
||||
|
||||
assert.match(source, /record\.thumbnailUrl/);
|
||||
assert.doesNotMatch(source, /<video src=\{record\.url\} className="w-full h-full object-cover" preload="metadata" \/>/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
isCurrentLocalImageThumbnail,
|
||||
isCurrentLocalVideoThumbnail,
|
||||
} from '@/lib/media-storage';
|
||||
import {
|
||||
getReferenceImageInputs,
|
||||
getReferenceThumbnailInputs,
|
||||
persistReferenceImages,
|
||||
} from '@/lib/reference-image-storage';
|
||||
|
||||
const workThumbnailQueue = new Map<string, Record<string, unknown>>();
|
||||
let workThumbnailProcessing = false;
|
||||
@@ -39,6 +44,12 @@ function isVideoWorkType(type: string): boolean {
|
||||
|
||||
function mapWork(row: Record<string, unknown>) {
|
||||
const params = (row.params || {}) as Record<string, unknown>;
|
||||
const referenceImages = Array.isArray(params.referenceImages)
|
||||
? params.referenceImages.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: typeof params.referenceImage === 'string' && params.referenceImage.trim()
|
||||
? [params.referenceImage]
|
||||
: undefined;
|
||||
const referenceImageThumbnails = getReferenceThumbnailInputs(params);
|
||||
return {
|
||||
id: row.id,
|
||||
type: fromWorkType(String(row.type || 'text2img')),
|
||||
@@ -52,12 +63,9 @@ function mapWork(row: Record<string, unknown>) {
|
||||
modelLabel: params.modelLabel || params.model || '',
|
||||
isCustomModel: Boolean(params.isCustomModel),
|
||||
params,
|
||||
referenceImage: params.referenceImage,
|
||||
referenceImages: Array.isArray(params.referenceImages)
|
||||
? params.referenceImages
|
||||
: params.referenceImage
|
||||
? [params.referenceImage]
|
||||
: undefined,
|
||||
referenceImage: referenceImages?.[0],
|
||||
referenceImages,
|
||||
referenceImageThumbnails: referenceImageThumbnails.length > 0 ? referenceImageThumbnails : undefined,
|
||||
creditsCost: Number(row.credits_cost || 0),
|
||||
published: row.is_public === true,
|
||||
publishedAt: row.is_public === true ? row.created_at : undefined,
|
||||
@@ -180,13 +188,49 @@ function dedupeRowsByResultUrl(rows: Record<string, unknown>[]) {
|
||||
const key = typeof row.result_url === 'string' && row.result_url.trim()
|
||||
? row.result_url
|
||||
: String(row.id || '');
|
||||
if (seen.has(key)) continue;
|
||||
if (seen.has(key)) {
|
||||
const target = deduped.find(item => (
|
||||
typeof item.result_url === 'string' && item.result_url.trim()
|
||||
? item.result_url
|
||||
: String(item.id || '')
|
||||
) === key);
|
||||
if (target) mergeWorkRowMetadata(target, row);
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
for (const candidate of rows) {
|
||||
const candidateKey = typeof candidate.result_url === 'string' && candidate.result_url.trim()
|
||||
? candidate.result_url
|
||||
: String(candidate.id || '');
|
||||
if (candidateKey === key && candidate !== row) mergeWorkRowMetadata(row, candidate);
|
||||
}
|
||||
deduped.push(row);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function hasReferenceMetadata(params: Record<string, unknown>): boolean {
|
||||
return typeof params.referenceImage === 'string' && params.referenceImage.trim().length > 0
|
||||
|| (Array.isArray(params.referenceImages) && params.referenceImages.length > 0);
|
||||
}
|
||||
|
||||
function mergeWorkRowMetadata(target: Record<string, unknown>, source: Record<string, unknown>) {
|
||||
const targetParams = (target.params || {}) as Record<string, unknown>;
|
||||
const sourceParams = (source.params || {}) as Record<string, unknown>;
|
||||
if (!target.thumbnail_url && source.thumbnail_url) target.thumbnail_url = source.thumbnail_url;
|
||||
if (!target.width && source.width) target.width = source.width;
|
||||
if (!target.height && source.height) target.height = source.height;
|
||||
if (!hasReferenceMetadata(targetParams) && hasReferenceMetadata(sourceParams)) {
|
||||
target.params = {
|
||||
...targetParams,
|
||||
referenceImage: sourceParams.referenceImage,
|
||||
referenceImages: sourceParams.referenceImages,
|
||||
referenceImageThumbnails: sourceParams.referenceImageThumbnails,
|
||||
refImageCount: sourceParams.refImageCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function historyRecordDedupeLockKey(userId: string, url: string): string {
|
||||
return `${userId}:${url}`;
|
||||
}
|
||||
@@ -229,7 +273,7 @@ export async function POST(request: NextRequest) {
|
||||
await client.query('BEGIN');
|
||||
const saved = [];
|
||||
for (const record of records) {
|
||||
const params = {
|
||||
const initialParams = {
|
||||
...(record.params || {}),
|
||||
model: record.model || (record.params || {}).model,
|
||||
modelLabel: record.modelLabel || (record.params || {}).modelLabel,
|
||||
@@ -237,6 +281,19 @@ export async function POST(request: NextRequest) {
|
||||
referenceImage: record.referenceImage || (record.params || {}).referenceImage,
|
||||
referenceImages: record.referenceImages || (record.params || {}).referenceImages,
|
||||
};
|
||||
const persistedReferences = await persistReferenceImages(getReferenceImageInputs({
|
||||
referenceImage: record.referenceImage,
|
||||
referenceImages: record.referenceImages,
|
||||
params: initialParams,
|
||||
}));
|
||||
const referenceImages = persistedReferences.map(item => item.url);
|
||||
const referenceImageThumbnails = persistedReferences.map(item => item.thumbnailUrl || item.url);
|
||||
const params = {
|
||||
...initialParams,
|
||||
referenceImage: referenceImages[0] || undefined,
|
||||
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
|
||||
referenceImageThumbnails: referenceImageThumbnails.length > 0 ? referenceImageThumbnails : undefined,
|
||||
};
|
||||
const workType = toWorkType(String(record.type || 'image'), params);
|
||||
let url = String(record.url || '').trim();
|
||||
let thumbnailUrl = String(record.thumbnailUrl || '').trim() || null;
|
||||
@@ -272,18 +329,34 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
const existingRow = existing.rows[0];
|
||||
if ((thumbnailUrl && !existingRow.thumbnail_url) || (width && !existingRow.width) || (height && !existingRow.height)) {
|
||||
const existingParams = (existingRow.params || {}) as Record<string, unknown>;
|
||||
const shouldPatchReferences = referenceImages.length > 0 && (
|
||||
!Array.isArray(existingParams.referenceImages) ||
|
||||
existingParams.referenceImages.length === 0
|
||||
);
|
||||
if ((thumbnailUrl && !existingRow.thumbnail_url) || (width && !existingRow.width) || (height && !existingRow.height) || shouldPatchReferences) {
|
||||
const nextParams = shouldPatchReferences
|
||||
? {
|
||||
...existingParams,
|
||||
referenceImage: referenceImages[0],
|
||||
referenceImages,
|
||||
referenceImageThumbnails,
|
||||
refImageCount: Math.max(Number(existingParams.refImageCount || 0), referenceImages.length),
|
||||
}
|
||||
: existingParams;
|
||||
await client.query(
|
||||
`UPDATE works
|
||||
SET thumbnail_url = COALESCE(thumbnail_url, $1),
|
||||
width = COALESCE(width, $2),
|
||||
height = COALESCE(height, $3)
|
||||
WHERE id = $4`,
|
||||
[thumbnailUrl, width, height, existingRow.id],
|
||||
height = COALESCE(height, $3),
|
||||
params = $4::jsonb
|
||||
WHERE id = $5`,
|
||||
[thumbnailUrl, width, height, JSON.stringify(nextParams), existingRow.id],
|
||||
);
|
||||
existingRow.thumbnail_url = existingRow.thumbnail_url || thumbnailUrl;
|
||||
existingRow.width = existingRow.width || width;
|
||||
existingRow.height = existingRow.height || height;
|
||||
existingRow.params = nextParams;
|
||||
}
|
||||
saved.push(mapWork(existingRow));
|
||||
continue;
|
||||
|
||||
@@ -50,6 +50,7 @@ import { useGenerationJobRecovery } from '@/components/create/use-generation-job
|
||||
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';
|
||||
import { ReferencePreviewImage } from '@/components/reference-preview-image';
|
||||
|
||||
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
|
||||
const IMAGE_TO_IMAGE_SELECTED_MODEL_KEY = 'miaojing_create_image_to_image_selected_model';
|
||||
@@ -839,8 +840,7 @@ export function ImageToImagePanel() {
|
||||
className="liquid-glass-soft relative group aspect-square cursor-zoom-in overflow-hidden rounded-2xl"
|
||||
onClick={() => setReferencePreviewSrc(img.dataUrl)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={img.dataUrl} alt={img.name} className="w-full h-full object-cover" />
|
||||
<ReferencePreviewImage src={img.dataUrl} alt={img.name} className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-1 left-1 rounded-full bg-black/70 px-2 py-0.5 text-[11px] font-medium text-white shadow-sm backdrop-blur"
|
||||
|
||||
@@ -37,6 +37,7 @@ import { GenerationTaskList, type ActiveGenerationTask } from '@/components/crea
|
||||
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';
|
||||
import { ReferencePreviewImage } from '@/components/reference-preview-image';
|
||||
|
||||
const IMAGE_TO_VIDEO_SELECTED_MODEL_KEY = 'miaojing_create_image_to_video_selected_model';
|
||||
const IMAGE_TO_VIDEO_MODEL_TOUCHED_KEY = 'miaojing_create_image_to_video_model_touched';
|
||||
@@ -625,8 +626,7 @@ export function ImageToVideoPanel() {
|
||||
className="liquid-glass-soft relative aspect-square cursor-zoom-in overflow-hidden rounded-2xl"
|
||||
onClick={() => setReferencePreviewSrc(img.dataUrl)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={img.dataUrl} alt={img.name} className="h-full w-full object-cover" />
|
||||
<ReferencePreviewImage src={img.dataUrl} alt={img.name} className="h-full w-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-1 left-1 rounded-full bg-black/70 px-2 py-0.5 text-[11px] font-medium text-white shadow-sm backdrop-blur"
|
||||
@@ -837,7 +837,14 @@ export function ImageToVideoPanel() {
|
||||
<div className="w-full aspect-video flex items-center justify-center"><Film className="h-6 w-6 text-muted-foreground/30" /></div>
|
||||
) : (
|
||||
<div className="w-full aspect-video relative overflow-hidden">
|
||||
<video src={record.url} className="w-full h-full object-cover" preload="metadata" />
|
||||
{record.thumbnailUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={record.thumbnailUrl} alt={record.prompt || '视频预览'} className="h-full w-full object-cover" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted/60">
|
||||
<Film className="h-6 w-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="h-8 w-8 rounded-full bg-white/90 flex items-center justify-center">
|
||||
<Film className="h-4 w-4 text-black ml-0.5" />
|
||||
|
||||
@@ -29,6 +29,7 @@ import { toast } from 'sonner';
|
||||
import { FullscreenPreview } from '@/components/fullscreen-preview';
|
||||
import { ImageMetadataBadge } from '@/components/image-metadata-badge';
|
||||
import { useImageActionsContextMenu } from '@/components/image-actions-context-menu';
|
||||
import { ReferencePreviewImage } from '@/components/reference-preview-image';
|
||||
|
||||
interface CreationDetailDialogProps {
|
||||
record: CreationRecord | null;
|
||||
@@ -128,7 +129,15 @@ function getRecordReferenceImages(record: CreationRecord): string[] {
|
||||
: typeof record.params?.referenceImage === 'string' && !isPlaceholder(record.params.referenceImage)
|
||||
? [record.params.referenceImage]
|
||||
: [];
|
||||
return [...new Set([...single, ...fromArray, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
|
||||
return [...new Set([...single, ...fromArray, ...fromParams].filter(url => url && !url.startsWith('[')))];
|
||||
}
|
||||
|
||||
function getRecordReferenceImageThumbnails(record: CreationRecord): string[] {
|
||||
const fromRecord = Array.isArray(record.referenceImageThumbnails) ? record.referenceImageThumbnails : [];
|
||||
const fromParams = Array.isArray(record.params?.referenceImageThumbnails)
|
||||
? (record.params.referenceImageThumbnails as unknown[]).filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
return [...new Set([...fromRecord, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
|
||||
}
|
||||
|
||||
function getReuseTarget(record: CreationRecord): CreationReuseTarget | null {
|
||||
@@ -193,6 +202,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
|
||||
const isReversePromptRecord = record.type === 'reverse-prompt';
|
||||
const referenceImages = getRecordReferenceImages(record);
|
||||
const referenceImageThumbnails = getRecordReferenceImageThumbnails(record);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const url = record.url;
|
||||
@@ -647,8 +657,8 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
<div className="grid max-h-[190px] grid-cols-3 gap-2 overflow-y-auto pr-1">
|
||||
{referenceImages.map((url, index) => (
|
||||
<div key={`${url}-${index}`} className="group relative overflow-hidden rounded-lg border border-border bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<ReferencePreviewImage
|
||||
thumbnailSrc={referenceImageThumbnails[index]}
|
||||
src={url}
|
||||
alt={`参考图 ${index + 1}`}
|
||||
className="aspect-square w-full cursor-zoom-in object-cover"
|
||||
@@ -662,15 +672,6 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange, o
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const result = await downloadFile(url, `miaojing-reference-${record.id}-${index + 1}.${getImageDownloadExtension(url)}`);
|
||||
if (!result.ok) window.open(url, '_blank');
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
113
src/components/reference-preview-image.tsx
Normal file
113
src/components/reference-preview-image.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, type MouseEventHandler } from 'react';
|
||||
|
||||
type ReferencePreviewImageProps = {
|
||||
src: string;
|
||||
thumbnailSrc?: string | null;
|
||||
alt: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onContextMenu?: MouseEventHandler<HTMLImageElement>;
|
||||
};
|
||||
|
||||
const MAX_EDGE = 360;
|
||||
const QUALITY = 0.7;
|
||||
|
||||
function isLocalOrDataUrl(src: string): boolean {
|
||||
if (src.startsWith('data:image/')) return true;
|
||||
if (src.startsWith('/')) return true;
|
||||
if (typeof window === 'undefined') return true;
|
||||
try {
|
||||
return new URL(src, window.location.href).origin === window.location.origin;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplaySource(src: string): string {
|
||||
if (isLocalOrDataUrl(src)) return src;
|
||||
return `/api/download?url=${encodeURIComponent(src)}&filename=reference-preview.jpg&disposition=inline`;
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('参考图预览加载失败'));
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToDataUrl(image: HTMLImageElement): string {
|
||||
const width = image.naturalWidth || image.width;
|
||||
const height = image.naturalHeight || image.height;
|
||||
if (!width || !height) return image.src;
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(width, height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(width * scale));
|
||||
canvas.height = Math.max(1, Math.round(height * scale));
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return image.src;
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/webp', QUALITY);
|
||||
}
|
||||
|
||||
export function ReferencePreviewImage({
|
||||
src,
|
||||
thumbnailSrc,
|
||||
alt,
|
||||
className,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}: ReferencePreviewImageProps) {
|
||||
const [previewSrc, setPreviewSrc] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackSrc = useMemo(() => getDisplaySource(src), [src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setPreviewSrc('');
|
||||
setFailed(false);
|
||||
return;
|
||||
}
|
||||
setFailed(false);
|
||||
if (thumbnailSrc) {
|
||||
setPreviewSrc(thumbnailSrc);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setPreviewSrc('');
|
||||
loadImage(getDisplaySource(src))
|
||||
.then(image => {
|
||||
if (cancelled) return;
|
||||
setPreviewSrc(canvasToDataUrl(image));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src, thumbnailSrc]);
|
||||
|
||||
const displaySrc = previewSrc || (failed ? fallbackSrc : '');
|
||||
if (!displaySrc) {
|
||||
return <div className={`animate-pulse bg-muted/70 ${className || ''}`} aria-label={alt} />;
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={displaySrc}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface CreationRecord {
|
||||
publishedAt?: string; // Set only after a confirmed gallery publish
|
||||
referenceImage?: string; // For img2img: the reference image URL
|
||||
referenceImages?: string[]; // Optional multiple reference image URLs
|
||||
referenceImageThumbnails?: string[];
|
||||
publisherNickname?: string; // Set when publishing
|
||||
creditsCost?: number;
|
||||
}
|
||||
@@ -43,6 +44,7 @@ export interface PublishedWork {
|
||||
params: Record<string, unknown>;
|
||||
referenceImage?: string;
|
||||
referenceImages?: string[];
|
||||
referenceImageThumbnails?: string[];
|
||||
publisherId: string;
|
||||
publisherNickname: string;
|
||||
publishedAt: string;
|
||||
@@ -471,6 +473,7 @@ export function publishWork(
|
||||
params: record.params,
|
||||
referenceImage: record.referenceImage,
|
||||
referenceImages: record.referenceImages,
|
||||
referenceImageThumbnails: record.referenceImageThumbnails,
|
||||
publisherId,
|
||||
publisherNickname,
|
||||
publishedAt: new Date().toISOString(),
|
||||
@@ -504,6 +507,7 @@ export async function shareToGallery(options: {
|
||||
negativePrompt?: string;
|
||||
referenceImage?: string;
|
||||
referenceImages?: string[];
|
||||
referenceImageThumbnails?: string[];
|
||||
params?: Record<string, unknown>;
|
||||
creditsCost?: number;
|
||||
thumbnailUrl?: string;
|
||||
@@ -534,6 +538,7 @@ export async function shareToGallery(options: {
|
||||
modelLabel: options.modelLabel,
|
||||
referenceImage: options.referenceImage,
|
||||
referenceImages: options.referenceImages,
|
||||
referenceImageThumbnails: options.referenceImageThumbnails,
|
||||
params: options.params,
|
||||
creditsCost: options.creditsCost,
|
||||
}),
|
||||
@@ -560,6 +565,7 @@ export async function shareToGallery(options: {
|
||||
params: options.params || {},
|
||||
referenceImage: options.referenceImage,
|
||||
referenceImages: options.referenceImages,
|
||||
referenceImageThumbnails: options.referenceImageThumbnails,
|
||||
publisherId: options.publisherId || 'anonymous',
|
||||
publisherNickname: options.publisherNickname || '匿名用户',
|
||||
publishedAt: new Date().toISOString(),
|
||||
@@ -619,6 +625,7 @@ export async function syncPublishedToSupabase(): Promise<number> {
|
||||
modelLabel: string;
|
||||
referenceImage?: string;
|
||||
referenceImages?: string[];
|
||||
referenceImageThumbnails?: string[];
|
||||
params: Record<string, unknown>;
|
||||
publisherId?: string;
|
||||
publisherNickname?: string;
|
||||
@@ -636,6 +643,7 @@ export async function syncPublishedToSupabase(): Promise<number> {
|
||||
modelLabel: work.modelLabel || '',
|
||||
referenceImage: work.referenceImage,
|
||||
referenceImages: work.referenceImages,
|
||||
referenceImageThumbnails: work.referenceImageThumbnails,
|
||||
params: work.params || {},
|
||||
publisherId: work.publisherId,
|
||||
publisherNickname: work.publisherNickname,
|
||||
@@ -655,6 +663,7 @@ export async function syncPublishedToSupabase(): Promise<number> {
|
||||
modelLabel: r.modelLabel || '',
|
||||
referenceImage: r.referenceImage,
|
||||
referenceImages: r.referenceImages,
|
||||
referenceImageThumbnails: r.referenceImageThumbnails,
|
||||
params: r.params || {},
|
||||
});
|
||||
}
|
||||
@@ -697,6 +706,7 @@ export async function syncPublishedToSupabase(): Promise<number> {
|
||||
modelLabel: work.modelLabel,
|
||||
referenceImage: work.referenceImage,
|
||||
referenceImages: work.referenceImages,
|
||||
referenceImageThumbnails: work.referenceImageThumbnails,
|
||||
params: work.params,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -28,6 +28,12 @@ function safePublicUrl(value: unknown): string | undefined {
|
||||
return text;
|
||||
}
|
||||
|
||||
function safeReferenceInput(value: unknown): string | undefined {
|
||||
const text = safeString(value);
|
||||
if (!text || 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>
|
||||
@@ -52,6 +58,15 @@ function getSafeReferenceImages(payload: Record<string, unknown>): string[] {
|
||||
return Array.from(new Set(references));
|
||||
}
|
||||
|
||||
function getReferenceInputs(payload: Record<string, unknown>): string[] {
|
||||
const references = [
|
||||
safeReferenceInput(payload.image),
|
||||
...(Array.isArray(payload.images) ? payload.images.map(safeReferenceInput) : []),
|
||||
...(Array.isArray(payload.extraImages) ? payload.extraImages.map(safeReferenceInput) : []),
|
||||
].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;
|
||||
@@ -66,7 +81,7 @@ function sanitizeHistoryParams(payload: Record<string, unknown>, extra: Record<s
|
||||
delete rest.extraImages;
|
||||
delete rest.customApiConfig;
|
||||
const config = getPayloadConfig(payload);
|
||||
const references = getSafeReferenceImages(payload);
|
||||
const references = getReferenceInputs(payload);
|
||||
return {
|
||||
...rest,
|
||||
...extra,
|
||||
|
||||
@@ -249,7 +249,7 @@ export function isCurrentLocalVideoThumbnail(url: unknown): boolean {
|
||||
&& url.includes(`-${VIDEO_FRAME_THUMBNAIL_PROFILE}.webp`);
|
||||
}
|
||||
|
||||
async function createLocalImageThumbnail(input: {
|
||||
export async function createLocalImageThumbnail(input: {
|
||||
buffer: Buffer;
|
||||
sourceKey: string;
|
||||
thumbnailPrefix: string;
|
||||
|
||||
133
src/lib/reference-image-storage.ts
Normal file
133
src/lib/reference-image-storage.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import path from 'path';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { createLocalImageThumbnail, parseImageDataUrl, readImageBufferFromUrl } from '@/lib/media-storage';
|
||||
|
||||
export type PersistedReferenceImage = {
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
};
|
||||
|
||||
function normalizeReferenceUrl(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isPersistableReferenceUrl(url: string): boolean {
|
||||
return Boolean(url) && !url.startsWith('[');
|
||||
}
|
||||
|
||||
function contentExtension(mimeType: string, fallbackUrl = ''): string {
|
||||
const normalized = mimeType.split(';')[0]?.toLowerCase();
|
||||
if (normalized === 'image/jpeg' || normalized === 'image/jpg') return 'jpg';
|
||||
if (normalized === 'image/png') return 'png';
|
||||
if (normalized === 'image/webp') return 'webp';
|
||||
if (normalized === 'image/gif') return 'gif';
|
||||
const ext = path.extname(fallbackUrl.split('?')[0] || '').replace('.', '').toLowerCase();
|
||||
return /^(jpe?g|png|webp|gif)$/i.test(ext) ? (ext === 'jpeg' ? 'jpg' : ext) : 'jpg';
|
||||
}
|
||||
|
||||
async function persistReferenceUrl(url: string, index: number): Promise<PersistedReferenceImage | null> {
|
||||
const normalized = normalizeReferenceUrl(url);
|
||||
if (!isPersistableReferenceUrl(normalized)) return null;
|
||||
|
||||
const existingKey = localStorage.getKeyFromPublicUrl(normalized);
|
||||
if (existingKey) {
|
||||
const thumbnailUrl = await createLocalImageThumbnail({
|
||||
buffer: await localStorage.readFileAsync(existingKey),
|
||||
sourceKey: existingKey,
|
||||
thumbnailPrefix: 'thumbnails/works/references',
|
||||
}).catch(() => null);
|
||||
return { url: normalized, thumbnailUrl };
|
||||
}
|
||||
|
||||
const dataImage = parseImageDataUrl(normalized);
|
||||
if (dataImage) {
|
||||
const suffix = `${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const key = await localStorage.uploadFileObjectOnly({
|
||||
fileContent: dataImage.buffer,
|
||||
fileName: `works/references/${suffix}.${dataImage.ext || contentExtension(dataImage.mimeType)}`,
|
||||
contentType: dataImage.mimeType,
|
||||
});
|
||||
const url = await localStorage.generatePresignedUrl({ key, expireTime: 2592000 });
|
||||
const thumbnailUrl = await createLocalImageThumbnail({
|
||||
buffer: dataImage.buffer,
|
||||
sourceKey: key,
|
||||
thumbnailPrefix: 'thumbnails/works/references',
|
||||
}).catch(() => null);
|
||||
return { url, thumbnailUrl };
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
const source = await readImageBufferFromUrl(normalized);
|
||||
if (!source) return { url: normalized, thumbnailUrl: null };
|
||||
const suffix = `${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const key = await localStorage.uploadFileObjectOnly({
|
||||
fileContent: source.buffer,
|
||||
fileName: `works/references/${suffix}.${source.ext || contentExtension(source.mimeType, normalized)}`,
|
||||
contentType: source.mimeType,
|
||||
});
|
||||
const url = await localStorage.generatePresignedUrl({ key, expireTime: 2592000 });
|
||||
const thumbnailUrl = await createLocalImageThumbnail({
|
||||
buffer: source.buffer,
|
||||
sourceKey: key,
|
||||
thumbnailPrefix: 'thumbnails/works/references',
|
||||
}).catch(() => null);
|
||||
return { url, thumbnailUrl };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function persistReferenceImages(urls: unknown[]): Promise<PersistedReferenceImage[]> {
|
||||
const persisted: PersistedReferenceImage[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [index, value] of urls.entries()) {
|
||||
const url = normalizeReferenceUrl(value);
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
try {
|
||||
const reference = await persistReferenceUrl(url, index);
|
||||
if (reference && !persisted.some(item => item.url === reference.url)) {
|
||||
persisted.push(reference);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[reference-image-storage] persist reference image failed:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
export function getReferenceImageInputs(record: {
|
||||
referenceImage?: unknown;
|
||||
referenceImages?: unknown;
|
||||
params?: Record<string, unknown>;
|
||||
}): string[] {
|
||||
const params = record.params || {};
|
||||
const values = [
|
||||
record.referenceImage,
|
||||
...(Array.isArray(record.referenceImages) ? record.referenceImages : []),
|
||||
params.referenceImage,
|
||||
...(Array.isArray(params.referenceImages) ? params.referenceImages : []),
|
||||
params.image,
|
||||
...(Array.isArray(params.images) ? params.images : []),
|
||||
...(Array.isArray(params.extraImages) ? params.extraImages : []),
|
||||
params.sourceImage,
|
||||
params.source_image,
|
||||
params.inputImage,
|
||||
params.input_image,
|
||||
];
|
||||
return values
|
||||
.map(normalizeReferenceUrl)
|
||||
.filter(url => isPersistableReferenceUrl(url));
|
||||
}
|
||||
|
||||
export function getReferenceThumbnailInputs(params: Record<string, unknown>): string[] {
|
||||
return Array.isArray(params.referenceImageThumbnails)
|
||||
? params.referenceImageThumbnails.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
export default {
|
||||
getReferenceImageInputs,
|
||||
getReferenceThumbnailInputs,
|
||||
persistReferenceImages,
|
||||
};
|
||||
Reference in New Issue
Block a user