diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8136d39f6..031b36936 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,9 @@ "Bash(curl -s http://localhost:8081/health)", "Bash(npm -v)", "Bash(cargo --version)", - "Bash(rustc --version)" + "Bash(rustc --version)", + "Skill(code-review)", + "Skill(code-review:*)" ] } } diff --git a/.env b/.env index e1ed925f9..424107578 100644 --- a/.env +++ b/.env @@ -1,5 +1,7 @@ # 微信小程序 web-view 登录配置。 # 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。 +GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false + WECHAT_MINI_PROGRAM_APP_ID="" WECHAT_MINI_PROGRAM_APP_SECRET="" WECHAT_JS_CODE_SESSION_ENDPOINT="" diff --git a/.env.example b/.env.example index df656b892..6b611bebd 100644 --- a/.env.example +++ b/.env.example @@ -199,6 +199,10 @@ VITE_LLM_DEBUG_LOG="false" # Set to "true" to expose local diagnostic panels, or "false" to hide them. VITE_DEBUG_MODE="" +# Optional: show the image editor right-side Agent entry at runtime. +# This is read by api-server and exposed through /api/runtime/frontend-config. +GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false" + # Optional: official VikingDB credentials for regenerating build-tag similarities # with the Python embedding script. The script auto-loads `.env.local` and uses # the fixed `bge-large-zh` embedding model. diff --git a/.env.local b/.env.local index 311781f60..66c7cb8e0 100644 --- a/.env.local +++ b/.env.local @@ -42,6 +42,8 @@ LLM_DEBUG_LOG="true" # 注意:不要在客户端启用调试日志,避免敏感数据泄露 # VITE_LLM_DEBUG_LOG="false" +GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=true + ALIYUN_OSS_BUCKET="xushi-dev" ALIYUN_OSS_REGION="oss-cn-beijing" ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com" diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index ff5306b42..3ecc25e7c 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -11,8 +11,11 @@ import type { AdminDatabaseTableListResponse, AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, + AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, + AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminEditorAssetListQuery, AdminEditorAssetListResponse, + AdminDirectUploadTicketPayload, AdminEditorShowcaseAssetResponse, AdminEditorShowcaseCampaignResponse, AdminEditorShowcaseDisplayRequest, @@ -27,6 +30,7 @@ import type { AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest, AdminUpdateWorkVisibilityResponse, + AdminUploadedEditorShowcaseCampaignImage, AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRechargeProductRequest, @@ -394,6 +398,35 @@ export function upsertAdminEditorShowcaseCampaign( ); } +export async function uploadAdminEditorShowcaseCampaignImage( + token: string, + file: File, +): Promise { + const contentType = resolveAdminImageContentType(file); + const dimensions = await readAdminImageFileDimensions(file); + const response = await request( + '/admin/api/editor-showcase/campaign/image-upload-ticket', + { + method: 'POST', + token, + body: { + fileName: file.name.trim() || 'showcase-campaign.png', + contentType, + contentLength: file.size, + } satisfies AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, + }, + ); + await postAdminDirectUploadFile(response.upload, file); + const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, ''); + return { + imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath, + imageObjectKey: objectKey, + imageWidth: dimensions.imageWidth, + imageHeight: dimensions.imageHeight, + legacyPublicPath: response.upload.legacyPublicPath, + }; +} + export function listProfileRedeemCodes(token: string) { return request( '/admin/api/profile/redeem-codes', @@ -556,6 +589,123 @@ function buildAssetReadUrlQuery(query: AdminAssetReadUrlQuery) { return queryString ? `?${queryString}` : ''; } +function resolveAdminImageContentType(file: File) { + const declaredType = file.type.trim(); + if (declaredType.startsWith('image/')) { + return declaredType; + } + const extension = file.name.trim().toLowerCase().match(/\.([a-z0-9]+)$/u)?.[1]; + if (extension === 'jpg' || extension === 'jpeg') { + return 'image/jpeg'; + } + if (extension === 'png') { + return 'image/png'; + } + if (extension === 'webp') { + return 'image/webp'; + } + if (extension === 'gif') { + return 'image/gif'; + } + return declaredType || 'application/octet-stream'; +} + +function normalizeAdminImageDimensions(width: number, height: number) { + const imageWidth = Math.round(width); + const imageHeight = Math.round(height); + if ( + !Number.isFinite(imageWidth) || + !Number.isFinite(imageHeight) || + imageWidth <= 0 || + imageHeight <= 0 + ) { + return null; + } + return { imageWidth, imageHeight }; +} + +async function readAdminImageFileDimensions(file: File) { + if (typeof createImageBitmap === 'function') { + try { + const bitmap = await createImageBitmap(file); + const dimensions = normalizeAdminImageDimensions( + bitmap.width, + bitmap.height, + ); + bitmap.close(); + if (dimensions) { + return dimensions; + } + } catch { + // Fall back to HTMLImageElement decoding below. + } + } + + if ( + typeof Image === 'undefined' || + typeof URL === 'undefined' || + typeof URL.createObjectURL !== 'function' + ) { + throw new Error('读取活动卡图片尺寸失败,请重新选择图片'); + } + + return new Promise<{ imageWidth: number; imageHeight: number }>( + (resolve, reject) => { + const objectUrl = URL.createObjectURL(file); + const image = new Image(); + const cleanup = () => { + if (typeof URL.revokeObjectURL === 'function') { + URL.revokeObjectURL(objectUrl); + } + }; + image.onload = () => { + cleanup(); + const dimensions = normalizeAdminImageDimensions( + image.naturalWidth || image.width, + image.naturalHeight || image.height, + ); + if (dimensions) { + resolve(dimensions); + return; + } + reject(new Error('读取活动卡图片尺寸失败,请重新选择图片')); + }; + image.onerror = () => { + cleanup(); + reject(new Error('读取活动卡图片尺寸失败,请重新选择图片')); + }; + image.src = objectUrl; + }, + ); +} + +function buildAdminDirectUploadFormData( + upload: AdminDirectUploadTicketPayload, + file: File, +) { + const formData = new FormData(); + Object.entries(upload.formFields).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + formData.append(key, value); + } + }); + formData.append('file', file, file.name); + return formData; +} + +async function postAdminDirectUploadFile( + upload: AdminDirectUploadTicketPayload, + file: File, +) { + const response = await fetch(upload.host, { + method: 'POST', + body: buildAdminDirectUploadFormData(upload, file), + }); + if (!response.ok) { + throw new Error(`上传活动卡图片失败:HTTP ${response.status}`); + } +} + function buildQueryString(query: AdminTrackingEventListQuery) { const params = new URLSearchParams(); appendQueryParam(params, 'eventKey', query.eventKey); diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index f63ac4588..48f1a7656 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -457,6 +457,9 @@ export interface AdminEditorShowcaseCampaignPayload { enabled: boolean; title: string; imageSrc: string; + imageObjectKey?: string | null; + imageWidth?: number | null; + imageHeight?: number | null; prompt: string; author: string; costText: string; @@ -471,11 +474,41 @@ export interface AdminUpsertEditorShowcaseCampaignRequest { enabled: boolean; title: string; imageSrc: string; + imageObjectKey?: string | null; + imageWidth?: number | null; + imageHeight?: number | null; prompt: string; author: string; costText: string; } +export interface AdminDirectUploadTicketPayload { + bucket: string; + host: string; + objectKey: string; + legacyPublicPath: string; + contentType?: string | null; + formFields: Record; +} + +export interface AdminCreateEditorShowcaseCampaignImageUploadTicketRequest { + fileName: string; + contentType: string; + contentLength: number; +} + +export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse { + upload: AdminDirectUploadTicketPayload; +} + +export interface AdminUploadedEditorShowcaseCampaignImage { + imageSrc: string; + imageObjectKey: string; + imageWidth: number; + imageHeight: number; + legacyPublicPath: string; +} + export interface AdminUpsertProfileRedeemCodeRequest { code: string; mode: ProfileRedeemCodeMode; diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 9c7612f3d..346a7335d 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -1,14 +1,20 @@ /* @vitest-environment jsdom */ -import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'; -import {beforeEach, expect, test, vi} from 'vitest'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; import { getAdminAssetReadUrl, listAdminEditorAssets, } from '../api/adminApiClient'; -import type {AdminEditorAssetPayload} from '../api/adminApiTypes'; -import {AdminEditorAssetQueryPage} from './AdminEditorAssetQueryPage'; +import type { AdminEditorAssetPayload } from '../api/adminApiTypes'; +import { AdminEditorAssetQueryPage } from './AdminEditorAssetQueryPage'; vi.mock('../api/adminApiClient', () => ({ getAdminAssetReadUrl: vi.fn(), @@ -34,7 +40,7 @@ const generatedAsset: AdminEditorAssetPayload = { provider: 'character', taskId: 'task-1', assetKind: 'character', - generationInputs: {style: 'clay'}, + generationInputs: { style: 'clay' }, sourceResourceId: 'resource-1', thumbnailSrc: null, generationCostMudPoints: 12, @@ -60,10 +66,7 @@ beforeEach(() => { test('后台素材查询展示作者昵称和陶泥号', async () => { render( - , + , ); expect(await screen.findByText('作者昵称')).toBeTruthy(); @@ -73,25 +76,22 @@ test('后台素材查询展示作者昵称和陶泥号', async () => { test('后台素材查询按用户、搜索和时间调用查询接口', async () => { render( - , + , ); - await screen.findByRole('img', {name: '素材:角色形象 1'}); + await screen.findByRole('img', { name: '素材:角色形象 1' }); fireEvent.change(screen.getByLabelText('用户 ID'), { - target: {value: 'user-1'}, + target: { value: 'user-1' }, }); fireEvent.change(screen.getByLabelText('搜索'), { - target: {value: '陶泥角色'}, + target: { value: '陶泥角色' }, }); fireEvent.change(screen.getByLabelText('开始时间'), { - target: {value: '2026-07-01'}, + target: { value: '2026-07-01' }, }); fireEvent.change(screen.getByLabelText('结束时间'), { - target: {value: '2026-07-04'}, + target: { value: '2026-07-04' }, }); await waitFor(() => { @@ -107,26 +107,20 @@ test('后台素材查询按用户、搜索和时间调用查询接口', async () test('后台素材查询不展示分类筛选和分类列', async () => { render( - , + , ); expect(await screen.findByText('角色形象 1')).toBeTruthy(); expect(screen.queryByLabelText('分类')).toBeNull(); - expect(screen.queryByRole('columnheader', {name: '分类'})).toBeNull(); + expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull(); }); test('后台素材查询缩略图使用 objectKey 换签后展示', async () => { render( - , + , ); - const image = await screen.findByRole('img', {name: '素材:角色形象 1'}); + const image = await screen.findByRole('img', { name: '素材:角色形象 1' }); await waitFor(() => { expect(image.getAttribute('src')).toBe( @@ -139,6 +133,32 @@ test('后台素材查询缩略图使用 objectKey 换签后展示', async () => }); }); +test('后台素材查询音频素材使用统一封面缩略图', async () => { + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + assetId: 'asset-audio-1', + label: '胜利音效', + imageSrc: '/generated-editor-audios/sfx.mp3', + objectKey: 'generated-editor-audios/sfx.mp3', + assetKind: 'sound-effect', + }, + ], + nextCursor: null, + }); + + render( + , + ); + + const image = await screen.findByRole('img', { name: '素材:胜利音效' }); + expect(image.getAttribute('src')).toBe( + '/creation-home/audio-asset-cover.png', + ); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); +}); + test('后台素材查询格式化微秒时间文本', async () => { vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ entries: [ @@ -152,10 +172,7 @@ test('后台素材查询格式化微秒时间文本', async () => { }); render( - , + , ); expect(await screen.findByText('角色形象 1')).toBeTruthy(); @@ -164,15 +181,12 @@ test('后台素材查询格式化微秒时间文本', async () => { test('后台素材查询可查看素材详情', async () => { render( - , + , ); - fireEvent.click(await screen.findByRole('button', {name: '详情'})); + fireEvent.click(await screen.findByRole('button', { name: '详情' })); - const dialog = screen.getByRole('dialog', {name: '素材详情'}); + const dialog = screen.getByRole('dialog', { name: '素材详情' }); expect(within(dialog).getByText('asset-1')).toBeTruthy(); expect(within(dialog).getByText('1024 x 1024')).toBeTruthy(); expect(within(dialog).getByText('12 泥点')).toBeTruthy(); @@ -181,20 +195,21 @@ test('后台素材查询可查看素材详情', async () => { test('后台素材查询可打开弹窗查看完整提示词', async () => { render( - , + , ); - fireEvent.click(await screen.findByRole('button', {name: '完整提示词内容'})); + fireEvent.click( + await screen.findByRole('button', { name: '完整提示词内容' }), + ); - const dialog = screen.getByRole('dialog', {name: '完整提示词'}); + const dialog = screen.getByRole('dialog', { name: '完整提示词' }); expect(within(dialog).getByText('完整提示词内容')).toBeTruthy(); - fireEvent.click(within(dialog).getByRole('button', {name: '关闭完整提示词'})); + fireEvent.click( + within(dialog).getByRole('button', { name: '关闭完整提示词' }), + ); await waitFor(() => { - expect(screen.queryByRole('dialog', {name: '完整提示词'})).toBeNull(); + expect(screen.queryByRole('dialog', { name: '完整提示词' })).toBeNull(); }); }); diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index b5b705e9a..177865f24 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -1,17 +1,17 @@ -import {Eye, FileText, RefreshCcw, X} from 'lucide-react'; -import type {ReactNode} from 'react'; -import {useEffect, useState} from 'react'; +import { Eye, FileText, RefreshCcw, X } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import { getAdminAssetReadUrl, listAdminEditorAssets, } from '../api/adminApiClient'; -import type {AdminAssetReadUrlResponse} from '../api/adminApiClient'; +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import type { AdminEditorAssetListQuery, AdminEditorAssetPayload, } from '../api/adminApiTypes'; -import {handlePageError} from './pageUtils'; +import { handlePageError } from './pageUtils'; interface AdminEditorAssetQueryPageProps { token: string; @@ -19,6 +19,7 @@ interface AdminEditorAssetQueryPageProps { } const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; export function AdminEditorAssetQueryPage({ token, @@ -274,10 +275,11 @@ export function AdminEditorAssetQueryPage({ ); } -function AdminAssetThumbnail({entry}: {entry: AdminEditorAssetPayload}) { +function AdminAssetThumbnail({ entry }: { entry: AdminEditorAssetPayload }) { + const isAudio = isAdminAudioAsset(entry); const imageSrc = useAdminResolvedAssetImageSrc( - entry.thumbnailSrc || entry.imageSrc, - entry.objectKey, + isAudio ? AUDIO_ASSET_COVER_SRC : entry.thumbnailSrc || entry.imageSrc, + isAudio ? null : entry.objectKey, ); const alt = `素材:${entry.label || entry.assetId}`; @@ -288,6 +290,16 @@ function AdminAssetThumbnail({entry}: {entry: AdminEditorAssetPayload}) { ); } +function isAdminAudioAsset(entry: AdminEditorAssetPayload) { + const assetKind = entry.assetKind?.trim() ?? ''; + return ( + assetKind === 'sound-effect' || + assetKind === 'background-music' || + assetKind === 'editor_uploaded_audio' || + /\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim()) + ); +} + function AdminAssetDetailDialog({ entry, onClose, @@ -334,7 +346,9 @@ function AdminAssetDetailDialog({ {entry.generationCostMudPoints} 泥点 {entry.model || '-'} - {entry.provider || '-'} + + {entry.provider || '-'} + {entry.taskId || '-'} {entry.objectKey || '-'} @@ -463,7 +477,8 @@ function mergeAssetEntries( [...current, ...incoming].forEach((entry) => byId.set(entry.assetId, entry)); return [...byId.values()].sort( (left, right) => - parseAdminTimestamp(right.createdAt) - parseAdminTimestamp(left.createdAt) || + parseAdminTimestamp(right.createdAt) - + parseAdminTimestamp(left.createdAt) || right.assetId.localeCompare(left.assetId), ); } @@ -505,13 +520,13 @@ function parseAdminTimestamp(value: string | null | undefined) { function authorDisplayName(entry: AdminEditorAssetPayload) { return ( - entry.authorDisplayName?.trim() || - entry.authorPublicUserCode?.trim() || - '-' + entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-' ); } -function formatGenerationInputs(value: Record | null | undefined) { +function formatGenerationInputs( + value: Record | null | undefined, +) { if (!value) { return '-'; } diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx index f67e00914..766c8b05f 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -1,7 +1,13 @@ /* @vitest-environment jsdom */ -import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'; -import {beforeEach, expect, test, vi} from 'vitest'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { beforeEach, expect, test, vi } from 'vitest'; import { getAdminAssetReadUrl, @@ -9,10 +15,11 @@ import { listAdminEditorShowcaseAssets, reviewAdminEditorShowcaseAsset, updateAdminEditorShowcaseDisplay, + uploadAdminEditorShowcaseCampaignImage, upsertAdminEditorShowcaseCampaign, } from '../api/adminApiClient'; -import type {AdminEditorShowcaseAssetPayload} from '../api/adminApiTypes'; -import {AdminEditorShowcaseReviewPage} from './AdminEditorShowcaseReviewPage'; +import type { AdminEditorShowcaseAssetPayload } from '../api/adminApiTypes'; +import { AdminEditorShowcaseReviewPage } from './AdminEditorShowcaseReviewPage'; vi.mock('../api/adminApiClient', () => ({ getAdminAssetReadUrl: vi.fn(), @@ -20,6 +27,7 @@ vi.mock('../api/adminApiClient', () => ({ listAdminEditorShowcaseAssets: vi.fn(), reviewAdminEditorShowcaseAsset: vi.fn(), updateAdminEditorShowcaseDisplay: vi.fn(), + uploadAdminEditorShowcaseCampaignImage: vi.fn(), upsertAdminEditorShowcaseCampaign: vi.fn(), })); @@ -40,7 +48,7 @@ const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = { provider: 'character', taskId: 'task-1', assetKind: 'character', - generationInputs: {style: 'clay'}, + generationInputs: { style: 'clay' }, generationCostMudPoints: 12, refundMudPoints: 6, reviewStatus: 'pending', @@ -82,6 +90,9 @@ beforeEach(() => { enabled: true, title: '活动卡', imageSrc: '/campaign.png', + imageObjectKey: null, + imageWidth: null, + imageHeight: null, prompt: '活动提示词', author: '官方', costText: '12 泥点', @@ -115,12 +126,24 @@ beforeEach(() => { enabled: false, title: '新活动卡', imageSrc: '/new-campaign.png', + imageObjectKey: null, + imageWidth: null, + imageHeight: null, prompt: '新活动提示词', author: '官方', costText: '6 泥点', updatedAt: '2026-07-04T10:20:00Z', }, }); + vi.mocked(uploadAdminEditorShowcaseCampaignImage).mockResolvedValue({ + imageSrc: '/generated-character-drafts/editor/showcase-campaign/card.png', + imageObjectKey: + 'generated-character-drafts/editor/showcase-campaign/card.png', + imageWidth: 900, + imageHeight: 1200, + legacyPublicPath: + '/generated-character-drafts/editor/showcase-campaign/card.png', + }); }); test('后台精选审核展示待审核素材和活动卡配置', async () => { @@ -169,6 +192,36 @@ test('后台精选审核格式化微秒时间并显示素材名', async () => { expect(screen.queryByText('1783231493.573727Z')).toBeNull(); }); +test('后台精选审核音频素材使用统一封面缩略图', async () => { + vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ + entries: [ + { + ...pendingShowcaseAsset, + showcaseId: 'showcase-audio-1', + assetId: 'asset-audio-1', + label: '胜利音效', + imageSrc: '/generated-editor-audios/sfx.mp3', + objectKey: 'generated-editor-audios/sfx.mp3', + assetKind: 'sound-effect', + }, + ], + nextCursor: null, + }); + + render( + , + ); + + const image = await screen.findByRole('img', { name: '精选素材:胜利音效' }); + expect(image.getAttribute('src')).toBe( + '/creation-home/audio-asset-cover.png', + ); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); +}); + test('后台精选审核可以通过素材并查看完整提示词', async () => { render( />, ); - fireEvent.change( - await screen.findByLabelText('审核备注:角色形象 1'), - {target: {value: '质量通过'}}, - ); - fireEvent.click(screen.getByRole('button', {name: '通过'})); + fireEvent.change(await screen.findByLabelText('审核备注:角色形象 1'), { + target: { value: '质量通过' }, + }); + fireEvent.click(screen.getByRole('button', { name: '通过' })); await waitFor(() => { expect(reviewAdminEditorShowcaseAsset).toHaveBeenCalledWith('admin-token', { @@ -191,8 +243,8 @@ test('后台精选审核可以通过素材并查看完整提示词', async () => }); }); - fireEvent.click(screen.getByRole('button', {name: '完整提示词内容'})); - const dialog = screen.getByRole('dialog', {name: '完整提示词'}); + fireEvent.click(screen.getByRole('button', { name: '完整提示词内容' })); + const dialog = screen.getByRole('dialog', { name: '完整提示词' }); expect(within(dialog).getByText('完整提示词内容')).toBeTruthy(); }); @@ -209,7 +261,7 @@ test('后台精选审核可以切换展示和保存活动卡', async () => { />, ); - fireEvent.click(await screen.findByRole('button', {name: '隐藏'})); + fireEvent.click(await screen.findByRole('button', { name: '隐藏' })); await waitFor(() => { expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith( 'admin-token', @@ -222,15 +274,15 @@ test('后台精选审核可以切换展示和保存活动卡', async () => { }); fireEvent.change(await screen.findByLabelText('标题'), { - target: {value: '新活动卡'}, + target: { value: '新活动卡' }, }); fireEvent.change(screen.getByLabelText('图片地址'), { - target: {value: '/new-campaign.png'}, + target: { value: '/new-campaign.png' }, }); fireEvent.change(screen.getByLabelText('成本文案'), { - target: {value: '6 泥点'}, + target: { value: '6 泥点' }, }); - fireEvent.click(screen.getByRole('button', {name: '保存活动卡'})); + fireEvent.click(screen.getByRole('button', { name: '保存活动卡' })); await waitFor(() => { expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith( @@ -238,12 +290,90 @@ test('后台精选审核可以切换展示和保存活动卡', async () => { expect.objectContaining({ title: '新活动卡', imageSrc: '/new-campaign.png', + imageObjectKey: null, costText: '6 泥点', }), ); }); }); +test('后台精选活动卡上传图片时替换图片引用并隐藏 objectKey', async () => { + render( + , + ); + + await screen.findByDisplayValue('活动卡'); + const file = new File(['image-bytes'], 'card.png', { type: 'image/png' }); + fireEvent.change(screen.getByLabelText('上传活动卡图片'), { + target: { files: [file] }, + }); + + await waitFor(() => { + expect(uploadAdminEditorShowcaseCampaignImage).toHaveBeenCalledWith( + 'admin-token', + file, + ); + }); + expect( + await screen.findByDisplayValue( + '/generated-character-drafts/editor/showcase-campaign/card.png', + ), + ).toBeTruthy(); + expect( + screen.queryByDisplayValue( + 'generated-character-drafts/editor/showcase-campaign/card.png', + ), + ).toBeNull(); + + vi.mocked(uploadAdminEditorShowcaseCampaignImage).mockResolvedValueOnce({ + imageSrc: + '/generated-character-drafts/editor/showcase-campaign/replacement.png', + imageObjectKey: + 'generated-character-drafts/editor/showcase-campaign/replacement.png', + imageWidth: 1024, + imageHeight: 1536, + legacyPublicPath: + '/generated-character-drafts/editor/showcase-campaign/replacement.png', + }); + const replacementFile = new File(['replacement-bytes'], 'replacement.png', { + type: 'image/png', + }); + fireEvent.change(screen.getByLabelText('上传活动卡图片'), { + target: { files: [replacementFile] }, + }); + + await waitFor(() => { + expect(uploadAdminEditorShowcaseCampaignImage).toHaveBeenCalledWith( + 'admin-token', + replacementFile, + ); + }); + expect( + await screen.findByDisplayValue( + '/generated-character-drafts/editor/showcase-campaign/replacement.png', + ), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '保存活动卡' })); + + await waitFor(() => { + expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith( + 'admin-token', + expect.objectContaining({ + imageSrc: + '/generated-character-drafts/editor/showcase-campaign/replacement.png', + imageObjectKey: + 'generated-character-drafts/editor/showcase-campaign/replacement.png', + imageWidth: 1024, + imageHeight: 1536, + }), + ); + }); +}); + test('后台精选审核已通过素材可以设置精选分类', async () => { vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ entries: [ @@ -271,7 +401,7 @@ test('后台精选审核已通过素材可以设置精选分类', async () => { ); fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), { - target: {value: 'ui'}, + target: { value: 'ui' }, }); await waitFor(() => { @@ -286,7 +416,7 @@ test('后台精选审核已通过素材可以设置精选分类', async () => { }); }); -test('后台精选审核清空精选分类时自动隐藏素材', async () => { +test('后台精选审核清空精选分类时不会自动隐藏素材', async () => { vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({ entries: [approvedShowcaseAsset], nextCursor: null, @@ -295,7 +425,7 @@ test('后台精选审核清空精选分类时自动隐藏素材', async () => { entry: { ...approvedShowcaseAsset, showcaseCategory: null, - displayEnabled: false, + displayEnabled: true, }, }); @@ -307,7 +437,7 @@ test('后台精选审核清空精选分类时自动隐藏素材', async () => { ); fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), { - target: {value: ''}, + target: { value: '' }, }); await waitFor(() => { @@ -315,8 +445,8 @@ test('后台精选审核清空精选分类时自动隐藏素材', async () => { 'admin-token', { showcaseId: 'showcase-2', - displayEnabled: false, - showcaseCategory: null, + displayEnabled: true, + showcaseCategory: '', }, ); }); diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx index f3fbd561c..a6fb7d12d 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx @@ -1,6 +1,6 @@ -import {Eye, FileText, RefreshCcw, X} from 'lucide-react'; -import type {ReactNode} from 'react'; -import {useEffect, useState} from 'react'; +import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { getAdminAssetReadUrl, @@ -8,15 +8,16 @@ import { listAdminEditorShowcaseAssets, reviewAdminEditorShowcaseAsset, updateAdminEditorShowcaseDisplay, + uploadAdminEditorShowcaseCampaignImage, upsertAdminEditorShowcaseCampaign, } from '../api/adminApiClient'; -import type {AdminAssetReadUrlResponse} from '../api/adminApiClient'; +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; import type { AdminEditorShowcaseAssetPayload, AdminEditorShowcaseCampaignPayload, AdminEditorShowcaseListQuery, } from '../api/adminApiTypes'; -import {handlePageError} from './pageUtils'; +import { handlePageError } from './pageUtils'; interface AdminEditorShowcaseReviewPageProps { token: string; @@ -24,20 +25,20 @@ interface AdminEditorShowcaseReviewPageProps { } const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; const showcaseCategoryOptions = [ - {value: 'packs', label: '素材包'}, - {value: 'characters', label: '角色'}, - {value: 'ui', label: 'UI'}, - {value: 'music', label: '音乐'}, - {value: 'marketing', label: '美宣'}, + { value: 'characters', label: '角色' }, + { value: 'ui', label: 'UI' }, + { value: 'music', label: '音乐' }, + { value: 'marketing', label: '美宣' }, ]; const reviewStatusOptions = [ - {value: '', label: '全部'}, - {value: 'pending', label: '待审核'}, - {value: 'approved', label: '已通过'}, - {value: 'rejected', label: '已拒绝'}, + { value: '', label: '全部' }, + { value: 'pending', label: '待审核' }, + { value: 'approved', label: '已通过' }, + { value: 'rejected', label: '已拒绝' }, ]; export function AdminEditorShowcaseReviewPage({ @@ -68,8 +69,14 @@ export function AdminEditorShowcaseReviewPage({ prompt: '', author: '', costText: '', + imageObjectKey: null, + imageWidth: null, + imageHeight: null, }); const [isSavingCampaign, setIsSavingCampaign] = useState(false); + const [isUploadingCampaignImage, setIsUploadingCampaignImage] = + useState(false); + const campaignImageInputRef = useRef(null); useEffect(() => { void refreshPage(); @@ -175,11 +182,11 @@ export function AdminEditorShowcaseReviewPage({ showcaseCategory: string, ) { setErrorMessage(''); - const nextCategory = showcaseCategory.trim() || null; + const nextCategory = showcaseCategory.trim(); try { const response = await updateAdminEditorShowcaseDisplay(token, { showcaseId: entry.showcaseId, - displayEnabled: nextCategory ? entry.displayEnabled : false, + displayEnabled: entry.displayEnabled, showcaseCategory: nextCategory, }); replaceEntry(response.entry); @@ -196,6 +203,9 @@ export function AdminEditorShowcaseReviewPage({ enabled: campaignDraft.enabled, title: campaignDraft.title, imageSrc: campaignDraft.imageSrc, + imageObjectKey: campaignDraft.imageObjectKey ?? null, + imageWidth: campaignDraft.imageWidth ?? null, + imageHeight: campaignDraft.imageHeight ?? null, prompt: campaignDraft.prompt, author: campaignDraft.author, costText: campaignDraft.costText, @@ -210,6 +220,31 @@ export function AdminEditorShowcaseReviewPage({ } } + async function handleCampaignImageFile(file: File | null | undefined) { + if (!file) { + return; + } + setIsUploadingCampaignImage(true); + setErrorMessage(''); + try { + const upload = await uploadAdminEditorShowcaseCampaignImage(token, file); + setCampaignDraft((current) => ({ + ...current, + imageSrc: upload.imageSrc, + imageObjectKey: upload.imageObjectKey, + imageWidth: upload.imageWidth, + imageHeight: upload.imageHeight, + })); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsUploadingCampaignImage(false); + if (campaignImageInputRef.current) { + campaignImageInputRef.current.value = ''; + } + } + } + function replaceEntry(entry: AdminEditorShowcaseAssetPayload) { setEntries((current) => current.map((item) => @@ -314,9 +349,7 @@ export function AdminEditorShowcaseReviewPage({ {entry.label || '-'} - - {formatDateTime(entry.submittedAt)} - + {formatDateTime(entry.submittedAt)} {authorDisplayName(entry)} {entry.authorPublicUserCode?.trim() || '-'} @@ -342,16 +375,14 @@ export function AdminEditorShowcaseReviewPage({ )} - + {reviewStatusLabel(entry.reviewStatus)} {entry.reviewStatus === 'approved' ? ( - {entry.displayEnabled - ? '展示中' - : entry.showcaseCategory - ? '已隐藏' - : '待设置分类'} + {entry.displayEnabled ? '展示中' : '未展示'} ) : null} @@ -481,15 +512,39 @@ export function AdminEditorShowcaseReviewPage({ {entry.likeCount} {entry.model || '-'} - {entry.provider || '-'} + + {entry.provider || '-'} + {entry.taskId || '-'} {entry.objectKey || '-'} @@ -869,13 +940,13 @@ function reviewStatusClassName(value: string) { function authorDisplayName(entry: AdminEditorShowcaseAssetPayload) { return ( - entry.authorDisplayName?.trim() || - entry.authorPublicUserCode?.trim() || - '-' + entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-' ); } -function formatGenerationInputs(value: Record | null | undefined) { +function formatGenerationInputs( + value: Record | null | undefined, +) { if (!value) { return '-'; } diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index b281b1dec..08dc8b68d 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -1091,6 +1091,20 @@ button:disabled { min-width: 160px; } +.admin-hidden-file-input { + display: none; +} + +.admin-showcase-campaign-image-row { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-showcase-campaign-image-row input { + min-width: 0; +} + .admin-showcase-campaign-prompt { min-width: min(100%, 320px); } diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index a0ac06244..7d11c33f4 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -49,6 +49,7 @@ AUTH_REFRESH_COOKIE_PATH=/api/auth AUTH_REFRESH_COOKIE_SAME_SITE=Lax AUTH_REFRESH_COOKIE_SECURE=true GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=false +GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101 GENARRATIVE_SPACETIME_DATABASE=genarrative-prod @@ -138,6 +139,8 @@ GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET= GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false +# 可选:显式要求备份工作目录所在文件系统至少保留的可用空间;为空时按数据目录大小 + 安全余量估算。 +GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES= # 可选:定时 / publish 前备份使用独立最小权限 AccessKey;为空时回退 ALIYUN_OSS_ACCESS_KEY_*。 GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID= GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET= diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f2fed8ef8..f6bae0280 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -44,6 +44,39 @@ - 影响范围:AI 游戏创作 App 的 Tauri Rust 配置加载、主窗口配置面板、CLI wrapper、agent-run smoke、`check-config` 门禁、`.gitignore` 和实施计划文档。 - 验证方式:运行 `npm run ai-game-creator-shell:typecheck`、`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-07-02 图片画布生成抠图背景色使用 screenColor 传递 + +- 背景:画布角色、图标和 UI 素材生成过去固定要求 `#00FF00` 绿幕,后续 BGfilter 服务需要按生成时背景色做去背景,不能继续把背景色写死在 prompt 或后处理里。 +- 决策:角色形象、图标 spritesheet 和 UI 设计图素材提取不再向用户提供手动抠图背景色选择;前端用户路径统一提交 `screenColor=auto`,但用户可见生成输入快照不再写入 `抠图背景色` 或 `抠图模型`。api-server 在 11 个候选色中自动决策具体 hex,失败后兜底 `#CFEFFF`;最终 prompt 和 BgFilter 去背景只接收解析后的具体 hex 作为 `screen_color`。后端仍保留手动 hex 解析能力供内部兼容,角色动作抽帧暂不接入该选择,继续使用 legacy `#00FF00` 绿幕。 +- 影响范围:`/editor/canvas` 角色形象生成、图标素材生成、UI 设计图素材提取、BgFilter 服务入参、图片画布 MVP 和角色形象生成设计文档。 +- 验证方式:运行画布生成模型 / workflow / API client 定向前端测试、`cargo test -p api-server editor_green_screen --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-image generated_asset_sheet_light_blue_key_color_removes_selected_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 + +## 2026-07-05 图片画布抠图背景色自动决策 + +- 背景:手动背景色选择对用户负担较高,且不同角色、图标和 UI 素材主题需要避开不同主体色;但 BgFilter 和生成 prompt 仍必须拿到明确的纯色 hex。 +- 决策:前端用户路径直接固定通过 `screenColor=auto` 提交,不再展示背景色选项;api-server 新增 `editor_screen_background_decision` 模块,在角色形象、图标 spritesheet 和 UI 设计图素材提取组装 prompt 前解析 `screenColor`。手动 hex 直接校验并使用;`auto` 通过服务端 LLM 在 11 个候选色中选择具体 hex,最多重试 3 次,LLM 未配置、请求失败或返回非法颜色时 fallback 到 `浅雾蓝 #CFEFFF`。自动解析结果不写入用户可见生成输入快照;最终生图 prompt 和 BgFilter `screen_color` 永远只接收具体 hex,不透传 `auto`。 +- 影响范围:`/editor/canvas` 角色形象生成、图标素材生成、UI 设计图素材提取、api-server LLM 调用、BgFilter 参数、图片画布文档。 +- 验证方式:运行背景决策模块单测、画布生成模型 / workflow / API client 定向前端测试、`cargo test -p api-server editor_screen_background_decision editor_green_screen --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。 + +## 2026-07-05 BgFilter 失败时本地纯色去背兜底 + +- 背景:曾用一次性 BgFilter live probe 稳定复现 BgFilter 对 2K 输入返回 `HTTP 500 {"detail":"inference failed"}`,浏览器生成链路会因此收到“BgFilter 服务返回非成功状态”。 +- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取仍优先调用独立 BgFilter;若 BgFilter 请求失败、返回非成功状态、返回空图片或非法图片,api-server 记录 warning 后使用本地 `editor_green_screen` 按解析后的纯色背景执行确定性去背兜底,不中断生成。手动任意图片去背景仍只走独立 BiRefNet BFF,不使用该兜底。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、BgFilter 运维排障、图片画布生成后处理。 +- 验证方式:运行 `cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess editor_green_screen --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。 + +## 2026-07-03 图片画布生成纯色背景资产接入 BgFilter + +- 背景:独立 BgFilter 服务已部署在 image host,并提供 `POST /bgfilter/remove-background`,支持显式 `screen_color` 和 `seg_model`。手动去背景已有独立 BiRefNet BFF,不能把两个服务的配置或语义混在一起。 +- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file`、`screen_color=` 和 `seg_model=`,前端用户路径固定提交 `segModel=birefnet` 且不展示抠图模型选择;`anime-seg` 路径保留为后端可识别的内部能力但不对用户可见。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,token 未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。手动 `POST /api/editor/images/background-removals` 继续使用独立 BiRefNet 配置 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`,不受 BgFilter 影响。BgFilter 参数里的 `seg_model=birefnet` 只表示 BgFilter 内部分割后端,不等于手动去背景的独立 BiRefNet 服务。若 BgFilter 失败,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 兜底。角色动作抽帧仍保留 legacy `#00FF00` 与本地 `editor_green_screen` 透明化。 +- 影响范围:`server-rs/crates/api-server/src/config.rs`、`server-rs/crates/api-server/src/editor_project.rs`、图片画布 MVP 文档和角色形象生成设计文档。 +- 验证方式:运行 `cargo test -p api-server config::tests::from_env_reads_editor_bgfilter_settings_and_reuses_background_token editor_project::tests::editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 + ## 2026-07-03 作品公开默认关闭 - 背景:作品发布完成不应默认进入公开广场 / 公开详情 / 公开互动消费路径,需要先由后台可见性开关明确开启。 @@ -55,7 +88,7 @@ ## 2026-07-04 陶泥儿精选改为素材提交审核后公开 - 背景:`/creation` 的 `陶泥儿精选` 过去依赖 `editor_project_resource.public_showcase_enabled`,生成画布资源默认可公开,和“作品公开默认关闭、由用户主动投稿精选”的运营要求冲突,也无法在后台审核、返还泥点和配置固定活动卡。 -- 决策:`陶泥儿精选` 的公开事实改为独立 `editor_showcase_asset` 审核表。生成素材默认不公开;用户在账号级素材库对 `sourceType="generated"` 且有媒体内容的素材提交审核,后端快照素材信息并写入 `pending`。后台审核通过后写入 `approved`,但默认 `display_enabled=false` 且 `showcase_category=null`,运营需按前台 Tab 手动设置分类并开启展示;审核通过时按 `generation_cost_mud_points` 返还 50% 泥点;拒绝后写入 `rejected`。公开接口 `GET /api/editor/showcase/resources` 只返回已通过、展示开启且分类合法的快照,按通过时间和 `showcaseId` 倒序分页,并可携带后台配置的固定活动卡。旧 `editor_project_resource.public_showcase_enabled` 和旧 PATCH 接口只保留兼容,不再驱动精选公开。 +- 决策:`陶泥儿精选` 的公开事实改为独立 `editor_showcase_asset` 审核表。生成素材默认不公开;用户在账号级素材库对 `sourceType="generated"` 且有媒体内容的素材提交审核,后端快照素材信息并写入 `pending`。后台审核通过后写入 `approved`,但默认 `display_enabled=false` 且 `showcase_category=null`,运营可按前台具体 Tab 手动设置分类并开启展示;未设置分类的素材展示开启后进入前台“全部”,但不进入角色 / UI / 音乐 / 美宣具体分类。审核通过时按 `generation_cost_mud_points` 返还 50% 泥点;拒绝后写入 `rejected`。公开接口 `GET /api/editor/showcase/resources` 返回已通过、展示开启且媒体非空的快照,按通过时间和 `showcaseId` 倒序分页,并可携带后台配置的固定活动卡。旧 `editor_project_resource.public_showcase_enabled` 和旧 PATCH 接口只保留兼容,不再驱动精选公开。 - 影响范围:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`spacetime-client` 绑定与 mapper、`api-server` 编辑器和后台路由、admin-web 精选审核页、素材库右键菜单、`/creation` 精选瀑布流、图片画布文档和后端表目录。 - 验证方式:运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check --manifest-path server-rs/Cargo.toml -p spacetime-module -p spacetime-client -p api-server`、前端 / 后台 typecheck 与精选相关组件测试,确认默认不公开、提交后 pending、审核通过后展示和返还、展示开关与点赞生效。 - 关联文档:`docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 @@ -267,7 +300,7 @@ ## 2026-06-18 图片画布 UI 设计图提取素材保留图集 - 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。 -- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。`,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材;图标素材生成只保留扣绿后的 spritesheet 图集,不再额外铺独立图标。 +- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材;图标素材生成只保留透明 spritesheet 图集,不再额外铺独立图标。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。 - 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。 - 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`。 - 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 27a992727..eb4bd1d97 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -167,6 +167,14 @@ - 验证:对应测试应断言生成按钮点击后 `dialog` 消失但 `image-canvas-editor__generation-frame--generating` 仍然存在。 - 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`。 +## 图片画布生成器全体点不开先查卡住的临时交互状态 + +- 现象:特定操作后,画布中已有生成器点击不再显示设定对话框,而且不是单个生成器坏掉;新建生成器或刷新页面后恢复。 +- 原因:旧生成器激活依赖全局交互状态;如果 `Shift` / 空格按住态因为窗口失焦漏掉 `keyup`,或“从画布选择参考图”等临时 picking / 菜单状态没有在激活旧生成器时清理,后续点击会被当成多选或选参考图而短路。 +- 处理:窗口 `blur` / 页面隐藏时释放 `Shift` 和空格按住态;激活已有 generation dialog 时同步清理参考图 picking、规格 / 参考菜单和右键菜单;active / inactive 生成器状态的 ref 与 React state 必须同事件周期同步。 +- 验证:`npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx -- --runInBand`,并跑 `ImageCanvasEditorView.test.tsx` 确认真实组件链路仍能激活生成器。 +- 关联:`src/components/image-editor/useCanvasGenerationDialogs.ts`、`src/components/image-editor/useImageCanvasKeyboardShortcuts.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`。 + ## 图片画布素材多时拖拽卡顿先查等距吸附候选规模 - 现象:画布素材数量增加后,拖拽单个图层或生成占位框时 pointermove 明显卡顿,关闭或绕开吸附后体感恢复。 @@ -362,7 +370,7 @@ ## Pingora 直连 80/443 不能只改 env - 现象:`/etc/genarrative/pingora-gateway.env` 已把 `GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN` / `HTTP_REDIRECT_LISTEN` 改到 `0.0.0.0:443` / `0.0.0.0:80`,但 `genarrative-pingora-gateway.service` 启动失败,日志出现低端口绑定权限错误。 -- 原因:默认 service 用非 root `genarrative` 用户运行,并且主模板为了保持 shadow 安全边界不带 `CAP_NET_BIND_SERVICE`。低端口直连必须通过显式 systemd drop-in 单独授予 capability;同时 Certbot 私钥默认未必允许 `genarrative` 读取,Nginx 也可能仍占用 `80/443`。另一个常见误区是 API release 只带 `pingora-direct-enable.sh` / rollback 壳脚本,却漏带 `pingora-current-release-audit.mjs`、`pingora-direct-rehearsal-status.mjs`、`check-pingora-direct-preflight.mjs`、`check-pingora-direct-live.mjs`、`deploy/systemd/`、`deploy/env/` 或 `deploy/pingora/`,导致从 `/opt/genarrative/current` 启用时依赖 Jenkins 工作区、源码 checkout 或 `/etc` 里某份参考模板;或者 release 已经包含新版 `pingora-gateway`,但已运行的 shadow / canary / direct service 没有随 `current` 链接切换重启,仍在跑旧二进制。Server-Provision 安装到 `/etc/genarrative/pingora/genarrative-pingora-gateway-direct-entry.conf` 的 drop-in 只用于人工审阅和显式覆盖;直连启用脚本默认必须读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,否则旧 `/etc` 模板会掩盖发布包缺失。API deploy 脚本本身也不能继续用部署工作区根部的 `scripts/deploy/production-api-deploy.sh`,否则 Jenkins workspace 里的脚本会掩盖 `build/` 发布包缺少 deploy / maintenance 同目录脚本的问题;备份脚本、健康巡检脚本和 env 示例目录同样不能从部署工作区兜底,切换命令证据脚本也不能从部署工作区兜底,否则 current release 会和上游构建归档漂移。Pingora 直连依赖、备份脚本、巡检脚本、env 示例目录和 API deploy 执行入口都必须来自上游发布产物;随包 `api-server.sha256` 和可选 `pingora-gateway.sha256` 也必须复制进 current release,供随包 current release 自审校验二进制;随包 `deploy/pingora/pingora-gateway.env.example` 也不能只检查存在,还要保持 gzip-only、不信任 XFF、前置代理确认关闭、接流保护开启和空 probe token 这些生产安全默认值;`production-api-deploy.sh` 发现缺失时应 fail-fast 并保持维护模式,不应从部署工作区兜底补齐;所有 API 发布包都必须携带 `release-manifest.json` 且登记 `api-server` artifact,发布包包含 Pingora 时还必须登记 `pingora-gateway` artifact,否则 deploy 应在切换 current 前失败;deploy 必须要求 release root、current link 和 api env file 都是绝对路径,release version 以数字或字母开头并拒绝点目录,再先写 staging release,全部复制完成后用非合并语义提升为正式 release,失败时清理 staging 且不留下正式 release,同版本 release 已存在、提升前竞态出现或 current 路径不是符号链接时拒绝覆盖 / 合并,避免旧文件混入 current;发布包包含 Pingora 时,deploy 必须先确认 systemd 最终配置没有 direct-entry `CAP_NET_BIND_SERVICE`、env 仍是 `127.0.0.1:18081` shadow 且未配置 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,再提升 release、切换 current 并 `restart` Pingora shadow;配置不安全时必须在切换 current 前失败并保持维护模式。 +- 原因:默认 service 用非 root `genarrative` 用户运行,并且主模板为了保持 shadow 安全边界不带 `CAP_NET_BIND_SERVICE`。低端口直连必须通过显式 systemd drop-in 单独授予 capability;同时 Certbot 私钥默认未必允许 `genarrative` 读取,Nginx 也可能仍占用 `80/443`。另一个常见误区是 API release 只带 `pingora-direct-enable.sh` / rollback 壳脚本,却漏带 `pingora-current-release-audit.mjs`、`pingora-direct-rehearsal-status.mjs`、`check-pingora-direct-preflight.mjs`、`check-pingora-direct-live.mjs`、`deploy/systemd/`、`deploy/env/` 或 `deploy/pingora/`,导致从 `/opt/genarrative/current` 启用时依赖 Jenkins 工作区、源码 checkout 或 `/etc` 里某份参考模板;或者 release 已经包含新版 `pingora-gateway`,但已运行的 shadow / canary / direct service 没有随 `current` 链接切换重启,仍在跑旧二进制。Jenkins API Build、API Deploy 和 Full Build-And-Deploy 默认要求 Pingora 产物,并用 `--require-pingora-gateway` 在部署阶段硬校验;手工本地 API 包仍需显式 `--include-pingora-gateway` 才会把二进制、checksum 和 manifest artifact 写入发布包。Server-Provision 安装到 `/etc/genarrative/pingora/genarrative-pingora-gateway-direct-entry.conf` 的 drop-in 只用于人工审阅和显式覆盖;直连启用脚本默认必须读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,否则旧 `/etc` 模板会掩盖发布包缺失。API deploy 脚本本身也不能继续用部署工作区根部的 `scripts/deploy/production-api-deploy.sh`,否则 Jenkins workspace 里的脚本会掩盖 `build/` 发布包缺少 deploy / maintenance 同目录脚本的问题;备份脚本、健康巡检脚本和 env 示例目录同样不能从部署工作区兜底,切换命令证据脚本也不能从部署工作区兜底,否则 current release 会和上游构建归档漂移。Pingora 直连依赖、备份脚本、巡检脚本、env 示例目录和 API deploy 执行入口都必须来自上游发布产物;随包 `api-server.sha256` 和可选 `pingora-gateway.sha256` 也必须复制进 current release,供随包 current release 自审校验二进制;随包 `deploy/pingora/pingora-gateway.env.example` 也不能只检查存在,还要保持 gzip-only、不信任 XFF、前置代理确认关闭、接流保护开启和空 probe token 这些生产安全默认值;`production-api-deploy.sh` 发现缺失时应在 current 切换前 fail-fast、清理 staging 并退出本次打开的维护模式,不应从部署工作区兜底补齐;所有 API 发布包都必须携带 `release-manifest.json` 且登记 `api-server` artifact,发布包包含 Pingora 时还必须登记 `pingora-gateway` artifact,否则 deploy 应在切换 current 前失败;deploy 必须要求 release root、current link 和 api env file 都是绝对路径,release version 以数字或字母开头并拒绝点目录,再先写 staging release,全部复制完成后用非合并语义提升为正式 release,失败时清理 staging 且不留下正式 release,同版本 release 已存在、提升前竞态出现或 current 路径不是符号链接时拒绝覆盖 / 合并,避免旧文件混入 current;发布包包含 Pingora 时,deploy 必须先确认 systemd 最终配置没有 direct-entry `CAP_NET_BIND_SERVICE`、env 仍是 `127.0.0.1:18081` shadow 且未配置 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,再提升 release、切换 current 并 `restart` Pingora shadow;配置不安全时必须在切换 current 前失败并退出本次打开的维护模式,current 切换后的 readiness / 服务重启失败仍保留维护模式。 - 处理:确认真实 TLS 证书和 redirect env 已写入 `/etc/genarrative/pingora-gateway.env`、service 模板和 `systemctl cat` 最终配置读取的 `EnvironmentFile=` 都包含这份 env、当前执行用户和 `genarrative-pingora-gateway.service` 的 `User=` 服务用户都能读取证书链 / 私钥、current release 的 `pingora-gateway` 已存在且可执行、Nginx 或其它进程已释放 `80/443` 后,先用 `npm run plan:pingora-direct-cutover -- --require-direct ...` 生成只读 JSON runbook,并逐条审阅 Host 与回退巡检入口确认、current release 自包含自审、current release preflight、启用前基础 readiness、direct enable dry-run、direct enable apply、启用后 `--require-direct` 复核、rollback dry-run、rollback apply、回退后 health patrol 切回 Nginx 并恢复切换前 public base URL / Host、回退后 health patrol env 复核;runbook 只用于审阅,不修改系统。正式 runbook 中 `--direct-redirect-host`、`--rollback-nginx-smoke-host` 和 `--direct-host` 必须使用同一 hostname,只允许端口不同,避免 redirect 或回退 smoke 各自验证到不同入口;同时必须提供 `--rollback-health-patrol-public-base-url <切换前Nginx巡检入口>`,若切换前 Nginx 巡检需要 Host 覆盖,再追加 `--rollback-health-patrol-public-host <切换前Host>`,确认步骤会展示回退后要恢复的 public base URL / Host,避免回退 runbook 把现场巡检入口覆盖成仓库默认值;如需把回退后 Pingora shadow 探针复核纳入 runbook,追加 `--rollback-pingora-shadow-probe-url` / `--rollback-pingora-shadow-probe-token`,JSON 输出会隐藏 token 原文。随后先执行 `/opt/genarrative/current/scripts/ops/pingora-current-release-audit.mjs --release-root /opt/genarrative/current --require-pingora-gateway --systemd-show`,再 dry-run `/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh --no-status`,最后执行 `/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh --apply --preflight-env-file /etc/genarrative/pingora-gateway.env --preflight-check-cert-readable --preflight-check-service-env-file --preflight-check-service-user-cert-readable --preflight-check-service-binary-executable --preflight-check-ports-free --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`,由脚本先跑 direct preflight,再安装 drop-in、reload systemd、重启 Pingora,并用 `systemctl cat` 核验 capability 和 `EnvironmentFile=/etc/genarrative/pingora-gateway.env` 已生效、用 `systemctl show ... ExecStart` 核验最终 service 仍指向随包主 service 模板里的 current release `pingora-gateway`、用 `systemctl is-active` 确认服务 active,再以 JSON 模式执行 direct live smoke,验证 HTTPS / HTTP redirect / ACME / WSS 101 和 Pingora access log request_id 落盘,并要求 `direct-access-log` 结构化结果 `matchedCount == checked`、`missingCount=0`、`mismatchCount=0`;如果 direct live 退出 0 但缺少该结构化证据,也必须视为启用失败。直连启用后同步调整 `/etc/genarrative/health-patrol.env`:设置 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=pingora-direct`,本机打 `127.0.0.1` 时设置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`,否则巡检会继续按 Nginx 模式误报。验证失败时执行 `/opt/genarrative/current/scripts/deploy/pingora-direct-rollback.sh --apply --reload-nginx --nginx-smoke-url https://<域名>/ --nginx-smoke-expect-body ''` 或 `npm run deploy:pingora-direct-rollback -- --apply --reload-nginx --nginx-smoke-url https://<域名>/ --nginx-smoke-expect-body ''`;回退脚本先跑 `nginx -t`,通过后才移除 drop-in、reload systemd、重启 Pingora,并用 `systemctl cat` 核验 capability 已移除、用 `systemctl show ... ExecStart` 核验最终 service 仍指向随包主 service 模板里的 current release `pingora-gateway`,随后 reload Nginx、确认 Nginx service 仍为 active,并用 curl smoke URL 证明公网入口已回到 Nginx;回退 smoke URL/body 必须来自切换前真实 Nginx 入口,不要继续用固定 `http://127.0.0.1/healthz` 与 `"ok":true`;回退脚本 `--apply` 不允许省略 `--reload-nginx` 或 `--nginx-smoke-url`,当 smoke URL 指向本机地址时必须同时提供 `--nginx-smoke-host <域名>`,且 host 值不能包含 URL、路径或查询;回退后把 health patrol gateway mode 改回 `nginx`,恢复切换前 public base URL / Host,并用 `node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs --env-file /etc/genarrative/health-patrol.env --expected-gateway-mode nginx --expected-public-base-url <切换前Nginx巡检入口> --require-empty-public-host` 复核;若切换前 Nginx 巡检需要 Host 覆盖,则把 `--require-empty-public-host` 换成 `--expected-public-host <切换前Host>`。若 env 已在回退命令前切回 Nginx,也可给 rollback 脚本追加 `--health-patrol-env-file /etc/genarrative/health-patrol.env --health-patrol-expected-public-base-url <切换前Nginx巡检入口> --health-patrol-require-empty-public-host` 让它在 Nginx smoke 后自动复核;切换前 Nginx 巡检需要 Host 覆盖时把最后一项换成 `--health-patrol-expected-public-host <切换前Host>`。若要同时证明 Pingora shadow 高端口仍活着,追加 `--pingora-shadow-probe-url http://127.0.0.1:18081/__genarrative_pingora/healthz --pingora-shadow-probe-token `,脚本会隐藏 token 并要求响应为 `gateway=pingora-shadow`。 - 处理补充:不要直接 chmod `/etc/letsencrypt/live` 或 `archive` 来让 Pingora 读取证书;Certbot live 路径通常是 symlink,即使 `stat -L` 看起来是普通文件,父目录权限也会让非 root `genarrative` 用户不可达。先用随包 `node -- /opt/genarrative/current/scripts/deploy/pingora-tls-cert-sync.mjs --apply --source-cert-file /etc/letsencrypt/live/<域名>/fullchain.pem --source-key-file /etc/letsencrypt/live/<域名>/privkey.pem --target-dir /etc/genarrative/pingora-tls/<域名>` 把证书同步到 Pingora 私有目录,再让 `GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE` / `TLS_KEY_FILE` 指向 `/etc/genarrative/pingora-tls/<域名>/fullchain.pem` 和 `privkey.pem`。脚本默认 dry-run,`--apply` 才写入,目标目录默认 `root:genarrative 0750`,文件默认 `root:genarrative 0640`,并拒绝符号链接目标目录或目标文件。 - 处理补充:不要在切换窗口手工编辑 `/etc/genarrative/health-patrol.env` 的三项网关变量;使用 `node -- /opt/genarrative/current/scripts/deploy/pingora-health-patrol-env-switch.mjs --apply --env-file /etc/genarrative/health-patrol.env --gateway-mode pingora-direct --public-base-url <直连HTTPS入口> --public-host <域名>` 切到直连,回退前用同一脚本传 `--gateway-mode nginx --public-base-url <切换前Nginx巡检入口>` 并按切换前记录选择 `--clear-public-host` 或 `--public-host <切换前Host>`。脚本只改 gateway mode / public base URL / public Host,并立即复用随包 env 复核脚本,减少空 Host 和旧值残留;生产巡检、env 复核和 env 切换脚本读取的布尔 env 都必须是明确布尔值,非法值直接失败,不能把拼写错误当成 false;env 复核脚本的 `--env-file` 与 env 切换脚本的 `--env-file` / `--check-script` 必须是绝对路径且不能是文件系统根目录,也不能包含换行或 NUL;env 切换脚本写入的 public base URL / Host 同样不能包含换行或 NUL。Node 22 已内置 `--env-file` 启动参数,直接用 `node script.mjs --env-file ...` 或 shebang 执行 `.mjs --env-file ...` 都可能让 Node 抢走业务参数;所有这类命令都必须写成 `node -- script.mjs --env-file ...`,或通过已内置 `node --` 的 npm script 执行。 @@ -403,7 +411,7 @@ - 踩坑补充:证据根目录总审计选择“每类最新证据”后,还必须证明这些证据来自同一次切换时间线。最新证据选择和标准八段时间线证明只接受 `schemaVersion=1` 且带合法、规范 UTC 毫秒格式 `manifest.generatedAt` 的 manifest,命令记录 `startedAt` / `finishedAt` 也必须是 `new Date().toISOString()` 形式;缺失、非法、省略毫秒、本地时区或其它宽松可解析格式都会直接失败,不能用目录 mtime 兜底;证据目录被复制、归档或恢复后,也必须以 manifest 时间为准。同一阶段或同一命令如果出现多个候选共享最新 `manifest.generatedAt`,总审计会以 `AMBIGUOUS_LATEST` 失败并列出重复目录,不能按目录名排序打平;应重新归档该阶段 / 命令证据,或把旧证据移出正式证据根目录后再审计。标准八段证据都被要求时,每段审计状态都必须是 `OK`,`manifest.generatedAt` 必须满足 `pre-cutover -> enable-apply -> post-enable:pingora-health-patrol-direct-env-switch -> post-enable -> rollback-prep:pingora-gateway-shadow-env-switch -> rollback-prep:pingora-health-patrol-nginx-env-switch -> rollback-apply -> post-rollback`,且默认八段跨度不能超过 24 小时;非 OK、倒序或跨度过大都代表可能混入不同切换窗口遗留证据或现场状态未达标,必须失败后重新归档或清理证据根目录。任何证据 manifest 只要显式写入 `cutoverRunId` 字段,就必须是安全非空 ID,不能用空字符串伪装成缺省字段。确需跨更长维护窗口时,只能在生成 runbook 时显式传 `--cutover-evidence-timeline-max-span-ms `,让最终总审计 JSON 记录本次放宽后的 `timeline.maxSpanMs` 与实际 `timeline.spanMs`。 - 踩坑补充:标准八段时间线失败时不要只看顶层 `ok=false` 或 `diagnostics` 文本。`timeline.failedCount` 会按具体失败项累计,`timeline.failureBreakdown` 会把非 OK 证据、缺少时间、cutoverRunId 混入、时间倒序和跨度超限拆开计数;同一次审计可能同时暴露多个证据问题,应逐项修复后重新归档。 - 踩坑补充:同一天多次演练或切换时,只靠“最新证据”和 24 小时窗口仍可能把两轮证据拼在一起。正式 runbook 会生成或接受 `--cutover-run-id `,并把同一 `manifest.cutoverRunId` 写入三阶段证据包、五条真实切换命令证据和最终总审计;最终审计必须带 `--require-cutover-run-id <本次cutoverRunId>`,缺少该字段或 ID 不一致时必须失败。即使人工临时总审计忘记带 `--require-cutover-run-id`,标准八段时间线里只要任一证据声明了 `manifest.cutoverRunId`,八段也必须全部声明同一个值,否则总审计失败。 -- 验证:先运行 `npm run check:pingora-direct-preflight -- --env-file /etc/genarrative/pingora-gateway.env --require-live-env --systemd-cat --check-cert-readable --check-service-env-file --check-service-user-cert-readable --check-service-binary-executable --check-ports-free`,确认 env、drop-in、service EnvironmentFile 一致性、当前用户证书权限、服务用户证书权限、service 二进制可执行性和 80/443 已释放;`systemctl cat genarrative-pingora-gateway.service` 必须显示 `AmbientCapabilities=CAP_NET_BIND_SERVICE`、`CapabilityBoundingSet=CAP_NET_BIND_SERVICE` 和 `EnvironmentFile=/etc/genarrative/pingora-gateway.env`;启用脚本 apply 必须先通过 current release 自审,失败时不安装 direct-entry drop-in;还必须带 direct HTTPS / HTTP / Host / redirect host / SpacetimeDB database / Pingora access log 参数,并在重启后直接完成 direct live smoke 和 direct-access-log JSON 证据校验;也可用 release readiness `--require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable` 把 HTTPS、HTTP redirect / ACME、正式域名 Host/SNI、redirect Location host、Pingora access log request_id 落盘、env 预检、systemd drop-in、service EnvironmentFile 一致性、当前用户和服务用户证书可读、service 二进制可执行、显式目标库和 WSS 101 一起纳入硬门禁,并拒绝 `--direct-skip-wss`,避免 TLS 证书只按 `127.0.0.1` 误测、HTTP redirect Location 指错域名、service 实际读取另一份 env、root / deploy 用户可读但 systemd 服务用户不可读、current release 缺少可执行 `pingora-gateway`,或 WSS subscribe 隐式打到默认 SpacetimeDB 库。`check-pingora-release-readiness.mjs --help` 的正式直连和只生成 runbook 示例也必须带 `--direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`,不要让值班人员复制示例后才被 `--require-direct` 拦截。current release 自审、状态快照和证据包的布尔 env 必须是明确布尔值,非法值会失败,不得把拼错的 run / require / fail 开关当成 false。`npm run plan:pingora-direct-cutover -- --require-direct ...` 输出必须包含 Host 与回退巡检入口确认、current release preflight、启用前不带 `--require-direct` 的基础 readiness、direct enable dry-run/apply、启用后带 `--require-direct` 的复核、rollback dry-run/apply、回退后 health patrol 切回 Nginx 并恢复切换前 public base URL / Host、回退后 health patrol env 复核;缺少 `--require-direct`、缺少 `--rollback-health-patrol-public-base-url`、缺少 `--direct-pingora-access-log`、redirect Host 漂移或 rollback smoke Host 漂移时必须失败,避免生成缺少正式直连硬门禁或验证不同入口的切换计划。Host 与回退巡检入口确认步骤必须展示回退后要恢复的 health patrol public base URL / Host。直连后 `genarrative-health-patrol.service` 应使用 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=pingora-direct`,状态 JSON 中 `gatewayMode` 应为 `pingora-direct`,并检查 `genarrative-pingora-gateway.service` 而不是 `nginx.service`;public probe 走 `127.0.0.1` 时应带 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`。回退后 `nginx -t` 必须先通过,`systemctl cat genarrative-pingora-gateway.service` 不应再显示这两条 capability,`systemctl show genarrative-pingora-gateway.service --property=ExecStart --value --no-pager` 必须仍指向 current release 的 `pingora-gateway`,`systemctl is-active nginx.service` 应为 `active`,`curl --fail --max-time 5` 访问 `--nginx-smoke-url` 应成功;若 smoke URL 为本机地址必须带 `--nginx-smoke-host <域名>`,证明正式 vhost 已回到 Nginx;随后用 `node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs ...` 复核 health patrol env,必须显示 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx` 且 public base URL / Host 与切换前记录一致,shadow probe 可选复核必须返回 `gateway=pingora-shadow`。本机提交前还要运行 `npm run check:pingora-direct-enable`、`npm run check:pingora-direct-rollback`、`npm run check:production-health-patrol`、`npm run check:production-api-release`、`npm run check:pingora-production-release-build` 和 `npm run check:production-api-deploy`,确保脚本默认 dry-run 不会安装或删除 drop-in、current release 自审失败时启用脚本不会安装 drop-in、direct live 退出 0 但缺少 `direct-access-log` 结构化证据时启用失败,API release 布局自包含,真实 Pingora release 二进制能构建并进入发布包,API deploy 从发布产物内执行后 current release 自包含;缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、切换命令证据脚本、env 示例目录或 direct live smoke 脚本的发布包都必须部署失败并保持维护模式。正式直连 readiness 必须带 `--direct-health-patrol-env-file /etc/genarrative/health-patrol.env`,并用 `scripts/check-production-health-patrol-env.mjs` 阻断 health patrol 仍停在 Nginx 模式或本机 direct probe 缺少正式 Host;发布包包含 `pingora-gateway` 时,`npm run check:production-api-deploy` 必须覆盖服务 active / inactive 都会在 shadow 配置安全时执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,同时覆盖 direct-entry capability 或公网监听 env 下不会提升 release、不会切 current、不会自动 restart。 +- 验证:先运行 `npm run check:pingora-direct-preflight -- --env-file /etc/genarrative/pingora-gateway.env --require-live-env --systemd-cat --check-cert-readable --check-service-env-file --check-service-user-cert-readable --check-service-binary-executable --check-ports-free`,确认 env、drop-in、service EnvironmentFile 一致性、当前用户证书权限、服务用户证书权限、service 二进制可执行性和 80/443 已释放;`systemctl cat genarrative-pingora-gateway.service` 必须显示 `AmbientCapabilities=CAP_NET_BIND_SERVICE`、`CapabilityBoundingSet=CAP_NET_BIND_SERVICE` 和 `EnvironmentFile=/etc/genarrative/pingora-gateway.env`;启用脚本 apply 必须先通过 current release 自审,失败时不安装 direct-entry drop-in;还必须带 direct HTTPS / HTTP / Host / redirect host / SpacetimeDB database / Pingora access log 参数,并在重启后直接完成 direct live smoke 和 direct-access-log JSON 证据校验;也可用 release readiness `--require-direct --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log --direct-health-patrol-env-file /etc/genarrative/health-patrol.env --direct-preflight-env-file /etc/genarrative/pingora-gateway.env --direct-preflight-systemd --direct-preflight-check-cert-readable --direct-preflight-check-service-env-file --direct-preflight-check-service-user-cert-readable --direct-preflight-check-service-binary-executable` 把 HTTPS、HTTP redirect / ACME、正式域名 Host/SNI、redirect Location host、Pingora access log request_id 落盘、env 预检、systemd drop-in、service EnvironmentFile 一致性、当前用户和服务用户证书可读、service 二进制可执行、显式目标库和 WSS 101 一起纳入硬门禁,并拒绝 `--direct-skip-wss`,避免 TLS 证书只按 `127.0.0.1` 误测、HTTP redirect Location 指错域名、service 实际读取另一份 env、root / deploy 用户可读但 systemd 服务用户不可读、current release 缺少可执行 `pingora-gateway`,或 WSS subscribe 隐式打到默认 SpacetimeDB 库。`check-pingora-release-readiness.mjs --help` 的正式直连和只生成 runbook 示例也必须带 `--direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`,不要让值班人员复制示例后才被 `--require-direct` 拦截。current release 自审、状态快照和证据包的布尔 env 必须是明确布尔值,非法值会失败,不得把拼错的 run / require / fail 开关当成 false。`npm run plan:pingora-direct-cutover -- --require-direct ...` 输出必须包含 Host 与回退巡检入口确认、current release preflight、启用前不带 `--require-direct` 的基础 readiness、direct enable dry-run/apply、启用后带 `--require-direct` 的复核、rollback dry-run/apply、回退后 health patrol 切回 Nginx 并恢复切换前 public base URL / Host、回退后 health patrol env 复核;缺少 `--require-direct`、缺少 `--rollback-health-patrol-public-base-url`、缺少 `--direct-pingora-access-log`、redirect Host 漂移或 rollback smoke Host 漂移时必须失败,避免生成缺少正式直连硬门禁或验证不同入口的切换计划。Host 与回退巡检入口确认步骤必须展示回退后要恢复的 health patrol public base URL / Host。直连后 `genarrative-health-patrol.service` 应使用 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=pingora-direct`,状态 JSON 中 `gatewayMode` 应为 `pingora-direct`,并检查 `genarrative-pingora-gateway.service` 而不是 `nginx.service`;public probe 走 `127.0.0.1` 时应带 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`。回退后 `nginx -t` 必须先通过,`systemctl cat genarrative-pingora-gateway.service` 不应再显示这两条 capability,`systemctl show genarrative-pingora-gateway.service --property=ExecStart --value --no-pager` 必须仍指向 current release 的 `pingora-gateway`,`systemctl is-active nginx.service` 应为 `active`,`curl --fail --max-time 5` 访问 `--nginx-smoke-url` 应成功;若 smoke URL 为本机地址必须带 `--nginx-smoke-host <域名>`,证明正式 vhost 已回到 Nginx;随后用 `node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs ...` 复核 health patrol env,必须显示 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=nginx` 且 public base URL / Host 与切换前记录一致,shadow probe 可选复核必须返回 `gateway=pingora-shadow`。本机提交前还要运行 `npm run check:pingora-direct-enable`、`npm run check:pingora-direct-rollback`、`npm run check:production-health-patrol`、`npm run check:production-api-release`、`npm run check:pingora-production-release-build` 和 `npm run check:production-api-deploy`,确保脚本默认 dry-run 不会安装或删除 drop-in、current release 自审失败时启用脚本不会安装 drop-in、direct live 退出 0 但缺少 `direct-access-log` 结构化证据时启用失败,API release 布局自包含,真实 Pingora release 二进制能构建并进入发布包,API deploy 从发布产物内执行后 current release 自包含;缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、切换命令证据脚本、env 示例目录或 direct live smoke 脚本的发布包都必须在 current 切换前部署失败并退出本次打开的维护模式。正式直连 readiness 必须带 `--direct-health-patrol-env-file /etc/genarrative/health-patrol.env`,并用 `scripts/check-production-health-patrol-env.mjs` 阻断 health patrol 仍停在 Nginx 模式或本机 direct probe 缺少正式 Host;发布包包含 `pingora-gateway` 时,`npm run check:production-api-deploy` 必须覆盖服务 active / inactive 都会在 shadow 配置安全时执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,同时覆盖 direct-entry capability 或公网监听 env 下不会提升 release、不会切 current、不会自动 restart。 - 顺序补充:正式 runbook 必须先通过 health patrol env 切换脚本预置回 Nginx 和切换前 public base URL / Host,再执行 `rollback apply`;回退脚本内置 env 复核和独立 env 复核都会阻断 public base URL / Host 漂移。 - 顺序补充:正式 runbook 还必须在 `rollback apply` 前预置 Pingora shadow env。启用前和 `--dry-run-cutover` 要求 `80/443` 空闲;启用后 `--require-direct` 复核不再要求端口空闲,因为端口应由 Pingora 占用。回退时要先用 current release 随包 `node -- /opt/genarrative/current/scripts/deploy/pingora-gateway-env-shadow-switch.mjs --apply --env-file /etc/genarrative/pingora-gateway.env` 恢复 `GENARRATIVE_PINGORA_GATEWAY_LISTEN=127.0.0.1:18081` 并清空 TLS / HTTP redirect 低端口监听,再移除 direct-entry drop-in 和重启 Pingora。 - 关联:`deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`、`deploy/env/health-patrol.env.example`、`deploy/env/pingora-direct-live.env.example`、`deploy/env/pingora-canary-live.env.example`、`scripts/deploy/pingora-direct-enable.sh`、`scripts/deploy/pingora-direct-rollback.sh`、`scripts/deploy/pingora-tls-cert-sync.mjs`、`scripts/check-pingora-direct-preflight.mjs`、`scripts/check-pingora-direct-live.mjs`、`scripts/ops/pingora-cutover-command-evidence.mjs`、`scripts/ops/pingora-cutover-evidence-verify.mjs`、`scripts/ops/pingora-cutover-evidence-audit.mjs`、`scripts/jenkins-server-provision.sh`、`scripts/build-production-release.sh`、`scripts/deploy/production-api-deploy.sh`、`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`。 @@ -458,10 +466,10 @@ ## 生产冷备份后 API 和外部生成 worker 不能只依赖 SpacetimeDB 自恢复 -- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped;或图片画布生成请求返回队列态后长期显示排队,`external_generation_job` 有 claimable pending,但 `genarrative-external-generation-worker@1.service` / controller 是 inactive。 -- 原因:`genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` 时这些服务会被 systemd 依赖关系一并停止;如果 `genarrative-database-backup.service` 只恢复数据库或只重启 API,外部生成队列就不会被消费。 -- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service`、`--restart-service-after genarrative-external-generation-worker@1.service` 和 `--restart-service-after genarrative-external-generation-controller.service`;`genarrative-api.service` 也保留对 controller 的 `Wants` 弱依赖,覆盖“只恢复 API”的现场兜底。仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。 -- 验证:`systemctl cat genarrative-database-backup.service` 应包含这些参数;`systemctl is-active spacetimedb.service genarrative-api.service genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service nginx.service` 全为 `active`;`curl -fsS http://127.0.0.1:3101/v1/ping`、`/healthz`、`/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功;`get_external_generation_queue_stats_and_return` 不应长期出现 claimable pending。 +- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped;或图片画布生成请求返回队列态后长期显示排队,`external_generation_job` 有 claimable pending,但 `genarrative-external-generation-worker@1.service` / controller 是 inactive;也可能先看到 `/var/lib/genarrative/database-backups` 把根分区写满,`gzip: stdout: No space left on device`。 +- 原因:`genarrative-api.service`、`genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service` 都配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` 时这些服务会被 systemd 依赖关系一并停止;如果备份脚本只在打包成功后重启依赖服务,那么 tar/gzip 因空间不足失败时就只会恢复数据库,外部生成队列和 API 仍无人接管。 +- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service`、`--restart-service-after genarrative-external-generation-worker@1.service` 和 `--restart-service-after genarrative-external-generation-controller.service`;备份脚本必须在停止 SpacetimeDB 前做工作目录剩余空间预检,并且一旦已经停过 SpacetimeDB,就算打包失败也要先恢复 SpacetimeDB 与这些依赖服务,再返回原始备份错误。`genarrative-api.service` 也保留对 controller 的 `Wants` 弱依赖,覆盖“只恢复 API”的现场兜底。仓库用 `npm run check:production-ops` 和 `npm run check:database-backup` 检查 systemd 模板、脚本失败路径、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。 +- 验证:`systemctl cat genarrative-database-backup.service` 应包含这些参数;`systemctl is-active spacetimedb.service genarrative-api.service genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service nginx.service` 全为 `active`;`curl -fsS http://127.0.0.1:3101/v1/ping`、`/healthz`、`/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功;`npm run check:database-backup` 覆盖空间不足不触碰 systemctl、tar 失败仍恢复依赖服务;`get_external_generation_queue_stats_and_return` 不应长期出现 claimable pending。 - 关联:`deploy/systemd/genarrative-database-backup.service`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## Pingora Brotli 不能只看 Content-Encoding diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 8171878bf..a3d14d34b 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,14 +20,8 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑默认从原图模型和分辨率初始化,但提交使用面板当前选择的 `model/aspectRatio/imageSize`;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作快速编辑固定使用 `seedance2.0-fast` 动作 / 视频模型。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准绿幕资产统一走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的绿幕提示词契约和本地确定性绿幕透明化,字节级解码、透明化和 PNG 编码下沉复用 `platform-image::generated_asset_sheets`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作抽帧后的序列帧都属于标准绿幕资产,不再依赖 BiRefNet;后端在执行绿幕透明化前必须先把带绿幕源图写入 OSS。BiRefNet 服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;可选访问令牌只来自服务端环境变量 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;手动去背景作为画布本地任务展示,记录运行 / 完成 / 失败、耗时和 provider,不写入 SpacetimeDB 外部生成队列。 -- 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs` 的最后一张隐式参考,也不在参考图条里固定展示 `图x`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交时保留用户提示词里对 `原图`、`当前图片`、`当前图` 或 `图1` 的原始表述,不再改写为 `图N`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;生成成功后直接用结果覆盖原图图层,该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 -- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 -- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 -- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图`、`生成角色动作` 和快速编辑提交后创建的 `Quick Edit Generator`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;快速编辑提交时同样必须把避让后的 placeholder 写入独立生成占位,生成结果落在该占位位置。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 -- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准绿幕资产统一走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的绿幕提示词契约和本地确定性绿幕透明化,字节级解码、透明化和 PNG 编码下沉复用 `platform-image::generated_asset_sheets`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作抽帧后的序列帧都属于标准绿幕资产,不再依赖 BiRefNet;后端在执行绿幕透明化前必须先把带绿幕源图写入 OSS。BiRefNet 服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;可选访问令牌只来自服务端环境变量 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 图片画布抠图分两类:手动去除背景面向用户任意图片,走登录态同源 BFF `POST /api/editor/images/background-removals` 并转发远端 BiRefNet;编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用独立 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 11 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt 和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。三条 BgFilter 路径还必须固定把默认 `segModel=birefnet` 传为 `seg_model`;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交该值。这里的 `birefnet` 只是 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。后端在调用 BgFilter 前必须先把带纯色背景 / 绿幕源图写入 OSS;若 BgFilter 请求失败、返回非成功状态、空图片或非法图片,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作抽帧后的序列帧暂不接用户背景色选择,仍沿用 legacy `#00FF00` 绿幕和本地 `editor_green_screen` 透明化,不依赖 BiRefNet 或 BgFilter。BiRefNet 手动去背景服务地址为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL/remove-background`,默认 `http://58.87.105.82/remove-background`;BgFilter 可选访问令牌来自 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`,所有令牌都只在服务端注入,前端不持有令牌。api-server 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object,再返回 `imageSrc/objectKey/assetObjectId/taskId`;queue 模式下手动去背景进入 SpacetimeDB 外部生成队列,画布任务侧栏只展示服务器任务阶段,生成中才显示耗时,不显示百分比;有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 @@ -59,7 +53,7 @@ - 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"`、`sourceType="uploaded"`。`/project` 与 `/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 -- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面并写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 @@ -89,14 +83,10 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带绿幕源图,再走 `editor_green_screen` 绿幕透明化后处理。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 -- `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件并转发到 BiRefNet 去背景服务;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs` 和 `provider`。服务地址由 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 配置,令牌只在服务端通过 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 注入。 -- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带绿幕 spritesheet 源图,再走 `editor_green_screen` 绿幕透明化后处理,并由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。 -- `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和 `editor_green_screen` 组装的标准绿幕素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带绿幕 spritesheet 源图,再走 `editor_green_screen` 绿幕透明化后处理,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 -- `POST /api/editor/images/edits`:按提示词、当前图片 Data URL 和最多 8 张额外参考图调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`,生成成功后 api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=` 生成透明 PNG。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后由 api-server 解析为图片文件并转发到 BiRefNet 去背景服务;请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径;响应返回 `imageSrc`、`objectKey`、`assetObjectId`、`width`、`height`、`taskId`、`elapsedMs`、`provider` 和可选 `project` 快照。服务地址由 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL` 配置,令牌只在服务端通过 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 注入。 -- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带绿幕 spritesheet 源图,再走 `editor_green_screen` 绿幕透明化后处理,并由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。 -- `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和 `editor_green_screen` 组装的标准绿幕素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带绿幕 spritesheet 源图,再走 `editor_green_screen` 绿幕透明化后处理,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 +- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端保存透明 spritesheet project resource / 账号素材,并随响应返回对应快照。 +- `POST /api/editor/ui-designs/assets/extractions`:以前端已绘入红色框选轮廓的 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet,生成成功后 api-server 先保存带纯色背景 spritesheet 源图,再调用 BgFilter 生成透明 spritesheet,并按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 - `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;接口能力仍可接收明确参考图,但图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 - `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、provider、taskId、duration、歌词和 `priceMudPoints`。 @@ -130,14 +120,13 @@ - 快速编辑面板底部只显示模型选择和 `修改` 按钮;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图或红框序号标注图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户填写的快速编辑提示词。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果,完成后用新的 project resource 引用替换当前图层,并在右上角本地任务侧栏展示耗时和状态。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 点击底部 Dock 的“画布 Agent”后,右侧独立 Agent 面板打开;素材 / 图层侧栏和任务侧栏被收起。再次点击或点击面板关闭按钮后收起 Agent。打开素材 / 图层侧栏或任务侧栏时,Agent 面板同步关闭。 - Agent 面板能读取当前工程会话列表;无历史会话时发送第一条消息会先创建“新对话”。支持新建会话、切换会话和删除当前会话;删除必须通过独立确认弹窗完成,不能在面板下方追加确认内容。 - Agent 输入支持文本消息、附件消息和纯附件消息;附件选择弹窗可在“画布 / 素材库”之间切换,只展示图片类资源,最多选择 9 张。 - 发送消息后,面板展示用户消息、Agent 阶段状态和 SSE 增量回复;`stage/message_delta/tool_started/tool_completed/generation_result/error/done` 都能被正确渲染。流式响应中点击“停止”会中断当前请求,并把仍在 streaming / generating 的消息标记为停止态。 - Agent 返回生成结果缩略图后,点击缩略图应优先聚焦当前画布中已有 `resourceId` 对应图层;如果当前内存布局尚未包含该资源,则重新读取工程快照,应用后再聚焦新图层。对话入口触发生成时不创建“即将生成”画布占位;生成中状态只显示在消息流,生成完成后通过后端 `canvasCompletion` 落新图层。工具失败时消息内必须保留失败 generation record 和错误气泡,不能只弹一次性 toast。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词;生成的透明 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 7f72da0b9..5c7fe7bb5 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -60,7 +60,7 @@ npm run check:server-rs-ddd - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 - 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-*`,负责直传、确认、绑定和读取。 - 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。 -- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*`。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。 +- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/frontend-config`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*`。`/api/runtime/frontend-config` 由 `api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭,前端不再读取 `VITE_*` 构建期变量决定生产显示。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。 - 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。 - 后台素材查询:`GET /admin/api/editor-assets` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset` 中 `source_type = 'generated'` 的素材,支持 `ownerUserId`、`keyword`、`createdAfter`、`createdBefore`、`cursor` 和 `limit`;返回缩略图 / Object Key、作者展示名、陶泥号、提示词、生成输入和生成成本,只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。 - 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。 @@ -219,6 +219,7 @@ npm run check:server-rs-ddd - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 +- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 继续代理独立 BiRefNet 服务,配置为 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`、`GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 和 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS`。角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取的生成后纯色背景透明化改走独立 BgFilter 服务,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,token 未配置时复用 BiRefNet token。BgFilter 请求必须显式传 `screen_color=` 和 `seg_model=`;前端用户路径不展示抠图模型选择并固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,其中 `birefnet` 只表示 BgFilter 管线内部后端,不等同于手动去背景的独立 BiRefNet 服务。角色动作抽帧仍沿用 legacy `#00FF00` 和本地 `editor_green_screen` 透明化。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 @@ -503,7 +504,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorShowcaseAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:`陶泥儿精选` 的独立审核与公开快照表。用户从账号级生成素材提交后,后端把素材媒体、提示词、生成输入、素材类型和 `generation_cost_mud_points` 快照到该表,初始 `review_status = pending`、`display_enabled = false` 且 `showcase_category = null`。后台审核通过后写入 `approved`,但仍保持不展示;运营需在后台按前台 Tab 手动设置 `showcase_category`(`packs`、`characters`、`ui`、`music`、`marketing`)并开启展示后,素材才进入公开精选。审核通过时生成确定性返还流水 `editor-showcase-refund:{showcase_id}`,BFF 按 50% 生成成本返还泥点后回写 `refund_completed_at`;拒绝后写入 `rejected`。公开精选 `GET /api/editor/showcase/resources` 只读取 `review_status = approved`、`display_enabled = true`、`showcase_category` 合法且媒体非空的记录,按通过时间 / `showcase_id` 倒序 cursor 分页。素材删除时,待审核记录标记 `asset_deleted_while_pending`,已拒绝记录删除,已通过记录保留快照继续展示。 +- 说明:`陶泥儿精选` 的独立审核与公开快照表。用户从账号级生成素材提交后,后端把素材媒体、提示词、生成输入、素材类型和 `generation_cost_mud_points` 快照到该表,初始 `review_status = pending`、`display_enabled = false` 且 `showcase_category = null`。后台审核通过后写入 `approved`,但仍保持不展示;运营可在后台按前台具体 Tab 手动设置 `showcase_category`(`characters`、`ui`、`music`、`marketing`)并开启展示。未设置分类的素材不归入具体 Tab,但展示开启后仍进入前台“全部”。审核通过时生成确定性返还流水 `editor-showcase-refund:{showcase_id}`,BFF 按 50% 生成成本返还泥点后回写 `refund_completed_at`;拒绝后写入 `rejected`。公开精选 `GET /api/editor/showcase/resources` 读取 `review_status = approved`、`display_enabled = true` 且媒体非空的记录,按通过时间 / `showcase_id` 倒序 cursor 分页。素材删除时,待审核记录标记 `asset_deleted_while_pending`,已拒绝记录删除,已通过记录保留快照继续展示。 - 索引:`by_editor_showcase_asset_owner_user_id`、`by_editor_showcase_asset_review_status`。 ### `editor_showcase_asset_like` @@ -517,7 +518,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorShowcaseCampaignConfig` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = default`。后台可配置启用状态、标题、图片 URL、提示词、作者和成本文案;固定活动卡不包含副标题或 Object Key,公开精选接口只在启用时返回该配置。 +- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = global`。后台可配置启用状态、标题、图片地址、提示词、作者和成本文案;上传按钮通过后台受控上传票据把图片写入 OSS,保存时同时落 `image_src`、内部图片 OSS `image_object_key`、`image_width` 和 `image_height`。公开精选接口只在启用时返回该配置,前端优先用 `image_object_key` 走签名读地址展示,并按记录的图片宽高决定活动卡比例。 - 索引:主键 `config_id`。 ### `inventory_slot` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 6c48ab98e..29d9e84b0 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -234,6 +234,10 @@ npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.servi 生产环境变量模板在 `deploy/env/api-server.env.example`: +主站前端运行时配置由 `api-server` 下发;画板右侧 Agent 入口使用 +`GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false` 默认关闭,需要开启时只改生产 +api-server 环境变量并重启 `api-server`,不再通过 `VITE_*` 构建期变量控制。 + ```env GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups @@ -241,11 +245,12 @@ GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET= GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false +GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES= GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID= GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET= ``` -`GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET` 为空时会回退 `ALIYUN_OSS_BUCKET`;AccessKey 默认复用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,也可用 `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID` / `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET` 为备份 bucket 单独配置最小权限账号。`Genarrative-Server-Provision` 会创建 `/var/lib/genarrative/database-backups` 并归属 `genarrative:genarrative`,同时安装并启用 `genarrative-database-backup.timer`。手动检查定时器:`systemctl list-timers genarrative-database-backup.timer`;手动触发一次:`systemctl start genarrative-database-backup.service`。如果 timer 显示 `enabled` 但 `inactive/dead` 且 `NEXT` / `Trigger` 为空,先写入当前 stamp 避免 `Persistent=true` 在白天立刻补跑冷备份:`touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer && systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,随后确认下一次触发时间约为次日 `03:20`。 +`GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET` 为空时会回退 `ALIYUN_OSS_BUCKET`;AccessKey 默认复用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,也可用 `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID` / `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET` 为备份 bucket 单独配置最小权限账号。冷备脚本会在停止 SpacetimeDB 前检查 `GENARRATIVE_DATABASE_BACKUP_WORK_DIR` 所在文件系统剩余空间;未设置 `GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES` 时,按数据目录大小加安全余量估算,空间不足会在停库前失败,避免写满根分区。即使打包或上传前步骤失败,只要脚本已经停过 SpacetimeDB,也会先恢复 SpacetimeDB 并执行 `--restart-service-after` 指定的 API / worker / controller,再带着原始备份错误退出。`Genarrative-Server-Provision` 会创建 `/var/lib/genarrative/database-backups` 并归属 `genarrative:genarrative`,同时安装并启用 `genarrative-database-backup.timer`。手动检查定时器:`systemctl list-timers genarrative-database-backup.timer`;手动触发一次:`systemctl start genarrative-database-backup.service`。如果 timer 显示 `enabled` 但 `inactive/dead` 且 `NEXT` / `Trigger` 为空,先写入当前 stamp 避免 `Persistent=true` 在白天立刻补跑冷备份:`touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer && systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,随后确认下一次触发时间约为次日 `03:20`。 冷备份后必须做一次只读验收,不要只看 `genarrative-database-backup.service` 是否成功退出: @@ -322,7 +327,7 @@ current release 自审的 `--release-root` 和 `--systemd-service` 不能包含 current release 自审、状态快照、证据包、direct preflight、生产巡检、health patrol env 复核和 env 切换脚本读取的布尔 env 只接受 `true/false`、`1/0`、`yes/no`、`on/off` 或空值,拼写错误会直接失败,避免 `REQUIRE_GATEWAY`、`RUN_HEALTH_PATROL`、`REQUIRE_PINGORA_GATEWAY`、`FAIL_ON_CRITICAL`、`TRUST_X_FORWARDED_FOR` 或巡检切换开关被悄悄当成 false。 -正式直连 runbook 的启用前 release readiness 基础门禁和启用后 `--require-direct` 复核,都必须调用 current release 随包的 `/opt/genarrative/current/scripts/check-pingora-release-readiness.mjs`。生产 API release、Jenkins API Build 归档、Jenkins API Deploy 复制清单和 `production-api-deploy.sh` 都必须携带该聚合门禁脚本;缺失时部署应 fail-fast 并保持维护模式,切换窗口不能回退到源码 checkout 或 Jenkins workspace 的相对路径脚本。 +正式直连 runbook 的启用前 release readiness 基础门禁和启用后 `--require-direct` 复核,都必须调用 current release 随包的 `/opt/genarrative/current/scripts/check-pingora-release-readiness.mjs`。生产 API release、Jenkins API Build 归档、Jenkins API Deploy 复制清单和 `production-api-deploy.sh` 都必须携带该聚合门禁脚本;缺失时部署应在 current 切换前 fail-fast、清理 staging 并退出本次打开的维护模式,切换窗口不能回退到源码 checkout 或 Jenkins workspace 的相对路径脚本。 current release 随包执行的 release readiness 必须追加 `--release-runtime-only`,只运行包内可自包含的 current release 自审、live canary、真实 access log 对账、direct preflight、health patrol env 复核和 direct live smoke;不要在 `/opt/genarrative/current` 上运行默认源码全量门禁。默认不带 `--release-runtime-only` 的聚合门禁仍属于本机 / CI / 构建环境使用,负责覆盖 Cargo、npm、Docker、Nginx 静态 / 真机校验和发布包构建烟测。 @@ -361,7 +366,7 @@ cat /var/lib/genarrative/health-patrol/status.json `Genarrative-Web-Build` 会把 `build//web.tar.gz`、`web.tar.gz.sha256`、`release-manifest.json` 和 `scripts/deploy/production-web-deploy.sh` 直接归档为 Jenkins 构建产物;`Genarrative-Web-Deploy` 只通过 `copyArtifacts` 从指定上游构建复制这些产物和部署脚本,不再在目标机器 checkout Git,再执行随构建归档的 `scripts/deploy/production-web-deploy.sh`。Web 发布不再读取构建机本地缓存目录,也不再通过 release agent `rsync` 回构建机拉取大包;如果 deploy 找不到 `web.tar.gz`,应先检查上游 Web Build 是否按同一 `BUILD_VERSION` 成功归档产物。 -`Genarrative-Api-Build` 的 Jenkins 归档产物必须包含 `build//api-server`、`api-server.sha256`、`release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`scripts/ops/pingora-current-release-audit.mjs`、`scripts/ops/pingora-cutover-status-snapshot.mjs`、`scripts/ops/pingora-cutover-evidence-bundle.mjs`、`scripts/ops/pingora-cutover-command-evidence.mjs`、`scripts/ops/pingora-cutover-evidence-verify.mjs`、`scripts/ops/pingora-cutover-evidence-audit.mjs`、`scripts/check-pingora-direct-preflight.mjs`、`scripts/check-pingora-direct-live.mjs`、`scripts/check-pingora-canary-access-log-parity.mjs`、`scripts/check-production-health-patrol-env.mjs`、`scripts/deploy/pingora-direct-enable.sh`、`scripts/deploy/pingora-direct-rollback.sh`、`deploy/systemd/**`、`deploy/env/**` 和 `deploy/pingora/**`。`deploy/systemd/genarrative-database-backup.service` 从 `/opt/genarrative/current/scripts/database-backup-to-oss.mjs` 执行冷备份,`deploy/systemd/genarrative-health-patrol.service` 从 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs` 执行巡检;`Genarrative-Api-Deploy` 会从上游 API 构建产物复制并执行 `build//scripts/deploy/production-api-deploy.sh`,同目录的 `maintenance-on.sh` / `maintenance-off.sh` 也必须来自同一 build 产物;部署脚本会先写入 `${RELEASE_ROOT}/.${VERSION}.staging.$$`,把 `release-manifest.json` 校验后复制为 current release 的 `release-manifest.api-server.json`,并把备份脚本、巡检脚本、Pingora 直连启用 / 回退 / 预检 / live smoke / canary access log 对账 / health patrol env 复核 / current release 自审 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本,以及 `deploy/systemd`、`deploy/env`、`deploy/pingora` 支撑配置全部复制完成后,才用非合并语义提升为 `${RELEASE_ROOT}/${VERSION}` 并用固定替换语义切换 `current` 符号链接,不再在目标机器 checkout Git,也不再执行部署工作区根部脚本。Pingora 直连启用脚本必须能从 `/opt/genarrative/current` 独立执行 preflight 和 direct live smoke,并默认读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,不依赖 Jenkins 工作区、源码 checkout 或 `/etc` 参考模板;`plan:pingora-direct-cutover` 必须能用同一组参数生成 current release 切换 / 回退 runbook。`production-api-deploy.sh` 对 release manifest、备份脚本、巡检脚本、env 示例目录和 Pingora 直连依赖都执行 fail-fast,且 `--release-root`、`--current-link`、`--api-env-file` 必须是绝对路径,`--version` 必须以数字或字母开头并只能包含数字、字母、点、下划线和短横线,禁止 `.` / `..` 点目录;发布产物缺少 manifest、manifest 未登记 `api-server`、缺少脚本 / 配置目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现或 staging 构建中失败时会保留维护模式并停止部署;失败会清理 staging 目录且不会留下正式 release 目录,不再从部署机工作区兜底补文件,也不把旧同名 release 目录和新文件混合。如果 API 发布后 current release 中缺少这些脚本或目录,应先检查 `Genarrative-Api-Build` 的 `archiveArtifacts` 和 `Genarrative-Api-Deploy` 的 `copyArtifacts` 过滤器是否仍包含 `build//release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`build//scripts/database-backup-to-oss.mjs`、`build//scripts/ops/production-health-patrol.mjs`、`build//scripts/ops/pingora-current-release-audit.mjs`、`build//scripts/ops/pingora-cutover-status-snapshot.mjs`、`build//scripts/ops/pingora-cutover-evidence-bundle.mjs`、`build//scripts/ops/pingora-cutover-command-evidence.mjs`、`build//scripts/ops/pingora-cutover-evidence-verify.mjs`、`build//scripts/ops/pingora-cutover-evidence-audit.mjs`、`build//scripts/check-pingora-direct-preflight.mjs`、`build//scripts/check-pingora-direct-live.mjs`、`build//scripts/check-pingora-canary-access-log-parity.mjs`、`build//scripts/check-production-health-patrol-env.mjs`、`build//scripts/deploy/pingora-direct-enable.sh`、`build//scripts/deploy/pingora-direct-rollback.sh`、`build//deploy/systemd/**`、`build//deploy/env/**` 与 `build//deploy/pingora/**`,不要只在部署机工作区手工补文件。本机用 `npm run check:production-api-release` 通过临时 `CARGO_TARGET_DIR` 和假 `api-server` / `pingora-gateway` release binary 验证 `build-production-release.sh --component api-server --skip-api-build` 会把这些文件打进 API release,并验证显式 `--include-pingora-gateway --skip-pingora-gateway-build` 时发布包包含 `pingora-gateway`、`pingora-gateway.sha256` 和 manifest 登记;`npm run check:pingora-production-release-build` 则用假 `api-server` 和真实 `cargo build -p pingora-gateway --release --target x86_64-unknown-linux-gnu` 验证显式 include 路径能构出可执行网关二进制、checksum 和 manifest 登记;再用 `npm run check:production-api-deploy` 通过临时 release、fake `systemctl` / `curl` 验证从发布产物内执行 `production-api-deploy.sh` 会把这些文件复制到 current release,并验证缺少 release manifest、manifest 未登记 `api-server`、缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、current release 自审脚本、状态快照脚本、证据包脚本、证据验真脚本、证据根目录审计脚本、canary access log 对账脚本、env 示例目录或 direct live smoke 脚本时都会失败且保持维护模式,还会验证相对 release root / current link / api env file、点目录或点开头 version 被拒绝、失败时不留下 staging / 正式 release 目录、同版本 release 目录已存在、current 路径不是符号链接或提升前 release 目录竞态出现时拒绝覆盖 / 合并。Pingora 影子网关不是默认 API 归档物;只有显式用 `npm run build:production-release -- --component api-server --include-pingora-gateway` 或在 `Genarrative-Api-Build` 勾选 `INCLUDE_PINGORA_GATEWAY` 时,发布包才包含 `pingora-gateway` / `pingora-gateway.sha256`,API deploy 会在两者同时存在且 manifest 登记 `pingora-gateway` 时校验并复制到 current release;此时 build 脚本和 Jenkins 会先检查 `cmake`、C 编译器和 C++ 编译器,避免进入 Cargo 后才因 `libz-ng-sys` 构建依赖缺失失败。发布包包含 Pingora 时,deploy 会在提升 release 前读取 `systemctl cat genarrative-pingora-gateway.service` 和其 `EnvironmentFile`,拒绝 direct-entry `CAP_NET_BIND_SERVICE`、拒绝非 `127.0.0.1:18081` 的 shadow listen、拒绝 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,确认仍是本机 shadow 高端口后才切换 current;切换后执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,让 shadow / canary 机器加载新网关二进制。该自动拉起不会启用公网 `80/443` 直连入口;已经进入 direct-entry 状态的机器应走正式直连 runbook 或先回退到 shadow。 +`Genarrative-Api-Build` 的 Jenkins 归档产物必须包含 `build//api-server`、`api-server.sha256`、`release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`scripts/ops/pingora-current-release-audit.mjs`、`scripts/ops/pingora-cutover-status-snapshot.mjs`、`scripts/ops/pingora-cutover-evidence-bundle.mjs`、`scripts/ops/pingora-cutover-command-evidence.mjs`、`scripts/ops/pingora-cutover-evidence-verify.mjs`、`scripts/ops/pingora-cutover-evidence-audit.mjs`、`scripts/check-pingora-direct-preflight.mjs`、`scripts/check-pingora-direct-live.mjs`、`scripts/check-pingora-canary-access-log-parity.mjs`、`scripts/check-production-health-patrol-env.mjs`、`scripts/deploy/pingora-direct-enable.sh`、`scripts/deploy/pingora-direct-rollback.sh`、`deploy/systemd/**`、`deploy/env/**` 和 `deploy/pingora/**`。`deploy/systemd/genarrative-database-backup.service` 从 `/opt/genarrative/current/scripts/database-backup-to-oss.mjs` 执行冷备份,`deploy/systemd/genarrative-health-patrol.service` 从 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs` 执行巡检;`Genarrative-Api-Deploy` 会从上游 API 构建产物复制并执行 `build//scripts/deploy/production-api-deploy.sh`,同目录的 `maintenance-on.sh` / `maintenance-off.sh` 也必须来自同一 build 产物;部署脚本会先写入 `${RELEASE_ROOT}/.${VERSION}.staging.$$`,把 `release-manifest.json` 校验后复制为 current release 的 `release-manifest.api-server.json`,并把备份脚本、巡检脚本、Pingora 直连启用 / 回退 / 预检 / live smoke / canary access log 对账 / health patrol env 复核 / current release 自审 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本,以及 `deploy/systemd`、`deploy/env`、`deploy/pingora` 支撑配置全部复制完成后,才用非合并语义提升为 `${RELEASE_ROOT}/${VERSION}` 并用固定替换语义切换 `current` 符号链接,不再在目标机器 checkout Git,也不再执行部署工作区根部脚本。Pingora 直连启用脚本必须能从 `/opt/genarrative/current` 独立执行 preflight 和 direct live smoke,并默认读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,不依赖 Jenkins 工作区、源码 checkout 或 `/etc` 参考模板;`plan:pingora-direct-cutover` 必须能用同一组参数生成 current release 切换 / 回退 runbook。`production-api-deploy.sh` 对 release manifest、备份脚本、巡检脚本、env 示例目录和 Pingora 直连依赖都执行 fail-fast,且 `--release-root`、`--current-link`、`--api-env-file` 必须是绝对路径,`--version` 必须以数字或字母开头并只能包含数字、字母、点、下划线和短横线,禁止 `.` / `..` 点目录;发布产物缺少 manifest、manifest 未登记 `api-server`、缺少脚本 / 配置目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现或 staging 构建中失败时会在 current 切换前停止部署,清理 staging 并退出本次打开的维护模式;current 切换后的 Pingora 重启、worker 重启、controller 启动或 readiness 失败仍保留维护模式,避免暴露半发布版本;失败不会留下正式 release 目录,不再从部署机工作区兜底补文件,也不把旧同名 release 目录和新文件混合。如果 API 发布后 current release 中缺少这些脚本或目录,应先检查 `Genarrative-Api-Build` 的 `archiveArtifacts` 和 `Genarrative-Api-Deploy` 的 `copyArtifacts` 过滤器是否仍包含 `build//release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`build//scripts/database-backup-to-oss.mjs`、`build//scripts/ops/production-health-patrol.mjs`、`build//scripts/ops/pingora-current-release-audit.mjs`、`build//scripts/ops/pingora-cutover-status-snapshot.mjs`、`build//scripts/ops/pingora-cutover-evidence-bundle.mjs`、`build//scripts/ops/pingora-cutover-command-evidence.mjs`、`build//scripts/ops/pingora-cutover-evidence-verify.mjs`、`build//scripts/ops/pingora-cutover-evidence-audit.mjs`、`build//scripts/check-pingora-direct-preflight.mjs`、`build//scripts/check-pingora-direct-live.mjs`、`build//scripts/check-pingora-canary-access-log-parity.mjs`、`build//scripts/check-production-health-patrol-env.mjs`、`build//scripts/deploy/pingora-direct-enable.sh`、`build//scripts/deploy/pingora-direct-rollback.sh`、`build//deploy/systemd/**`、`build//deploy/env/**` 与 `build//deploy/pingora/**`,不要只在部署机工作区手工补文件。本机用 `npm run check:production-api-release` 通过临时 `CARGO_TARGET_DIR` 和假 `api-server` / `pingora-gateway` release binary 验证 `build-production-release.sh --component api-server --skip-api-build` 会把这些文件打进 API release,并验证显式 `--include-pingora-gateway --skip-pingora-gateway-build` 时发布包包含 `pingora-gateway`、`pingora-gateway.sha256` 和 manifest 登记;`npm run check:pingora-production-release-build` 则用假 `api-server` 和真实 `cargo build -p pingora-gateway --release --target x86_64-unknown-linux-gnu` 验证显式 include 路径能构出可执行网关二进制、checksum 和 manifest 登记;再用 `npm run check:production-api-deploy` 通过临时 release、fake `systemctl` / `curl` 验证从发布产物内执行 `production-api-deploy.sh` 会把这些文件复制到 current release,并验证缺少 release manifest、manifest 未登记 `api-server`、Pingora manifest / 二进制漂移、`--require-pingora-gateway` 缺少 Pingora、缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、current release 自审脚本、状态快照脚本、证据包脚本、证据验真脚本、证据根目录审计脚本、canary access log 对账脚本、env 示例目录或 direct live smoke 脚本时都会在 current 切换前失败并退出本次打开的维护模式,还会验证相对 release root / current link / api env file、点目录或点开头 version 被拒绝、失败时不留下 staging / 正式 release 目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现时拒绝覆盖 / 合并,以及 current 切换后的 readiness 失败会保留维护模式。Pingora 影子网关在 `Genarrative-Api-Build`、`Genarrative-Api-Deploy` 和 `Genarrative-Full-Build-And-Deploy` 中默认随 release 构建、归档、复制并用 `--require-pingora-gateway` 硬校验;只有显式取消 `INCLUDE_PINGORA_GATEWAY` 时才允许 API release 不带 `pingora-gateway` / `pingora-gateway.sha256`。本地 CLI 仍保留显式 `npm run build:production-release -- --component api-server --include-pingora-gateway`,用于在需要 Pingora 的手工发布包里登记 manifest 和 checksum;Jenkins 会先检查 `cmake`、C 编译器和 C++ 编译器,避免进入 Cargo 后才因 `libz-ng-sys` 构建依赖缺失失败。发布包包含 Pingora 时,deploy 会在提升 release 前读取 `systemctl cat genarrative-pingora-gateway.service` 和其 `EnvironmentFile`,拒绝 direct-entry `CAP_NET_BIND_SERVICE`、拒绝非 `127.0.0.1:18081` 的 shadow listen、拒绝 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,确认仍是本机 shadow 高端口后才切换 current;切换后执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,让 shadow / canary 机器加载新网关二进制。该自动拉起不会启用公网 `80/443` 直连入口;已经进入 direct-entry 状态的机器应走正式直连 runbook 或先回退到 shadow。 Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit.mjs`、直连切换状态快照脚本 `scripts/ops/pingora-cutover-status-snapshot.mjs`、证据包脚本 `scripts/ops/pingora-cutover-evidence-bundle.mjs`、命令证据脚本 `scripts/ops/pingora-cutover-command-evidence.mjs`、证据验真脚本 `scripts/ops/pingora-cutover-evidence-verify.mjs`、证据根目录审计脚本 `scripts/ops/pingora-cutover-evidence-audit.mjs` 和 canary access log 对账脚本 `scripts/check-pingora-canary-access-log-parity.mjs` 都属于 API release 的强制随包依赖;缺少任一脚本时 `check:production-api-release`、`check:production-api-deploy` 和生产运维护栏都必须失败,避免切换窗口只能靠 Jenkins 工作区或源码 checkout 临时补自审、证据或日志对账脚本。 diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index f93f7bfba..5d0efaeac 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -15,7 +15,7 @@ - 顶级导航中“创作”进入 `/creation`,“草稿”改为“项目”并进入 `/project`。 - 移动端隐藏“创作”和“项目”入口,只保留浏览和个人相关入口;移动端直达 `/creation` 时提示用户使用桌面端打开。 - 登录后在创作主页展示最近项目,并通过新建项目进入 `/editor/canvas?projectid=xxx`。 -- `陶泥儿精选` 展示全站用户图片画布项目中允许公开展示的画布生成素材包和素材,不使用账号级非项目素材、公开作品补充或 mock 素材。 +- `陶泥儿精选` 展示全站用户图片画布项目中允许公开展示的画布生成素材,不使用账号级非项目素材、公开作品补充或 mock 素材。 - 现有 `/creation/` 玩法工作台、草稿、作品架、生成恢复和发布链路保持不变。 ## 页面结构 @@ -73,13 +73,13 @@ `陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 -精选内容只使用用户从账号级素材库主动提交、后台审核通过、手动设置精选分类且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营需在后台按前台 Tab 设置分类(素材包、角色、UI、音乐、美宣)并开启展示。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。 +精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。 瀑布流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。 Tab: -- 素材包 +- 全部 - 角色 - UI - 音乐 @@ -90,7 +90,7 @@ Tab: 展示口径: -- 素材包:每个列表项展示同一规范图生成的规范图和相关素材组合。 +- 全部:展示所有已公开精选素材,包含未设置分类的素材。 - 角色:每个列表项展示游戏角色图和该角色生成的角色动画素材组合。 - UI:每个列表项展示游戏 UI 图和该 UI 图拆出的子素材组合。 - 音乐:展示背景音乐和音效。 @@ -112,11 +112,11 @@ Tab: 数据来源: -- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId`、`assetId`、审核状态、展示状态、`showcaseCategory`、点赞数、生成成本、返还泥点、`authorDisplayName` / `display_name` 和 `authorPublicUserCode` / 陶泥号用于展示;前端按 `showcaseCategory` 放入素材包、角色、UI、音乐或美宣 Tab,只展示后端已经筛过的公开结果。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`。 -- 素材包、角色、UI、音乐、音效、视频和美宣都必须来自用户素材库中已提交并通过审核的生成素材;不要求素材当前仍保留在某个项目资源中,已通过审核的快照在原素材删除后仍可保留公开展示。 +- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId`、`assetId`、审核状态、展示状态、`showcaseCategory`、点赞数、生成成本、返还泥点、`authorDisplayName` / `display_name` 和 `authorPublicUserCode` / 陶泥号用于展示;前端默认进入“全部”,展示所有后端已经筛过的公开结果;角色、UI、音乐或美宣 Tab 只展示对应 `showcaseCategory` 的素材。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`。 +- 全部、角色、UI、音乐、音效、视频和美宣都必须来自用户素材库中已提交并通过审核的生成素材;不要求素材当前仍保留在某个项目资源中,已通过审核的快照在原素材删除后仍可保留公开展示。 - 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。 - 上传素材、公开作品图片、mock 资源和假组合都不进入精选。 -- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。 +- 任意画布左侧素材列表中,单个素材右键打开素材菜单;原外置删除按钮移入该菜单,以文字 `删除` 展示。生成素材菜单内提供 `提交精选审核`,调用 `POST /api/editor/assets/{assetId}/showcase-submissions` 后进入 `pending` 状态;已提交、已通过或已拒绝的素材显示对应状态,不再显示默认打开的公开勾选项。素材不可提交或后端拒绝提交时,菜单必须显示具体原因,例如非生成素材、素材尚未保存、没有媒体内容或审核未通过不能重复提交。 - 后台新增纯素材查询页,读取账号级 `editor_asset` 中 `sourceType="generated"` 的素材,用于查看所有用户生成素材。该页只提供时间、用户 ID 和关键词查询,以及缩略图详情、提示词全文、生成成本、作者展示名和陶泥号查看;不提供分类筛选,也不承载审核、返还、展示开关或活动卡配置操作。 - 不为了填满展示区创建假素材、假作者、假泥点成本或假组合关系。 - 暂无真实数据的 Tab 保留 Tab 入口,但内容区显示简洁空态。 @@ -128,7 +128,7 @@ Tab: - 最近项目继续使用编辑器项目接口:`listEditorProjects`、`createEditorProject` 和 `/editor/canvas?projectid=xxx`。 - 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由;新建画布引导使用 `guide=toolbar` 一次性 query,主站九宫格直达生成器使用 `tool=` 一次性 query,画布消费后通过 `history.replaceState` 清理这两个参数。 - 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。 -- `陶泥儿精选` 读取后端公开精选接口返回的审核通过素材快照;公开事实以后端 `editor_showcase_asset.review_status`、`display_enabled` 和 `showcase_category` 为准,前端只做展示组合、Tab 归类、点赞交互和提交审核的乐观状态回滚。 +- `陶泥儿精选` 读取后端公开精选接口返回的审核通过素材快照;公开事实以后端 `editor_showcase_asset.review_status` 和 `display_enabled` 为准,`showcase_category` 只用于前端具体 Tab 归类,前端只做展示组合、Tab 归类、点赞交互和提交审核的乐观状态回滚。 - `/creation/` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。 ## 路由与导航 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index c77a9d1b5..1df4cf1d9 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -185,7 +185,7 @@ - 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。 - 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。 - 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。 -- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。` 生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,后续复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。 +- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用自动决策纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化,并复用图标素材拆分流程,把透明 spritesheet 图集和拆分素材都放到画布。 - 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。 - 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。 - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 diff --git a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md index a3d776835..84a4f9de7 100644 --- a/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md +++ b/docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md @@ -28,6 +28,7 @@ - 支持自定义画面比例和大小尺寸。 - 模型固定为 `gpt-image-2`,模型展示对齐角色规范面板底部固定模型样式,不响应点击、不弹出模型切换菜单;历史草稿如果残留其他模型,提交时也必须强制改为 `gpt-image-2`。 - 默认画面比例为 `16:9`,默认大小为 `1K`。 +- UI 素材提取面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。后端先在 11 个候选色中自动决策具体 hex,最多重试 3 次,失败兜底 `#CFEFFF`;后端调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 ## 提示词契约 @@ -57,10 +58,10 @@ - 后端固定使用 `gpt-image-2` 图片编辑链路,并固定提示词: ```text -仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。 +仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。 ``` -- 后端收到 spritesheet 后先把带绿幕源图写入 OSS,再走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。 +- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 按默认 `segModel=birefnet` 透明化,并复用图标素材的连通域拆分能力;未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`。 - 前端先把 spritesheet 原图作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分出的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布。 ## 验收点 @@ -70,5 +71,5 @@ - 从画布选择时只能绑定图标规范图片。 - 请求参数包含 `kind: "ui-design"`、`model: "gpt-image-2"`、比例、大小与可选参考图。 - 上传普通参考图后,请求参考图数组同时包含图标规范和普通参考图,生成图层信息面板展示 `用户输入`、`图标规范` 与普通参考图。 -- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,画布同时出现 spritesheet 图集和拆分后的独立素材。 +- 选中 UI 设计图时浮动工具栏显示 `提取素材`;点击后进入红框素材框选状态,至少框选一个区域后才能调用固定 `gpt-image-2` 提取接口,请求包含 `screenColor`,画布同时出现透明 spritesheet 图集和拆分后的独立素材。 - UI 素材提取面板上传普通参考图后,提取请求参考图数组同时包含红框 UI 设计图和普通参考图,生成图层信息面板展示 `UI设计图` 与普通参考图。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index cd32420d3..84a19ac5e 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -47,17 +47,18 @@ - `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。 - `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024`、`1K 2:3/9:16 -> 1024x1536`、`1K 3:2/16:9 -> 1536x1024`、`2K 1:1 -> 2048x2048`、`2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`。 - 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。 +- 不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`,最终 prompt 和 BgFilter 不透传 `auto`。 - Prompt 固定为: ```text -参考图1的图标规范,背景必须是单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet: +参考图1的图标规范,背景必须是自动决策出的单一纯色抠图背景,且平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet: <素材描述按中文顿号拼接> ``` ## 去背与保存 -- 后端收到 spritesheet 后先把带绿幕源图写入 OSS,再走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法,底层复用 `platform-image::generated_asset_sheets`。 +- 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;请求字段包含 `screenColor` 和 `segModel`,前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 - 去背后的整张 spritesheet 统一编码为透明 PNG,并作为唯一图标素材产物持久化。 - 响应保留 `iconImageSrcs` 字段用于兼容旧客户端,但图标素材生成固定返回空数组;UI 设计图提取素材仍可复用该响应结构返回切片素材。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 08134d4bf..d55d5a2c2 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -29,7 +29,7 @@ - 上传后的每张常规参考图以缩略图展示。 - 每张常规参考图右下角显示大号序号,从 `1` 开始递增。 3. 唯一文本框为 `角色设定`。 -4. 左下角展示画面比例和大小选择按钮。 +4. 左下角只展示画面比例和大小,不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。 5. 右下角展示模型选择和生成按钮。 ## 普通生成面板视觉口径 @@ -49,9 +49,10 @@ - 角色规范与常规参考图作为 `referenceImageSrcs` 传入,顺序固定为: 1. 角色规范图。 2. 常规参考图列表。 -- 请求同时提交 `model`、`aspectRatio` 和 `imageSize`: +- 请求同时提交 `model`、`screenColor`、`segModel`、`aspectRatio` 和 `imageSize`: - `model` 支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。 - 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。 + - 前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,不从生成器快照或输入快照恢复旧手动背景色 / 抠图模型。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`;调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 - 比例按 `x:y` 展示;大小按 `0.5K / 1K / 2K` 展示。 - 尺寸选项来源以 VectorEngine 接入文档为准: - `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`。 @@ -60,11 +61,11 @@ - `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架: ```text -基于图1的角色美术视觉规范指导生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色;绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带绿色描边、绿色投影或绿色反光;禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。 +按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯色背景 <颜色名> / RGB(,,),只作为抠像底色;纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具;角色主体不得带与背景色相同或相近的描边、投影或反光;禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。 角色设定:<用户输入的角色设定> ``` -- 角色图生成完成后,编辑器后端必须先把带绿幕源图写入 OSS,再走 `server-rs/crates/api-server/src/editor_green_screen.rs` 的统一绿幕透明化方法:复用 `platform-image::generated_asset_sheets` 的绿幕 / 近白背景去背能力,并开启内部绿幕 / 近白镂空检测。角色图 prompt 固定要求标准 `#00FF00 / RGB(0,255,0)` 绿幕;后端路径仍兼容生成模型把标准绿幕压成暗绿 / 灰绿背景的情况,但这类宽松识别只用于从画布边缘连通扩散出的背景,不作为全图断开绿色区域删除依据。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用远端 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。 +- 角色图生成完成后,编辑器后端必须先把带自动决策纯色背景的源图写入 OSS,再调用独立 BgFilter 服务透明化:multipart 字段包含 `file`、`screen_color=` 和 `seg_model=`,用户路径默认并只提交 `seg_model=birefnet`。这里的 `seg_model=birefnet` 是 BgFilter 管线内部后端,不等同于手动去背景使用的独立 BiRefNet 服务。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不调用手动去背景的独立 BiRefNet;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这些字段。 - 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;普通图片图层重绘仍保持 `kind: "quick-edit"`。 ## 生成规范参考图 @@ -102,7 +103,7 @@ - `角色规范` 与 `上传常规参考图` 入口是带预览视觉块的参考图卡片,不是无样式文字。 - `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。 - 上传常规参考图后缩略图右下角显示序号。 -- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`aspectRatio` 和 `imageSize`。 +- 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`screenColor`、`aspectRatio` 和 `imageSize`。 - 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。 - 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。 @@ -113,7 +114,7 @@ - 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。 - 角色图层重绘同样走 `/api/editor/images/generations` 的 `kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`。 - 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。 -- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后通过编辑器通用抠图方法执行绿幕 / 近白背景去背和内部镂空清理、写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 +- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化、写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 - `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。 - 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。 - 本次验证命令: @@ -161,7 +162,7 @@ - 视频生成完成后,后端按面板选择抽取对应帧数:`32`、`40` 或 `48`。 - 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 -- 每帧必须先把带绿幕源图写入 OSS,再执行 `editor_green_screen` 统一绿幕去背,输出透明背景 PNG。 +- 每帧必须先把带 legacy `#00FF00` 绿幕源图写入 OSS,再执行 `editor_green_screen` 统一绿幕去背,输出透明背景 PNG。角色动作暂不接入角色 / 图标生图的 `screenColor` 选择。 - 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。 - 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。 - 角色动作图层在画布中使用序列帧播放器循环展示透明 PNG 帧;刷新恢复时必须继续读取 `imageSequenceFrames`,不能回退到 `