diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 81893939c..5df358f76 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -441,6 +441,8 @@ function buildDashboardQuery(query: AdminDashboardQuery) { const params = new URLSearchParams(); appendQueryParam(params, 'granularity', query.granularity); appendQueryParam(params, 'anchor', query.anchor); + appendQueryParam(params, 'startDate', query.startDate); + appendQueryParam(params, 'endDate', query.endDate); const queryString = params.toString(); return queryString ? `?${queryString}` : ''; } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 7f550de16..7e4617954 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -54,11 +54,13 @@ export interface AdminOverviewResponse { database: AdminDatabaseOverviewPayload; } -export type AdminDashboardGranularity = 'day' | 'week' | 'month'; +export type AdminDashboardGranularity = 'day' | 'week' | 'month' | 'period'; export interface AdminDashboardQuery { granularity?: AdminDashboardGranularity; anchor?: string; + startDate?: string; + endDate?: string; } export interface AdminDashboardResponse { @@ -82,6 +84,7 @@ export interface AdminDashboardMetricsPayload { generatedAssets: number; consumedMudPoints: number; totalRegisteredUsers: number; + newRegisteredUsers: number; visitUsers: number; totalVisitUsers: number; visitCount: number; diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index 64f5412ac..46c47f20e 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -1,12 +1,12 @@ /* @vitest-environment jsdom */ -import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import {beforeEach, expect, test, vi} from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; -import {getAdminDashboard} from '../api/adminApiClient'; -import type {AdminDashboardResponse} from '../api/adminApiTypes'; -import {AdminDashboardPage} from './AdminDashboardPage'; +import { getAdminDashboard } from '../api/adminApiClient'; +import type { AdminDashboardResponse } from '../api/adminApiTypes'; +import { AdminDashboardPage } from './AdminDashboardPage'; vi.mock('../api/adminApiClient', () => ({ formatAdminApiError: vi.fn((error: unknown) => @@ -28,6 +28,7 @@ const dashboardResponse: AdminDashboardResponse = { generatedAssets: 12, consumedMudPoints: 88, totalRegisteredUsers: 1200, + newRegisteredUsers: 16, visitUsers: 34, totalVisitUsers: 456, visitCount: 98, @@ -40,7 +41,7 @@ const dashboardResponse: AdminDashboardResponse = { title: '生产素材', unit: '个', total: 12, - buckets: [{key: '2026-06-23', label: '2026-06-23', value: 12}], + buckets: [{ key: '2026-06-23', label: '2026-06-23', value: 12 }], }, ], operations: { @@ -53,9 +54,9 @@ const dashboardResponse: AdminDashboardResponse = { }, ], assetKindBreakdown: [ - {key: 'editor_generated_image', label: '画板图片', value: 9}, + { key: 'editor_generated_image', label: '画板图片', value: 9 }, ], - moduleVisitBreakdown: [{key: 'auth', label: '认证', value: 30}], + moduleVisitBreakdown: [{ key: 'auth', label: '认证', value: 30 }], }, warnings: [], generatedAt: '2026-06-23T12:00:00Z', @@ -70,8 +71,9 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = const user = userEvent.setup(); render(); - expect(await screen.findByText('今日总生产素材数')).toBeTruthy(); + expect(await screen.findByText('本日总生产素材数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); + expect(screen.getByText('本日新增用户数')).toBeTruthy(); expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy(); expect(screen.getByText('生产素材')).toBeTruthy(); @@ -82,38 +84,64 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = expect(screen.getByText('访问模块分布')).toBeTruthy(); }); -test('Dashboard 选择周时带上 week granularity 和周一 anchor', async () => { +test('Dashboard 选择本周时按当前终止日期填充整周范围', async () => { const user = userEvent.setup(); render(); - await screen.findByText('今日总生产素材数'); - await user.click(screen.getByRole('button', { name: '周' })); - fireEvent.change(screen.getByLabelText('周'), { - target: {value: '2026-W27'}, + await screen.findByText('本日总生产素材数'); + fireEvent.change(screen.getByLabelText('终止日期'), { + target: { value: '2026-07-02' }, }); + await user.click(screen.getByRole('button', { name: '本周' })); await waitFor(() => { expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { granularity: 'week', - anchor: '2026-06-29', + anchor: '2026-07-05', + startDate: '2026-06-29', + endDate: '2026-07-05', }); }); }); -test('Dashboard 选择月份时带上 month granularity 和月初 anchor', async () => { +test('Dashboard 选择本月时按当前终止日期填充整月范围', async () => { const user = userEvent.setup(); render(); - await screen.findByText('今日总生产素材数'); - await user.click(screen.getByRole('button', { name: '月' })); - fireEvent.change(screen.getByLabelText('月份'), { - target: {value: '2026-07'}, + await screen.findByText('本日总生产素材数'); + fireEvent.change(screen.getByLabelText('终止日期'), { + target: { value: '2026-07-12' }, }); + await user.click(screen.getByRole('button', { name: '本月' })); await waitFor(() => { expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { granularity: 'month', - anchor: '2026-07-01', + anchor: '2026-07-31', + startDate: '2026-07-01', + endDate: '2026-07-31', }); }); }); + +test('Dashboard 手动选择起止日期时使用本时段查询', async () => { + render(); + + await screen.findByText('本日总生产素材数'); + fireEvent.change(screen.getByLabelText('起始日期'), { + target: { value: '2026-06-01' }, + }); + fireEvent.change(screen.getByLabelText('终止日期'), { + target: { value: '2026-06-12' }, + }); + + await waitFor(() => { + expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { + granularity: 'period', + anchor: undefined, + startDate: '2026-06-01', + endDate: '2026-06-12', + }); + }); + expect(screen.getByText('本时段新增用户数')).toBeTruthy(); +}); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index ce76add07..e3277a236 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -1,14 +1,14 @@ -import {RefreshCw} from 'lucide-react'; -import {useCallback, useEffect, useMemo, useState} from 'react'; +import { RefreshCw } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; -import {getAdminDashboard} from '../api/adminApiClient'; +import { getAdminDashboard } from '../api/adminApiClient'; import type { AdminDashboardBreakdownRowPayload, AdminDashboardChartPayload, AdminDashboardGranularity, AdminDashboardResponse, } from '../api/adminApiTypes'; -import {handlePageError} from './pageUtils'; +import { handlePageError } from './pageUtils'; interface AdminDashboardPageProps { token: string; @@ -21,9 +21,10 @@ const granularityOptions: Array<{ id: AdminDashboardGranularity; label: string; }> = [ - {id: 'day', label: '日'}, - {id: 'week', label: '周'}, - {id: 'month', label: '月'}, + { id: 'day', label: '本日' }, + { id: 'week', label: '本周' }, + { id: 'month', label: '本月' }, + { id: 'period', label: '本时段' }, ]; export function AdminDashboardPage({ @@ -35,11 +36,13 @@ export function AdminDashboardPage({ ); const [granularity, setGranularity] = useState('day'); - const [anchor, setAnchor] = useState(() => formatDateInput(new Date())); + const [dateRange, setDateRange] = useState(() => { + const today = formatDateInput(new Date()); + return { startDate: today, endDate: today }; + }); const [activeTab, setActiveTab] = useState('metrics'); const [errorMessage, setErrorMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); - const anchorInput = buildAnchorInput(granularity, anchor); const loadDashboard = useCallback(async () => { setIsLoading(true); @@ -47,7 +50,9 @@ export function AdminDashboardPage({ try { const response = await getAdminDashboard(token, { granularity, - anchor, + anchor: granularity === 'period' ? undefined : dateRange.endDate, + startDate: dateRange.startDate, + endDate: dateRange.endDate, }); setDashboard(response); } catch (error: unknown) { @@ -55,7 +60,13 @@ export function AdminDashboardPage({ } finally { setIsLoading(false); } - }, [anchor, granularity, onUnauthorized, token]); + }, [ + dateRange.endDate, + dateRange.startDate, + granularity, + onUnauthorized, + token, + ]); useEffect(() => { void loadDashboard(); @@ -92,6 +103,12 @@ export function AdminDashboardPage({ value: metrics?.totalRegisteredUsers ?? 0, unit: '人', }, + { + id: 'new-registered-users', + label: `${rangePrefix(granularity)}新增用户数`, + value: metrics?.newRegisteredUsers ?? 0, + unit: '人', + }, { id: 'visit-users', label: `${rangePrefix(granularity)}访问人数`, @@ -134,6 +151,28 @@ export function AdminDashboardPage({ {dashboard?.range.periodLabel ?? '运营指标'} + + + 起始日期 + + handleDateRangeChange('startDate', event.target.value) + } + /> + + + 终止日期 + + handleDateRangeChange('endDate', event.target.value) + } + /> + + setGranularity(option.id)} + onClick={() => handleGranularityChange(option.id)} > {option.label} ))} - - {anchorInput.label} - { - const nextAnchor = normalizeAnchorInputValue( - granularity, - event.target.value, - ); - if (nextAnchor) { - setAnchor(nextAnchor); - } - }} - /> - ); + + function handleGranularityChange(nextGranularity: AdminDashboardGranularity) { + setGranularity(nextGranularity); + setDateRange((current) => + nextGranularity === 'period' + ? normalizeDateRange(current) + : buildPresetDateRange(nextGranularity, current.endDate), + ); + } + + function handleDateRangeChange( + field: 'startDate' | 'endDate', + value: string, + ) { + if (!parseDateValueAsUtc(value)) { + return; + } + setGranularity('period'); + setDateRange((current) => + normalizeDateRange({ ...current, [field]: value }), + ); + } } function MetricCard({ @@ -301,7 +345,7 @@ function MetricCard({ ); } -function ChartPanel({chart}: {chart: AdminDashboardChartPayload}) { +function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) { const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value)); return ( @@ -381,41 +425,6 @@ function formatDateInput(date: Date) { return `${year}-${month}-${day}`; } -function buildAnchorInput(granularity: AdminDashboardGranularity, anchor: string) { - if (granularity === 'week') { - return { - label: '周', - type: 'week', - value: dateToWeekInputValue(anchor), - }; - } - if (granularity === 'month') { - return { - label: '月份', - type: 'month', - value: anchor.slice(0, 7), - }; - } - return { - label: '日期', - type: 'date', - value: anchor, - }; -} - -function normalizeAnchorInputValue( - granularity: AdminDashboardGranularity, - value: string, -) { - if (granularity === 'week') { - return weekInputValueToMondayDate(value); - } - if (granularity === 'month') { - return monthInputValueToFirstDate(value); - } - return parseDateValueAsUtc(value) ? value : ''; -} - function rangePrefix(granularity: AdminDashboardGranularity) { if (granularity === 'week') { return '本周'; @@ -423,55 +432,65 @@ function rangePrefix(granularity: AdminDashboardGranularity) { if (granularity === 'month') { return '本月'; } - return '今日'; + if (granularity === 'period') { + return '本时段'; + } + return '本日'; } -function dateToWeekInputValue(dateValue: string) { +function buildPresetDateRange( + granularity: AdminDashboardGranularity, + selectedDate: string, +) { + const fallbackDate = formatDateInput(new Date()); + const anchor = parseDateValueAsUtc(selectedDate) + ? selectedDate + : fallbackDate; + if (granularity === 'week') { + return buildWeekDateRange(anchor); + } + if (granularity === 'month') { + return buildMonthDateRange(anchor); + } + return { startDate: anchor, endDate: anchor }; +} + +function buildWeekDateRange(dateValue: string) { const date = parseDateValueAsUtc(dateValue); if (!date) { - return ''; + return { startDate: dateValue, endDate: dateValue }; } - const day = date.getUTCDay() || 7; - date.setUTCDate(date.getUTCDate() + 4 - day); - const weekYear = date.getUTCFullYear(); - const yearStart = new Date(Date.UTC(weekYear, 0, 1)); - const week = Math.ceil( - ((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7, + const weekday = date.getUTCDay() || 7; + const monday = new Date(date); + monday.setUTCDate(date.getUTCDate() - weekday + 1); + const sunday = new Date(monday); + sunday.setUTCDate(monday.getUTCDate() + 6); + return { startDate: formatUtcDate(monday), endDate: formatUtcDate(sunday) }; +} + +function buildMonthDateRange(dateValue: string) { + const date = parseDateValueAsUtc(dateValue); + if (!date) { + return { startDate: dateValue, endDate: dateValue }; + } + + const firstDate = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1), ); - return `${weekYear}-W${String(week).padStart(2, '0')}`; + const lastDate = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0), + ); + return { + startDate: formatUtcDate(firstDate), + endDate: formatUtcDate(lastDate), + }; } -function weekInputValueToMondayDate(value: string) { - const match = /^(\d{4})-W(\d{2})$/.exec(value); - if (!match) { - return ''; - } - - const year = Number(match[1]); - const week = Number(match[2]); - if (!Number.isInteger(year) || week < 1 || week > 53) { - return ''; - } - - const jan4 = new Date(Date.UTC(year, 0, 4)); - const jan4Day = jan4.getUTCDay() || 7; - const monday = new Date(jan4); - monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7); - const mondayDate = formatUtcDate(monday); - return dateToWeekInputValue(mondayDate) === value ? mondayDate : ''; -} - -function monthInputValueToFirstDate(value: string) { - const match = /^(\d{4})-(\d{2})$/.exec(value); - if (!match) { - return ''; - } - - const year = Number(match[1]); - const month = Number(match[2]); - const firstDate = `${year}-${String(month).padStart(2, '0')}-01`; - return parseDateValueAsUtc(firstDate) ? firstDate : ''; +function normalizeDateRange(range: { startDate: string; endDate: string }) { + return range.startDate > range.endDate + ? { startDate: range.endDate, endDate: range.startDate } + : range; } function parseDateValueAsUtc(value: string) { diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 5cc78df0d..d826c2eff 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -284,10 +284,17 @@ button:disabled { } .admin-dashboard-granularity { - width: 188px; + width: 250px; flex: 0 0 auto; } +.admin-dashboard-period-fields { + display: grid; + width: 352px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + .admin-dashboard-date-field { width: 170px; } @@ -1279,11 +1286,16 @@ button:disabled { } .admin-dashboard-granularity, + .admin-dashboard-period-fields, .admin-dashboard-date-field, .admin-dashboard-actions .admin-secondary-button { width: 100%; } + .admin-dashboard-period-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .admin-dashboard-metric-grid, .admin-dashboard-operation-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1351,6 +1363,10 @@ button:disabled { width: 100%; } + .admin-dashboard-period-fields { + grid-template-columns: 1fr; + } + .admin-dashboard-metric-grid, .admin-dashboard-operation-grid { grid-template-columns: 1fr; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 725ea49da..1075149ff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -527,7 +527,7 @@ - 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。 - 决策:队列 BFF 暴露用户可见队列概览 `GET /api/runtime/external-generation/queue-overview` 和单 job 状态 `GET /api/runtime/external-generation/jobs/{jobId}`;首版固定“单动作单 job”,不拆提示词 / 生图 / 切图 / 持久化等阶段 job。进入队列的范围为跳一跳 `compile-draft` / `regenerate-tiles`、拼消消 `compile-draft` / `regenerate-atlas`、敲木鱼 `compile-draft` / `regenerate-hit-object` 图片资产动作;非外部图片生成动作继续 inline。 - 影响范围:外部生成 worker Module、api-server BFF、生成页等待展示、跳一跳 / 拼消消 / 敲木鱼创作与结果页生成动作、本地和生产验证文档。 -- 验证方式:本地 `npm run dev` 默认保留 inline 开发体验;验证 worker 队列、等待展示、lease 或扩缩容时显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 并启动 worker,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz`、`/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。 +- 验证方式:本地 `npm run dev` 与 `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程监听 HTTP 并消费外部生成队列;验证生产式拆分角色、lease 或扩缩容时分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz`、`/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。 - 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板 @@ -3710,6 +3710,7 @@ - 背景:后台需要默认进入运营数据面板,而不是服务 / 数据库状态页;看板要同时支持日 / 周 / 月筛选,并展示生产素材、泥点消耗、注册、访问和当前使用人数。 - 决策:`apps/admin-web` 默认路由改为 `#dashboard`,原 `#overview` 保留为“服务总览”。Dashboard 统一通过 `GET /admin/api/dashboard` 读取 api-server 后端投影,不让前端绕过 BFF 直接访问 SpacetimeDB。后端不新增 SpacetimeDB schema,聚合现有 `editor_project_resource`、`profile_wallet_ledger`、`profile_dashboard_state`、`tracking_daily_stat` 和 `tracking_event`。 -- 指标口径:生产素材数统计 `editor_project_resource.source_type = generated`;消耗泥点数统计 `profile_wallet_ledger.source_type = asset_operation_consume` 的负向流水绝对值;访问次数只统计 `tracking_daily_stat.scope_kind = site`;访问人数和当前使用人数按登录用户去重,匿名访问人数需要未来补 visitor id 后才能统计。 +- 2026-06-24 补充:Dashboard 日期控件改为起始日期 / 终止日期;`granularity=period` 使用 `startDate` / `endDate` 自定义闭区间,`day` / `week` / `month` 保留 `anchor` 兼容并由前端自动填充对应范围,页面周期切换展示为“本日 / 本周 / 本月 / 本时段”。 +- 指标口径:生产素材数统计 `editor_project_resource.source_type = generated`;消耗泥点数统计 `profile_wallet_ledger.source_type = asset_operation_consume` 的负向流水绝对值;总注册用户和新增用户数均来自 `profile_dashboard_state`,其中新增用户数按 `created_at` 落入当前时间窗统计;访问次数只统计 `tracking_daily_stat.scope_kind = site`;访问人数和当前使用人数按登录用户去重,匿名访问人数需要未来补 visitor id 后才能统计。 - 影响范围:`/admin/api/dashboard`、`shared-contracts` admin DTO、`apps/admin-web` 默认路由和 Dashboard 页面、后台运营文档。 - 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`、`npm run admin-web:typecheck`、`npx vitest run apps/admin-web/src/pages/AdminDashboardPage.test.tsx apps/admin-web/src/app/adminRoutes.test.ts --reporter verbose`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 33f5743bb..9f2bf1792 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -31,6 +31,14 @@ - 验证:`creationShowcaseModel.test.ts` 覆盖同源同媒体副本只展示原件;`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖拖拽生成素材到画布时继续提交 `sourceResourceId`;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 确认后端资源复用 / 精选过滤逻辑可编译。 - 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`src/components/creation-home/creationShowcaseModel.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 +## 陶泥儿精选作者丢失先查公开资源 owner 字段 + +- 现象:`/creation` 的 `陶泥儿精选` 卡片和预览弹窗中,素材下方作者 id 消失或只显示占位。 +- 原因:精选数据源来自公开 `editor_project_resource` 快照;如果 SpacetimeDB read model、`spacetime-client` mapper 或 `api-server` payload 任一层漏传 `owner_user_id` / `ownerUserId`,前端作者兜底就没有真实值可显示。 +- 处理:`EditorProjectResourceSnapshot`、`EditorProjectResourceRecord` 和 `EditorProjectResourcePayload` 需要一路保留 owner 字段;前端 `creationShowcaseModel` 在没有作者昵称字段时读取 `ownerUserId` 作为作者兜底。 +- 验证:`creationShowcaseModel.test.ts` 覆盖 owner id 作者兜底;`editorProjectClient.test.ts` 覆盖公开精选接口客户端保留 `ownerUserId`;后端改 read model 后运行 `npm run spacetime:generate`、`cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml` 和 `npm run check:spacetime-schema`。 +- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/mapper/editor_project.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`src/components/creation-home/creationShowcaseModel.ts`。 + ## 画板外部生成排队超时不是失败 - 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。 @@ -239,6 +247,22 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationPlacementModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。 - 关联:`src/components/image-editor/ImageCanvasGenerationPlacementModel.ts`、`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 +## 图片画布图片改造也必须创建独立生成器占位 + +- 现象:点击图片图层的“改造”后,输入框直接挂在原图上,提交时既不像其它生成入口一样有独立占位,也容易让用户误以为会覆盖源图。 +- 原因:图片改造复用了旧 `QuickEditPanelState` / redraw 面板路径,只把源图选中并在原图附近打开快速编辑框,没有进入统一的 `CanvasGenerationDialogState` 占位链路。 +- 处理:图片和用户快照图层的“改造”统一创建 `mode="quick-edit"` 的 generation dialog,占位仍走 `ImageCanvasGenerationPlacementModel`;源图作为隐式最后一张参考图提交,并把“当前图”提示词归一到对应参考图编号。音频改造继续走音频生成器路径,非图片 fallback 才保留旧面板。参考图 Data URL 提交前可压缩,但浏览器图片解码卡住时必须超时透传原图,不能阻塞生成请求。 +- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorImageReference.test.ts -- --runInBand`,以及 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "hides quick edit and redraw panels|opens generated image info|shows the quick edit generator" -- --runInBand`。 +- 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorImageReference.ts`。 + +## 图片画布生成完成应用项目快照后也要刷新素材库 + +- 现象:部分素材生成成功后画布上已经出现结果,但左侧素材库没有立刻出现新素材,刷新页面后才显示。 +- 原因:生成接口带 `project` 快照时,前端只调用 `applyProjectSnapshot(...)` 刷新画布布局;左侧素材库状态仍停留在首次 `loadEditorAssetLibrary()` 的结果。只有少数图片分支手动 `upsertGeneratedAsset`,图标素材图集、视频、音频、排队完成后重新 `loadEditorProject` 等分支不会统一更新素材库。 +- 处理:素材库 hook 必须提供显式 `refreshAssetLibrary()`;传给生成工作流的项目快照应用函数应包装为“先应用项目快照,再刷新素材库”。新增生成分支不要在各自分支散落刷新逻辑,除非是无项目快照的本地图层回填,才继续使用 `generatedAssetSnapshot` / `upsertGeneratedAsset`。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "refreshes the asset library after an icon generation project snapshot is applied" -- --runInBand`;`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx -- --runInBand`。 +- 关联:`src/components/image-editor/useImageCanvasAssetLibrary.ts`、`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`。 + ## Windows 本地 dev 不要把 RUSTC_WRAPPER 绕过写成 rustc - 现象:Windows 上执行 `npm run dev:api-server` 时,api-server 在 Cargo 启动阶段失败,日志出现 `error: multiple input filenames provided (first two filenames are ... rustc.exe and -)`,`/healthz` 无法访问。 diff --git a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md index bac119faa..66a94e92b 100644 --- a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md +++ b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md @@ -56,12 +56,29 @@ │ ├─ frames/ │ │ ├─ frame-01.png │ │ └─ frame-02.png +│ ├─ skeleton.json +│ ├─ README.md │ ├─ metadata.json │ └─ manifest.txt ├─ metadata.json └─ manifest.txt ``` +角色动作单图层导出时,直接生成独立 ZIP: + +```text +角色动作-SpineJSON.zip +├─ frames/ +│ ├─ frame-01.png +│ └─ frame-02.png +├─ skeleton.json +├─ README.md +├─ metadata.json +└─ manifest.txt +``` + +`skeleton.json` 使用 Spine JSON 4.2 结构表达序列帧:一个 `root` bone、一个 `character` slot、每帧一个 region attachment,`animations.animation.slots.character.attachment` 按时间切换 attachment。它不是 `.spine` 编辑器工程,也不包含 mesh、权重、IK 或拆件绑定。 + ## 元数据结构 `metadata.json` 记录项目、导出时间、图层和图片文件映射: @@ -134,6 +151,7 @@ assetObjectId > objectKey > sourceAssetId > src - 同源或可访问 URL 使用 `fetch` 拉取 Blob。 - 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截。 - `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`。 + - `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。 - 拉取失败时记录失败项,不中断整个导出。 5. 使用 `JSZip` 写入 `images/`、`metadata.json` 和 `manifest.txt`。 6. `zip.generateAsync({ type: 'blob' })` 生成文件。 @@ -158,6 +176,32 @@ assetObjectId > objectKey > sourceAssetId > src - 图层的锁定、翻转、分组状态写入元数据。 - `data:image` 图片不经过网络请求即可导出。 - URL 图片 fetch 失败时不中断其他素材导出。 +- 序列帧导出的 `skeleton.json` 可被独立验证器解析并预览。 + +## Spine JSON 验证器 + +导出产物验证项目位于 `tools/spine-json-export-validator/`,独立于主站构建。它用 `@esotericsoftware/spine-webgl` 的 `SkeletonJson`、自定义 `AttachmentLoader`、`GLTexture` 和 `SceneRenderer` 直接验证导出的 `skeleton.json + frames/`,不要求 atlas。 + +首次使用安装验证器自己的依赖: + +```bash +npm --prefix tools/spine-json-export-validator install +``` + +本地启动: + +```bash +npm run spine-export-validator:dev +``` + +检查命令: + +```bash +npm run spine-export-validator:typecheck +npm run spine-export-validator:build +``` + +验证器支持根目录 `skeleton.json`,也支持画布素材 ZIP 内 `sequences/<编号-标题>/skeleton.json`。当前只验证 region attachment;如果未来导出 mesh、sequence attachment 或多动画命名,需要同步扩展验证器的 loader 和 UI 摘要。 ## 后续扩展 diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index aa3de40e3..d9a618bc5 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -8,11 +8,12 @@ ## 查询参数 -`GET /admin/api/dashboard?granularity=day|week|month&anchor=YYYY-MM-DD` +`GET /admin/api/dashboard?granularity=day|week|month|period&anchor=YYYY-MM-DD&startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` - `granularity` 默认为 `day`。 -- `anchor` 使用北京时间日历日期。`week` 和 `month` 选择包含该日期的自然周 / 自然月。 -- 前端在日 / 周 / 月模式分别展示日期、周、月份选择器;周选择器会把选中的周转换为该周周一作为 `anchor`,月份选择器会转换为当月 1 日作为 `anchor`。 +- `anchor` 使用北京时间日历日期,保留给 `day` / `week` / `month` 兼容旧查询口径。`week` 和 `month` 选择包含该日期的自然周 / 自然月。 +- `period` 使用 `startDate` / `endDate` 作为闭区间自定义时段,最多 366 天。 +- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”会按当前终止日期自动填充对应自然日 / 自然周 / 自然月范围,手动修改起止日期或点击“本时段”后按当前日期范围查询。 - 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。 ## 指标口径 @@ -20,6 +21,7 @@ - 生产素材数:`editor_project_resource` 中 `source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。 - 消耗泥点数:`profile_wallet_ledger` 中 `source_type = asset_operation_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。 - 总注册用户:`profile_dashboard_state` 行数。 +- 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月 / 本时段切换。 - 访问次数:`tracking_daily_stat` 中 `scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。 - 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user` 的 `scope_id` 去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。 - 当前使用人数:最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 3cc32e059..6093f3207 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -208,11 +208,11 @@ npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.tes 本地 smoke: ```bash -GENARRATIVE_PROCESS_ROLE=all npm run dev +npm run dev curl -f http://127.0.0.1:/healthz ``` -本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke。 +本地 `npm run dev` 与 `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。需要验证生产式拆分角色、lease 重领或扩缩容时,再分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`,也可以使用隔离容器 smoke。 生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 848cb1884..67d49adc6 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -468,7 +468,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorProjectResource` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 +- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开响应必须透传 `ownerUserId`,供精选卡片在缺少作者昵称时显示作者 id。公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 - 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。 ### `editor_asset_folder` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index fbca02498..4cc568e2b 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -53,11 +53,9 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` 会用空的 `RUSTC_WRAP Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg,`npm run dev` / `npm run dev:api-server` 会自动把该目录加入本次 `api-server` 子进程 `Path`,并注入 `CHARACTER_ANIMATION_FFMPEG_PATH` / `CHARACTER_ANIMATION_FFPROBE_PATH` 的绝对路径。这样即使外层终端或长期运行的 dev 进程是在安装 FFmpeg 之前启动,角色动画抽帧也不会继续因为 `ffmpeg: program not found` 失败;若手动配置了上述环境变量或 `GENARRATIVE_CHARACTER_ANIMATION_*` 前缀变量,显式配置优先。 -开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 +开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true` 和 `GENARRATIVE_PROCESS_ROLE=all`,因此密码登录在本地开发环境可直接注册未知手机号账号,且本地 `api-server` 会同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。生产环境仍按 `api-server` 配置默认关闭密码自动注册,并由独立 worker 进程消费队列。 -本地 `npm run dev` 和 `npm run dev:api-server` 默认保留 inline 开发体验:未显式设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,外部生成 handler 会同步复用 worker executor,完成后返回 `completed`,便于快速确认 provider、OSS 和 SpacetimeDB 写回链路。inline 不创建 `external_generation_job`,也不能验证 worker lease、队列等待展示或动态扩缩容。 - -本地排查外部内容生成 worker 队列时,必须显式使用 queue,例如 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server`,让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`,以及图片画布 `editor_image_generation` / `editor_image_edit` / `editor_icon_spritesheet_generation` / `editor_ui_design_asset_extraction` / `editor_character_animation_generation` / `editor_video_generation` / `editor_sound_effect_generation` / `editor_background_music_generation`。非外部 provider 生成动作继续 inline,不进入队列。worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。 +本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`,以及图片画布 `editor_image_generation` / `editor_image_edit` / `editor_icon_spritesheet_generation` / `editor_ui_design_asset_extraction` / `editor_character_animation_generation` / `editor_video_generation` / `editor_sound_effect_generation` / `editor_background_music_generation`。非外部 provider 生成动作继续 inline,不进入队列。显式把本地进程角色设为 `api` 且没有 worker 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。 `我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。 @@ -387,7 +385,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. 生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。 -`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`external-generation-controller` 只管理 worker systemd 实例,`all` 仅用于本地或临时 smoke,不隐式启动 controller。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制;生产和容器压测默认保持 `queue`,本地 `npm run dev` 默认保留 `inline` 开发体验,只有显式配置 `queue` 才会落 `external_generation_job`。`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;生产默认由 `genarrative-external-generation-controller.service` 读取 `get_external_generation_queue_stats_and_return`,按 `claimable_pending + running_active + expired_running` 计算目标 worker 数,并对 `genarrative-external-generation-worker@N.service` 精确执行 `systemctl start/stop`。controller 参数模板是 `deploy/env/external-generation-controller.env.example`:默认保底 `MIN_WORKERS=1`、上限 `MAX_WORKERS=8`、每 worker 目标 `TARGET_JOBS_PER_WORKER=2`、`POLL_INTERVAL_MS=10000`、连续 `SCALE_DOWN_IDLE_ROUNDS=6` 轮完全空闲才缩容;缩容每轮只停止最高编号的一个实例,且不主动停止 `@1`。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会安装 worker 模板、controller unit 和两份专属 env 模板,默认 enable 首个 `genarrative-external-generation-worker@1.service` 与 `genarrative-external-generation-controller.service`;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active,同时重启并验活 controller。手动兜底扩容仍可用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`;controller 下轮会按队列压力修正到目标实例数。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`,若不希望重启 controller 可传 `--no-worker-controller`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。首版 worker 粒度是单动作单 job,不拆阶段 job;当前外部生成动作覆盖拼图、跳一跳、拼消消、敲木鱼和图片画布编辑器生成入口,纯元信息保存、发布、试玩启动、运行态动作和公开读取继续 inline。图片画布 worker 成功后由后端保存 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只轮询 job 并重新读取项目快照。当前生成业务失败只做用户重新触发,不做自动业务重试,避免 worker 退款和重试成功之间产生钱包账本漂移。 +`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`external-generation-controller` 只管理 worker systemd 实例,`all` 仅用于本地或临时 smoke,不隐式启动 controller。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制;生产和容器压测默认保持 `queue`,本地 `npm run dev` / `npm run dev:api-server` 默认由 dev 脚本注入 `GENARRATIVE_PROCESS_ROLE=all`,如果外部生成策略为 `queue` 会由同一进程消费队列。`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;生产默认由 `genarrative-external-generation-controller.service` 读取 `get_external_generation_queue_stats_and_return`,按 `claimable_pending + running_active + expired_running` 计算目标 worker 数,并对 `genarrative-external-generation-worker@N.service` 精确执行 `systemctl start/stop`。controller 参数模板是 `deploy/env/external-generation-controller.env.example`:默认保底 `MIN_WORKERS=1`、上限 `MAX_WORKERS=8`、每 worker 目标 `TARGET_JOBS_PER_WORKER=2`、`POLL_INTERVAL_MS=10000`、连续 `SCALE_DOWN_IDLE_ROUNDS=6` 轮完全空闲才缩容;缩容每轮只停止最高编号的一个实例,且不主动停止 `@1`。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会安装 worker 模板、controller unit 和两份专属 env 模板,默认 enable 首个 `genarrative-external-generation-worker@1.service` 与 `genarrative-external-generation-controller.service`;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active,同时重启并验活 controller。手动兜底扩容仍可用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`;controller 下轮会按队列压力修正到目标实例数。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`,若不希望重启 controller 可传 `--no-worker-controller`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。首版 worker 粒度是单动作单 job,不拆阶段 job;当前外部生成动作覆盖拼图、跳一跳、拼消消、敲木鱼和图片画布编辑器生成入口,纯元信息保存、发布、试玩启动、运行态动作和公开读取继续 inline。图片画布 worker 成功后由后端保存 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只轮询 job 并重新读取项目快照。当前生成业务失败只做用户重新触发,不做自动业务重试,避免 worker 退款和重试成功之间产生钱包账本漂移。 `Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等通用构建链依赖。因 VectorEngine 图片上游 POST 已改用 `libcurl`,当前 Linux release 构建出的 `api-server` 运行时需要 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认只提供 OpenSSL 3.0.x,不能直接满足该符号版本。Provision 会把 OpenSSL `3.2.0` 独立安装到 `/opt/genarrative/openssl-3.2.0`,校验官方 tarball SHA256,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 让 api-server 使用,避免替换系统 OpenSSL 或影响 ssh / nginx / apt。Ubuntu / apt 目标机为完成这一步会安装 `build-essential`、`ca-certificates`、`curl`、`perl`、`tar` 等 OpenSSL 运行时自举工具;这只服务于独立 OpenSSL 运行时安装,不代表 provision 重新承担 api-server 构建职责。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。 diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index f968603d5..347aca58c 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -63,7 +63,7 @@ `陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 -精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 且 `public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。 +精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 且 `public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。公开 BFF 必须返回资源 `ownerUserId`,前端在没有作者昵称字段时用该 id 作为作者兜底。若现有数据缺少提示词、作者标识或成本字段,v1 显示保守占位,不伪造内容。 Tab: @@ -100,7 +100,7 @@ Tab: 数据来源: -- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,只保留 `sourceType="generated"` 且 `publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。 +- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,快照包含 `ownerUserId` 作为作者展示兜底;前端只保留 `sourceType="generated"` 且 `publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。 - 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。 - 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。 - 上传素材、公开作品图片、mock 资源和假组合都不进入精选。 diff --git a/package.json b/package.json index 6d15185b0..87c2d3280 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,9 @@ "deploy:pingora-direct-rollback": "bash scripts/deploy/pingora-direct-rollback.sh", "assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs", "assets:match3d-style-references": "node scripts/generate-match3d-style-references.mjs", + "spine-export-validator:dev": "npm --prefix tools/spine-json-export-validator run dev --", + "spine-export-validator:typecheck": "npm --prefix tools/spine-json-export-validator run typecheck", + "spine-export-validator:build": "npm --prefix tools/spine-json-export-validator run build", "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", "check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index b5c204c0e..4fcd73b11 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -2253,6 +2253,7 @@ function buildApiServerProcessEnv({ ...baseEnv, // 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。 GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true', + GENARRATIVE_PROCESS_ROLE: baseEnv.GENARRATIVE_PROCESS_ROLE || 'all', GENARRATIVE_API_HOST: options.apiHost, GENARRATIVE_API_PORT: String(options.apiPort), GENARRATIVE_API_LOG: options.apiLog, diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 1d7e4c515..a95ff9981 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -211,10 +211,22 @@ describe('dev scheduler api-server env', () => { }); expect(env.GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED).toBe('true'); + expect(env.GENARRATIVE_PROCESS_ROLE).toBe('all'); expect(env.GENARRATIVE_API_PORT).toBe('9091'); expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199'); }); + test('dev 脚本保留显式 api-server 进程角色', () => { + const {options} = parseArgs(['api-server'], {}); + const env = buildApiServerProcessEnv({ + baseEnv: {GENARRATIVE_PROCESS_ROLE: 'api'}, + options, + state: {spacetimeServer: 'http://127.0.0.1:3199'}, + }); + + expect(env.GENARRATIVE_PROCESS_ROLE).toBe('api'); + }); + test('Windows 本地 dev 自动注入已安装的 FFmpeg 路径', () => { const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-ffmpeg-')); try { diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 200db9373..bc0f39f7c 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -62,6 +62,7 @@ const ADMIN_TRACKING_EVENT_MAX_LIMIT: u32 = 1000; const ADMIN_DATABASE_TABLE_DEFAULT_LIMIT: u32 = 100; const ADMIN_DATABASE_TABLE_MAX_LIMIT: u32 = 500; const ADMIN_DASHBOARD_ROW_LIMIT: u32 = 50_000; +const ADMIN_DASHBOARD_MAX_PERIOD_DAYS: i64 = 366; const ADMIN_DASHBOARD_ACTIVE_WINDOW_MICROS: i64 = 5 * 60 * 1_000_000; #[derive(Clone, Debug)] @@ -633,6 +634,7 @@ enum AdminDashboardGranularity { Day, Week, Month, + Period, } struct AdminDashboardRangeResolved { @@ -661,6 +663,12 @@ struct AdminDashboardWalletStats { warnings: Vec, } +#[derive(Default)] +struct AdminDashboardRegistrationStats { + new_registered_users: AdminDashboardSeries, + warnings: Vec, +} + #[derive(Default)] struct AdminDashboardTrackingStats { visit_count: AdminDashboardSeries, @@ -698,15 +706,20 @@ async fn build_admin_dashboard( let wallet_stats = fetch_admin_dashboard_wallet_stats(state, &range).await; warnings.extend(wallet_stats.warnings.iter().cloned()); + let registration_stats = fetch_admin_dashboard_registration_stats(state, &range).await; + warnings.extend(registration_stats.warnings.iter().cloned()); + let tracking_stats = fetch_admin_dashboard_tracking_stats(state, &range).await; warnings.extend(tracking_stats.warnings.iter().cloned()); let generated_assets = asset_stats.generated_assets.total(); let consumed_mud_points = wallet_stats.consumed_mud_points.total(); + let new_registered_users = registration_stats.new_registered_users.total(); let metrics = AdminDashboardMetricsPayload { generated_assets, consumed_mud_points, total_registered_users, + new_registered_users, visit_users: tracking_stats.visit_users, total_visit_users: tracking_stats.total_visit_users, visit_count: tracking_stats.visit_count.total(), @@ -775,11 +788,16 @@ fn resolve_admin_dashboard_range( "day" => AdminDashboardGranularity::Day, "week" => AdminDashboardGranularity::Week, "month" => AdminDashboardGranularity::Month, + "period" => AdminDashboardGranularity::Period, _ => { return Err(AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("dashboard granularity 只支持 day/week/month")); + .with_message("dashboard granularity 只支持 day/week/month/period")); } }; + if matches!(granularity, AdminDashboardGranularity::Period) { + return resolve_admin_dashboard_period_range(query); + } + let anchor_date_key = match query .anchor .as_deref() @@ -805,6 +823,7 @@ fn resolve_admin_dashboard_range( AdminDashboardGranularity::Month => { (dimension.month_start_date_key, dimension.month_end_date_key) } + AdminDashboardGranularity::Period => unreachable!("period range is resolved above"), }; Ok(AdminDashboardRangeResolved { granularity, @@ -814,6 +833,52 @@ fn resolve_admin_dashboard_range( }) } +fn resolve_admin_dashboard_period_range( + query: AdminDashboardQuery, +) -> Result { + let start_date_key = parse_admin_dashboard_date_param( + query.start_date.as_deref(), + "dashboard startDate 必须是合法 YYYY-MM-DD 日期", + )?; + let end_date_key = parse_admin_dashboard_date_param( + query.end_date.as_deref(), + "dashboard endDate 必须是合法 YYYY-MM-DD 日期", + )?; + if start_date_key > end_date_key { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("dashboard startDate 不能晚于 endDate")); + } + if end_date_key - start_date_key + 1 > ADMIN_DASHBOARD_MAX_PERIOD_DAYS { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message(format!( + "dashboard 自定义时段最多支持 {} 天", + ADMIN_DASHBOARD_MAX_PERIOD_DAYS + )), + ); + } + Ok(AdminDashboardRangeResolved { + granularity: AdminDashboardGranularity::Period, + anchor_date_key: end_date_key, + start_date_key, + end_date_key, + }) +} + +fn parse_admin_dashboard_date_param( + value: Option<&str>, + error_message: &str, +) -> Result { + let date = value + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error_message.to_string()) + })?; + module_runtime::parse_analytics_calendar_date_key(date).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error_message.to_string()) + }) +} + fn build_admin_dashboard_range_payload( range: &AdminDashboardRangeResolved, ) -> AdminDashboardRangePayload { @@ -829,6 +894,7 @@ fn build_admin_dashboard_range_payload( let month_key = dimension.month_key; format!("{:04}-{:02}", month_key / 100, month_key % 100) } + AdminDashboardGranularity::Period => format!("{start} 至 {end}"), }; AdminDashboardRangePayload { granularity: range.granularity.as_str().to_string(), @@ -932,6 +998,50 @@ async fn fetch_admin_dashboard_wallet_stats( stats } +async fn fetch_admin_dashboard_registration_stats( + state: &AppState, + range: &AdminDashboardRangeResolved, +) -> AdminDashboardRegistrationStats { + let mut stats = AdminDashboardRegistrationStats::default(); + let sql = format!( + "SELECT created_at FROM profile_dashboard_state LIMIT {}", + ADMIN_DASHBOARD_ROW_LIMIT + ); + let rows = match fetch_admin_dashboard_rows(state, &sql).await { + Ok(rows) => rows, + Err(message) => { + stats + .warnings + .push(format!("新增用户统计读取失败:{message}")); + return stats; + } + }; + warn_if_dashboard_rows_may_be_truncated(&mut stats.warnings, "新增用户", rows.len()); + apply_admin_dashboard_registration_rows(&mut stats, range, rows.iter()); + stats +} + +fn apply_admin_dashboard_registration_rows<'a, I>( + stats: &mut AdminDashboardRegistrationStats, + range: &AdminDashboardRangeResolved, + rows: I, +) where + I: IntoIterator, +{ + for row in rows { + let Some(columns) = row.as_array() else { + continue; + }; + let Some(created_at_micros) = columns.first().and_then(timestamp_value_to_micros) else { + continue; + }; + let day_key = module_runtime::runtime_profile_beijing_day_key(created_at_micros); + if range.contains_day_key(day_key) { + stats.new_registered_users.add(day_key, 1); + } + } +} + async fn fetch_admin_dashboard_tracking_stats( state: &AppState, range: &AdminDashboardRangeResolved, @@ -1197,6 +1307,7 @@ fn admin_dashboard_bucket_label(day_key: i64, granularity: AdminDashboardGranula .next() .map(ToOwned::to_owned) .unwrap_or(date), + AdminDashboardGranularity::Period => date.split('-').skip(1).collect::>().join("/"), } } @@ -1210,6 +1321,7 @@ impl AdminDashboardGranularity { Self::Day => "day", Self::Week => "week", Self::Month => "month", + Self::Period => "period", } } } @@ -2339,7 +2451,8 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess #[cfg(test)] mod tests { use super::{ - AdminDashboardGranularity, apply_admin_database_table_filters, + AdminDashboardGranularity, AdminDashboardRegistrationStats, AdminDashboardSeries, + apply_admin_dashboard_registration_rows, apply_admin_database_table_filters, build_admin_database_table_row, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, is_safe_spacetime_table_name, normalize_debug_path, @@ -2520,6 +2633,7 @@ mod tests { let day = resolve_admin_dashboard_range(AdminDashboardQuery { granularity: Some("day".to_string()), anchor: Some("2026-06-23".to_string()), + ..Default::default() }) .expect("day range should resolve"); assert_eq!(day.granularity, AdminDashboardGranularity::Day); @@ -2532,6 +2646,7 @@ mod tests { let week = resolve_admin_dashboard_range(AdminDashboardQuery { granularity: Some("week".to_string()), anchor: Some("2026-06-23".to_string()), + ..Default::default() }) .expect("week range should resolve"); assert_eq!( @@ -2546,6 +2661,7 @@ mod tests { let month = resolve_admin_dashboard_range(AdminDashboardQuery { granularity: Some("month".to_string()), anchor: Some("2026-06-23".to_string()), + ..Default::default() }) .expect("month range should resolve"); assert_eq!( @@ -2558,6 +2674,54 @@ mod tests { ); } + #[test] + fn resolve_admin_dashboard_range_supports_custom_period() { + let period = resolve_admin_dashboard_range(AdminDashboardQuery { + granularity: Some("period".to_string()), + start_date: Some("2026-06-10".to_string()), + end_date: Some("2026-06-18".to_string()), + ..Default::default() + }) + .expect("period range should resolve"); + + assert_eq!(period.granularity, AdminDashboardGranularity::Period); + assert_eq!( + period.start_date_key, + module_runtime::parse_analytics_calendar_date_key("2026-06-10").unwrap() + ); + assert_eq!( + period.end_date_key, + module_runtime::parse_analytics_calendar_date_key("2026-06-18").unwrap() + ); + assert_eq!(period.anchor_date_key, period.end_date_key); + } + + #[test] + fn apply_admin_dashboard_registration_rows_counts_created_accounts_in_range() { + let range = resolve_admin_dashboard_range(AdminDashboardQuery { + granularity: Some("period".to_string()), + start_date: Some("2026-06-10".to_string()), + end_date: Some("2026-06-18".to_string()), + ..Default::default() + }) + .expect("period range should resolve"); + let rows = vec![ + json!(["2026-06-09T15:59:59Z"]), + json!(["2026-06-10T00:30:00Z"]), + json!(["2026-06-18T15:59:59Z"]), + json!(["2026-06-18T16:00:00Z"]), + json!(["bad-timestamp"]), + ]; + let mut stats = AdminDashboardRegistrationStats { + new_registered_users: AdminDashboardSeries::default(), + warnings: Vec::new(), + }; + + apply_admin_dashboard_registration_rows(&mut stats, &range, rows.iter()); + + assert_eq!(stats.new_registered_users.total(), 2); + } + #[test] fn timestamp_value_to_micros_accepts_sql_shapes() { assert_eq!( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 25b3c27ec..f12dddd89 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -478,6 +478,7 @@ pub struct EditorCanvasPayload { pub struct EditorProjectResourcePayload { resource_id: String, project_id: String, + owner_user_id: String, image_src: String, object_key: Option, asset_object_id: Option, @@ -2480,6 +2481,7 @@ pub(crate) fn editor_project_resource_payload_from_record( EditorProjectResourcePayload { resource_id: record.resource_id, project_id: record.project_id, + owner_user_id: record.owner_user_id, image_src, object_key: record.object_key, asset_object_id: record.asset_object_id, @@ -3530,7 +3532,7 @@ fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool fn build_editor_character_image_prompt(role_setting: &str) -> String { vec![ - "严格基于图1的角色美术视觉规范指导中的美术风格、角色头身比、角色朝向等特征生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), + "按照角色描述生成游戏角色立绘。严格基于图1的角色美术视觉规范的美术风格、角色头身比、角色朝向等特征。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,禁止生成美术视觉规范,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), format!("角色设定:{}", role_setting.trim()), ] .join("\n") @@ -3852,11 +3854,42 @@ mod tests { ); } + #[test] + fn editor_project_resource_payload_includes_owner_user_id() { + let payload = editor_project_resource_payload_from_record(EditorProjectResourceRecord { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-author-1".to_string(), + asset_object_id: None, + image_src: "/generated-character-drafts/editor/spec.png".to_string(), + object_key: None, + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + asset_kind: None, + generation_inputs: None, + public_showcase_enabled: true, + created_at: "2026-06-23T00:00:00.000Z".to_string(), + updated_at: "2026-06-23T00:00:00.000Z".to_string(), + }); + + let value = serde_json::to_value(payload).expect("payload should serialize"); + + assert_eq!(value["ownerUserId"], json!("user-author-1")); + } + #[test] fn editor_payload_media_sanitizer_replaces_inline_layer_src_from_resource() { let resources = vec![EditorProjectResourcePayload { resource_id: "resource-1".to_string(), project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: Some("generated-character-drafts/editor/spec.png".to_string()), asset_object_id: Some("asset-object-1".to_string()), @@ -3928,6 +3961,7 @@ mod tests { let existing_resources = vec![EditorProjectResourcePayload { resource_id: "resource-spec".to_string(), project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: Some("generated-character-drafts/editor/spec.png".to_string()), asset_object_id: Some("asset-object-spec".to_string()), @@ -3995,6 +4029,7 @@ mod tests { let generated_resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "/generated-character-drafts/editor/character.png".to_string(), object_key: Some("generated-character-drafts/editor/character.png".to_string()), asset_object_id: Some("asset-object-character".to_string()), @@ -4452,6 +4487,7 @@ mod tests { let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "data:image/png;base64,character".to_string(), object_key: Some("generated-character-drafts/editor/character.png".to_string()), asset_object_id: Some("asset-object-1".to_string()), @@ -4552,6 +4588,7 @@ mod tests { let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "data:image/png;base64,character".to_string(), object_key: None, asset_object_id: None, @@ -4633,6 +4670,7 @@ mod tests { let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "data:image/png;base64,character".to_string(), object_key: None, asset_object_id: None, @@ -4755,6 +4793,7 @@ mod tests { let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "data:image/png;base64,character".to_string(), object_key: None, asset_object_id: None, diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 4b62fc137..346ee1607 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -157,12 +157,17 @@ pub struct AdminOverviewResponse { pub database: AdminDatabaseOverviewPayload, } -/// 后台 dashboard 查询参数;anchor 使用北京时间日历日期 YYYY-MM-DD。 +/// 后台 dashboard 查询参数;日期均使用北京时间日历日期 YYYY-MM-DD。 #[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AdminDashboardQuery { pub granularity: Option, + /// day / week / month 兼容旧查询口径,表示被选中的日期。 pub anchor: Option, + /// period 自定义时段起始日期,闭区间。 + pub start_date: Option, + /// period 自定义时段终止日期,闭区间。 + pub end_date: Option, } /// 后台 dashboard 返回运营指标、趋势图和读取告警。 @@ -195,6 +200,7 @@ pub struct AdminDashboardMetricsPayload { pub generated_assets: u64, pub consumed_mud_points: u64, pub total_registered_users: u64, + pub new_registered_users: u64, pub visit_users: u64, pub total_visit_users: u64, pub visit_count: u64, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index c17b47969..c2893f781 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -43,6 +43,7 @@ pub struct EditorProjectLayoutSaveAckRecord { pub struct EditorProjectResourceRecord { pub resource_id: String, pub project_id: String, + pub owner_user_id: String, pub asset_object_id: Option, pub image_src: String, pub object_key: Option, @@ -677,6 +678,7 @@ fn map_editor_project_resource_snapshot( Ok(EditorProjectResourceRecord { resource_id: snapshot.resource_id, project_id: snapshot.project_id, + owner_user_id: snapshot.owner_user_id, asset_object_id: snapshot.asset_object_id, image_src: snapshot.image_src, object_key: snapshot.object_key, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs index b1af43048..b74e44b80 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs @@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct EditorProjectResourceSnapshot { pub resource_id: String, pub project_id: String, + pub owner_user_id: String, pub asset_object_id: Option, pub image_src: String, pub object_key: Option, diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 3d8585bab..03b550d26 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -229,6 +229,7 @@ pub struct EditorProjectResourcePublicShowcaseListInput { pub struct EditorProjectResourceSnapshot { pub resource_id: String, pub project_id: String, + pub owner_user_id: String, pub asset_object_id: Option, pub image_src: String, pub object_key: Option, @@ -1956,6 +1957,7 @@ fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResour EditorProjectResourceSnapshot { resource_id: row.resource_id, project_id: row.project_id, + owner_user_id: row.owner_user_id, asset_object_id: row.asset_object_id, image_src: row.image_src, object_key: row.object_key, diff --git a/src/components/creation-home/creationShowcaseModel.test.ts b/src/components/creation-home/creationShowcaseModel.test.ts index 0bfe74cc1..c4aee3a37 100644 --- a/src/components/creation-home/creationShowcaseModel.test.ts +++ b/src/components/creation-home/creationShowcaseModel.test.ts @@ -76,6 +76,23 @@ describe('creationShowcaseModel', () => { expect(items[0]?.label).toBe('公开素材'); }); + it('uses the public project resource owner user id as showcase author fallback', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'characters', + projectResources: [ + createResource({ + resourceId: 'owner-resource', + label: '作者兜底素材', + assetKind: 'character', + ownerUserId: 'user-author-1', + }), + ], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.author).toBe('user-author-1'); + }); + it('deduplicates canvas copies that point back to the same generated resource', () => { const items = buildCreationShowcaseItems({ activeTab: 'characters', diff --git a/src/components/creation-home/creationShowcaseModel.ts b/src/components/creation-home/creationShowcaseModel.ts index 22fc6f608..e7ace3a37 100644 --- a/src/components/creation-home/creationShowcaseModel.ts +++ b/src/components/creation-home/creationShowcaseModel.ts @@ -68,6 +68,7 @@ const SHOWCASE_AUTHOR_KEYS = [ 'ownerLabel', 'ownerName', 'authorDisplayName', + 'ownerUserId', ] as const; export const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [ diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index de5516afc..d15e272a4 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -409,6 +409,7 @@ export function ImageCanvasAssetRowView({ { @@ -419,8 +420,17 @@ export function ImageCanvasAssetRowView({ closeMenu(); }} > - {publicShowcaseEnabled ? '✓' : ''} - 公开展示该素材 + + 公开展示该素材 + + {publicShowcaseEnabled ? ( + + ✓ + + ) : null} {asset.sourceKind === 'uploaded' ? ( event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); @@ -145,10 +146,10 @@ export function ImageCanvasBasicGenerationComposerView({ { expect(props.onToggleContextLayerLock).toHaveBeenCalledTimes(1); expect(props.onFlipContextLayers).toHaveBeenCalledWith('x'); expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); + expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1); + expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1); expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1); }); + it('closes the standalone image menu after opening character animation', () => { + const layer = createLayer({ assetKind: 'character' }); + const props = renderContextMenus({ + imageContextMenu: { layerId: layer.id, x: 20, y: 22 }, + imageContextMenuLayer: layer, + }); + + fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' })); + + expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer); + expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1); + }); + it('renders the image context menu when no canvas context menu is open', () => { const layer = createLayer({ assetKind: 'character' }); const props = renderContextMenus({ diff --git a/src/components/image-editor/ImageCanvasContextMenusView.tsx b/src/components/image-editor/ImageCanvasContextMenusView.tsx index e3b9019c0..46cd1ad49 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.tsx @@ -249,9 +249,11 @@ export function ImageCanvasContextMenusView({ - onOpenCharacterAnimationPanel(imageContextMenuLayer) - } + onClick={() => { + onOpenCharacterAnimationPanel(imageContextMenuLayer); + onCloseContextMenu(); + onCloseImageContextMenu(); + }} > 生成动画 @@ -300,7 +302,10 @@ export function ImageCanvasContextMenusView({ {imageContextMenuLayer.assetKind === 'character' ? ( onOpenCharacterAnimationPanel(imageContextMenuLayer)} + onClick={() => { + onOpenCharacterAnimationPanel(imageContextMenuLayer); + onCloseImageContextMenu(); + }} > 生成动画 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index b8bcdcc38..833aea8e0 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -985,8 +985,8 @@ describe('ImageCanvasEditorView generation integration', () => { clientY: 120, }); fireEvent.click(screen.getByRole('button', { name: '改造' })); - expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: '关闭重绘图片' })).toBeNull(); + expect(screen.getByRole('dialog', { name: '改造图片' })).toBeTruthy(); + expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy(); fireEvent.pointerDown(viewport, { button: 0, @@ -995,7 +995,7 @@ describe('ImageCanvasEditorView generation integration', () => { clientY: 190, }); - expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull(); + expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); }); it('does not render a standalone close button on the generation composer', () => { @@ -2413,6 +2413,148 @@ describe('ImageCanvasEditorView generation integration', () => { expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy(); }); + it('refreshes the asset library after an icon generation project snapshot is applied', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-icon-library-refresh', + title: '图标素材刷新画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-icon-spec-refresh', + resourceId: 'resource-icon-spec-refresh', + title: '清爽按钮图标规范', + src: 'data:image/png;base64,icon-spec-refresh', + x: 240, + y: 80, + width: 160, + height: 120, + originalWidth: 2048, + originalHeight: 1152, + zIndex: 11, + sourceType: 'generated', + assetKind: 'icon-spec', + }, + ], + resources: [], + updatedAt: '2026-06-15T00:00:00.000Z', + }); + loadEditorAssetLibraryMock + .mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [], + }) + .mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createGeneratedAsset({ + assetId: 'asset-icon-spritesheet-refresh', + folderId: 'project', + label: '清爽按钮素材图集', + imageSrc: 'data:image/png;base64,icon-sheet-refresh', + width: 512, + height: 512, + assetKind: 'icon-spritesheet', + }), + ], + }); + const spritesheetResource = createGeneratedResource({ + resourceId: 'resource-icon-spritesheet-refresh', + projectId: 'editor-project-icon-library-refresh', + imageSrc: 'data:image/png;base64,icon-sheet-refresh', + width: 512, + height: 512, + assetKind: 'icon-spritesheet', + }); + generateEditorIconSpritesheetMock.mockResolvedValueOnce({ + spritesheetImageSrc: 'data:image/png;base64,icon-sheet-refresh', + spritesheetWidth: 512, + spritesheetHeight: 512, + iconImageSrcs: [], + prompt: '图标 prompt', + actualPrompt: '图标 prompt', + model: 'gemini-3.1-flash-image-preview', + provider: 'VectorEngine', + taskId: 'icon-library-refresh-task', + priceMudPoints: 3, + spritesheetResource, + spritesheetAsset: createGeneratedAsset({ + assetId: 'asset-icon-spritesheet-refresh', + folderId: 'project', + label: '清爽按钮素材图集', + imageSrc: 'data:image/png;base64,icon-sheet-refresh', + width: 512, + height: 512, + assetKind: 'icon-spritesheet', + }), + project: { + projectId: 'editor-project-icon-library-refresh', + title: '图标素材刷新画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [spritesheetResource], + updatedAt: '2026-06-15T00:01:00.000Z', + }, + }); + render(); + + await screen.findByAltText('画布图片:清爽按钮图标规范'); + await waitFor(() => { + expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(1); + }); + expect( + screen.queryByRole('button', { name: '添加清爽按钮素材图集' }), + ).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); + const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); + fireEvent.click( + within(iconPanel).getByRole('button', { name: '图标规范' }), + ); + fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); + fireEvent.pointerDown( + screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, + { + button: 0, + pointerId: 182, + clientX: 260, + clientY: 100, + }, + ); + fireEvent.change( + within(iconPanel).getByRole('textbox', { name: '素材描述' }), + { + target: { value: '返回按钮' }, + }, + ); + fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(generateEditorIconSpritesheetMock).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2); + }); + expect( + await screen.findByRole('button', { name: '添加清爽按钮素材图集' }), + ).toBeTruthy(); + }); + it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { @@ -3457,12 +3599,12 @@ describe('ImageCanvasEditorView generation integration', () => { expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '改造' })); - const editDialog = screen.getByRole('dialog', { name: '重绘图片' }); + const editDialog = screen.getByRole('dialog', { name: '改造图片' }); expect(editDialog).toBeTruthy(); - const editPrompt = within(editDialog).getByLabelText('重绘提示词'); + const editPrompt = within(editDialog).getByLabelText('改造提示词'); expect(editPrompt.className).toContain('platform-text-field'); expect(editPrompt.className).toContain( - 'image-canvas-editor__quick-edit-prompt', + 'image-canvas-editor__generation-prompt', ); fireEvent.change(editPrompt, { target: { value: '把画面改成黄昏光线' }, @@ -3484,32 +3626,28 @@ describe('ImageCanvasEditorView generation integration', () => { }); await waitFor(() => { - expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); }); - expect(screen.getByAltText(/画布图片:生成图片 .* 重绘/)).toBeTruthy(); + expect(screen.getByAltText(/画布图片:生成图片 .* 快速编辑/)).toBeTruthy(); fireEvent.click( screen.getAllByRole('button', { - name: /查看生成图片 .* 重绘图片信息/u, + name: /查看生成图片 .* 快速编辑图片信息/u, })[0]!, ); const editedMetadataDialog = screen.getByRole('dialog', { name: '图片信息', }); expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull(); - expect(within(editedMetadataDialog).getByText('重绘提示词')).toBeTruthy(); + expect(within(editedMetadataDialog).getByText('快速编辑提示词')).toBeTruthy(); expect( within(editedMetadataDialog).getByText('把画面改成黄昏光线'), ).toBeTruthy(); - expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy(); - expect( - within(editedMetadataDialog).getByText(/^生成图片 \d+$/u), - ).toBeTruthy(); expect( screen.getByRole('button', { name: /当前缩放比例 \d+%/u }), ).toBeTruthy(); }); - it('keeps the redraw panel open after generation starts while keeping the source preview visible', async () => { + it('shows the quick edit generator after redraw starts while keeping the source preview visible', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-edit-generating', title: '重绘生成中画布', @@ -3555,20 +3693,18 @@ describe('ImageCanvasEditorView generation integration', () => { clientY: 180, }); fireEvent.click(screen.getByRole('button', { name: '改造' })); - const editDialog = screen.getByRole('dialog', { name: '重绘图片' }); - fireEvent.change(within(editDialog).getByLabelText('重绘提示词'), { + const editDialog = screen.getByRole('dialog', { name: '改造图片' }); + fireEvent.change(within(editDialog).getByLabelText('改造提示词'), { target: { value: '改成雨夜灯光' }, }); fireEvent.click(within(editDialog).getByRole('button', { name: '生成' })); - expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); + expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy(); expect(screen.getByAltText('画布图片:待重绘')).toBeTruthy(); expect(sourceLayer.className).not.toContain( 'image-canvas-editor__layer--generating', ); - expect( - within(editDialog).getByRole('button', { name: '生成中' }), - ).toBeTruthy(); }); it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e9be6c4f6..c2150ec9e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -13,6 +13,7 @@ import { createEditorAsset, createEditorProjectResource, type EditorAssetSnapshot, + type EditorProjectSnapshot, loadEditorGenerationPricing, } from '../../services/image-editor/editorProjectClient'; import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'; @@ -394,6 +395,7 @@ export function ImageCanvasEditorView() { handleAssetMarqueePointerDown, handleAssetMarqueePointerMove, handleAssetMarqueePointerUp, + refreshAssetLibrary, } = useImageCanvasAssetLibrary({ assetListRef, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, @@ -670,6 +672,13 @@ export function ImageCanvasEditorView() { canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, openEditorLoginModal, }); + const applyGeneratedProjectSnapshot = useCallback( + (project: EditorProjectSnapshot) => { + applyProjectSnapshot(project); + void refreshAssetLibrary(); + }, + [applyProjectSnapshot, refreshAssetLibrary], + ); const persistUpdatedLayerResource = useCallback( (layer: CanvasLayer) => { if (!projectId) { @@ -788,7 +797,7 @@ export function ImageCanvasEditorView() { projectId, assetFolderId: activeUploadFolderId, upsertGeneratedAsset, - applyProjectSnapshot, + applyProjectSnapshot: applyGeneratedProjectSnapshot, onWalletBalanceMayHaveChanged: refreshEditorWalletBalance, }); const { diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 6479edb40..ba822dc14 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; +import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { buildLayerExportMetadata, + buildSpineImageSequenceJson, + buildSpineImageSequenceReadme, dataUrlToBlob, formatExportDate, getImageExtensionFromTypeOrSrc, @@ -11,7 +14,6 @@ import { readLayerImageSequenceFrameBlob, sanitizeExportFilePart, } from './ImageCanvasExportModel'; -import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { DEFAULT_ICON_DESCRIPTIONS, UI_DESIGN_ASSET_EXTRACTION_PROMPT, @@ -169,6 +171,56 @@ describe('ImageCanvasExportModel', () => { }); }); + it('builds a Spine JSON attachment timeline from image sequence frames', () => { + const layer = buildLayer({ + mediaType: 'image-sequence', + originalWidth: 192, + originalHeight: 256, + durationSeconds: 0.5, + }); + const spineJson = buildSpineImageSequenceJson({ + layer, + frames: [ + { frameIndex: 1, fileName: 'frame-01.png', width: 192, height: 256 }, + { frameIndex: 2, fileName: 'frame-02.png', width: 192, height: 256 }, + { frameIndex: 3, fileName: 'frame-03.png', width: 192, height: 256 }, + { frameIndex: 4, fileName: 'frame-04.png', width: 192, height: 256 }, + ], + }); + + expect(spineJson.skeleton).toMatchObject({ + images: './frames/', + fps: 8, + width: 192, + height: 256, + }); + expect(spineJson.bones).toEqual([{ name: 'root' }]); + expect(spineJson.slots[0]).toEqual({ + name: 'character', + bone: 'root', + attachment: 'animation_0001', + }); + expect( + spineJson.skins[0]!.attachments.character.animation_0001, + ).toMatchObject({ + type: 'region', + path: 'frame-01', + y: 128, + width: 192, + height: 256, + }); + expect( + spineJson.animations.animation.slots.character.attachment, + ).toEqual([ + { time: 0, name: 'animation_0001' }, + { time: 0.125, name: 'animation_0002' }, + { time: 0.25, name: 'animation_0003' }, + { time: 0.375, name: 'animation_0004' }, + { time: 0.5, name: 'animation_0001' }, + ]); + expect(buildSpineImageSequenceReadme(layer)).toContain('skeleton.json'); + }); + it('reads private object-key assets through same-origin bytes before exporting', async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url: string) => { diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 320878a48..d1619260d 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -2,8 +2,8 @@ import { readAssetBytes } from '../../services/assetReadUrlService'; import type { CanvasAssetExportMetadata, CanvasGenerationInputField, - CanvasMediaType, CanvasLayer, + CanvasMediaType, } from './ImageCanvasEditorTypes'; import { DEFAULT_ICON_DESCRIPTIONS, @@ -210,6 +210,119 @@ export function getImageSequenceFrameFileName( return `frame-${frameNumber}.${extension}`; } +export type ExportedImageSequenceFrame = { + frameIndex: number; + fileName: string; + width: number; + height: number; +}; + +const DEFAULT_SPINE_VERSION = '4.2.00'; +const SPINE_SEQUENCE_BONE_NAME = 'root'; +const SPINE_SEQUENCE_SLOT_NAME = 'character'; +const SPINE_SEQUENCE_ANIMATION_NAME = 'animation'; + +function stripFileExtension(fileName: string) { + return fileName.replace(/\.[^.]+$/u, ''); +} + +function roundSpineTime(value: number) { + return Number(value.toFixed(6)); +} + +export function buildSpineImageSequenceJson({ + layer, + frames, +}: { + layer: CanvasLayer; + frames: ExportedImageSequenceFrame[]; +}) { + if (!frames.length) { + throw new Error('序列帧为空'); + } + + const durationSeconds = + typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0 + ? layer.durationSeconds + : frames.length; + const frameDuration = durationSeconds / frames.length; + const skeletonWidth = frames[0]?.width || layer.originalWidth; + const skeletonHeight = frames[0]?.height || layer.originalHeight; + const attachments = Object.fromEntries( + frames.map((frame, index) => { + const attachmentName = `${SPINE_SEQUENCE_ANIMATION_NAME}_${String( + index + 1, + ).padStart(4, '0')}`; + return [ + attachmentName, + { + type: 'region', + path: stripFileExtension(frame.fileName), + x: 0, + y: roundSpineTime(frame.height / 2), + width: frame.width, + height: frame.height, + }, + ]; + }), + ); + const timeline = frames.map((_frame, index) => ({ + time: roundSpineTime(index * frameDuration), + name: `${SPINE_SEQUENCE_ANIMATION_NAME}_${String(index + 1).padStart( + 4, + '0', + )}`, + })); + timeline.push({ + time: roundSpineTime(durationSeconds), + name: `${SPINE_SEQUENCE_ANIMATION_NAME}_0001`, + }); + + return { + skeleton: { + spine: DEFAULT_SPINE_VERSION, + images: './frames/', + fps: Math.max(1, Math.round(frames.length / durationSeconds)), + width: skeletonWidth, + height: skeletonHeight, + }, + bones: [{ name: SPINE_SEQUENCE_BONE_NAME }], + slots: [ + { + name: SPINE_SEQUENCE_SLOT_NAME, + bone: SPINE_SEQUENCE_BONE_NAME, + attachment: `${SPINE_SEQUENCE_ANIMATION_NAME}_0001`, + }, + ], + skins: [ + { + name: 'default', + attachments: { + [SPINE_SEQUENCE_SLOT_NAME]: attachments, + }, + }, + ], + animations: { + [SPINE_SEQUENCE_ANIMATION_NAME]: { + slots: { + [SPINE_SEQUENCE_SLOT_NAME]: { + attachment: timeline, + }, + }, + }, + }, + }; +} + +export function buildSpineImageSequenceReadme(layer: CanvasLayer) { + return [ + `素材:${layer.title}`, + '内容:Spine JSON 序列帧导入包', + '导入:在 Spine Editor 中导入 skeleton.json,并保持 frames/ 目录与 skeleton.json 同级。', + '说明:该 JSON 使用单 slot attachment timeline 播放序列帧,不包含拆件、骨骼绑定或 mesh 权重。', + ].join('\n'); +} + export async function blobToUint8Array(blob: Blob) { if (typeof blob.arrayBuffer === 'function') { return new Uint8Array(await blob.arrayBuffer()); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index c0753550c..325509132 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -985,7 +985,8 @@ export function ImageCanvasGenerationComposerView({ ) : null} - {generateDialog?.mode === 'generate' && + {(generateDialog?.mode === 'generate' || + generateDialog?.mode === 'quick-edit') && generateDialog.composerOpen !== false && generationComposerStyle ? ( { }), ), ).toBe('请先登录后再生成图片'); + expect( + resolveImageGenerationErrorMessage( + new ApiClientError({ + message: '请求冲突(requestId: wallet-one)', + status: 409, + code: 'CONFLICT', + details: { provider: 'profile-wallet' }, + }), + ), + ).toBe('泥点余额不足,请充值后再生成'); + expect( + resolveImageGenerationErrorMessage( + new ApiClientError({ + message: '请求冲突(requestId: conflict-one)', + status: 409, + code: 'CONFLICT', + }), + ), + ).toBe('生成任务未完成,请稍后重试'); }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 791920d3a..501c3ec17 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -1304,7 +1304,10 @@ export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) { return 'Image Generator'; } -export function resolveImageGenerationErrorMessage(error: unknown) { +export function resolveImageGenerationErrorMessage( + error: unknown, + fallbackMessage = '生成图片失败', +) { if ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) @@ -1312,7 +1315,22 @@ export function resolveImageGenerationErrorMessage(error: unknown) { return '请先登录后再生成图片'; } + if (error instanceof ApiClientError && error.status === 409) { + const message = error.message.trim(); + if (message && !message.startsWith('请求冲突')) { + return message; + } + const provider = + typeof error.details?.provider === 'string' + ? error.details.provider.trim() + : ''; + if (provider === 'profile-wallet') { + return '泥点余额不足,请充值后再生成'; + } + return '生成任务未完成,请稍后重试'; + } + return error instanceof Error && error.message.trim() ? error.message - : '生成图片失败'; + : fallbackMessage; } diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 3237de2a7..3ba2a483b 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -118,6 +118,61 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); + it('builds quick-edit canvas generator plans with the source layer as the final reference', () => { + const sourceLayer = createLayer({ + objectKey: 'generated-character-drafts/editor/source.png', + assetKind: 'character', + }); + + const plan = buildImageGenerationSubmissionPlan({ + dialog: { + mode: 'quick-edit', + prompt: '把当前图改成雨天', + status: 'idle', + sourceLayerId: sourceLayer.id, + imageModel: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '4:3', + imageSize: '1K', + generationReferences: [ + { + id: 'style-reference', + label: '风格参考', + src: 'data:image/png;base64,style', + }, + ], + }, + layers: [sourceLayer], + nextGeneratedIndex: 4, + }); + + expect(plan).toMatchObject({ + kind: 'quick-edit', + normalizedPrompt: '把图2改成雨天', + sourceLayer, + input: { + prompt: '把图2改成雨天', + size: '1024x768', + kind: 'character', + model: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '4:3', + imageSize: '1K', + referenceImageSrcs: [ + 'data:image/png;base64,style', + 'generated-character-drafts/editor/source.png', + ], + }, + result: { + title: '源图 快速编辑', + assetKind: 'character', + generationInputs: { + fields: [{ title: '快速编辑提示词', value: '把图2改成雨天' }], + references: [], + }, + }, + rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2, + }); + }); + it('builds spec generation plans with reference prompt semantics', () => { const plan = buildImageGenerationSubmissionPlan({ dialog: { diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index c2f3d60ef..b9dcc7df0 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -21,6 +21,7 @@ import { buildPublicationMaterialsGenerationInputs, buildPublicationMaterialsGenerationPrompt, buildPublicationMaterialsPrompt, + buildQuickEditGenerationInputs, buildSoundEffectGenerationInputs, buildSpecGenerationInputs, buildSpecPrompt, @@ -38,8 +39,12 @@ import { DEFAULT_VIDEO_WEB_SEARCH_ENABLED, ICON_DESCRIPTION_LIMIT, IMAGE_MODEL_GPT_IMAGE_2, + inferEditorImageAspectRatio, + inferEditorImageSizeLabel, normalizeEditorImageModel, + normalizeQuickEditPromptSourceReference, resolveCharacterAnimationSourceImageSrc, + resolveEditorImageGenerationPixelSize, SEEDANCE_VIDEO_REFERENCE_LIMITS, SPEC_GENERATION_ASPECT_RATIO, SPEC_GENERATION_IMAGE_SIZE, @@ -157,6 +162,18 @@ export type ImageGenerationSubmissionPlan = }; rememberImageModel?: string; } + | { + kind: 'quick-edit'; + normalizedPrompt: string; + sourceLayer: CanvasLayer; + input: EditorImageGenerationInput; + result: { + title: string; + assetKind?: CanvasLayer['assetKind']; + generationInputs: CanvasGenerationInputs; + }; + rememberImageModel?: string; + } | { kind: 'video'; normalizedPrompt: string; @@ -232,6 +249,70 @@ export function buildImageGenerationSubmissionPlan({ }; } + if (dialog.mode === 'quick-edit' && dialog.sourceLayerId) { + const sourceLayer = layers.find( + (layer) => layer.id === dialog.sourceLayerId, + ); + if (!sourceLayer) { + throw new Error('未找到要改造的图片'); + } + const quickEditReferences = dialog.generationReferences ?? []; + const sourceReferenceIndex = quickEditReferences.length + 1; + const basePrompt = dialog.prompt.trim() || '快速编辑图片'; + const normalizedQuickEditPrompt = normalizeQuickEditPromptSourceReference( + basePrompt, + sourceReferenceIndex, + ); + const imageModel = normalizeEditorImageModel(dialog.imageModel); + const aspectRatio = + dialog.aspectRatio ?? + inferEditorImageAspectRatio( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ); + const imageSize = + dialog.imageSize ?? + inferEditorImageSizeLabel( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ); + const outputSize = resolveEditorImageGenerationPixelSize({ + model: imageModel, + aspectRatio, + imageSize, + }); + return { + kind: 'quick-edit', + normalizedPrompt: normalizedQuickEditPrompt, + sourceLayer, + input: { + prompt: normalizedQuickEditPrompt, + size: `${outputSize.width}x${outputSize.height}`, + kind: sourceLayer.assetKind === 'character' ? 'character' : 'quick-edit', + model: imageModel, + aspectRatio, + imageSize, + referenceImageSrcs: [ + ...quickEditReferences.map((reference) => + resolveImageReferenceSubmissionSource(reference), + ), + sourceLayer.objectKey?.trim() || sourceLayer.src, + ], + }, + result: { + title: `${sourceLayer.title} 快速编辑`, + assetKind: sourceLayer.assetKind, + generationInputs: buildQuickEditGenerationInputs( + '快速编辑提示词', + normalizedQuickEditPrompt, + sourceLayer, + quickEditReferences, + ), + }, + rememberImageModel: imageModel, + }; + } + if (dialog.mode === 'spec') { const specType = dialog.specType ?? 'custom'; const specValues = dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType]; diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 1f08c69cf..74c681bfd 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -1,6 +1,13 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { @@ -1091,6 +1098,81 @@ describe('ImageCanvasWorldView', () => { ).toBeTruthy(); }); + it('keeps the last loaded sequence frame visible while the next frame is resolving', () => { + vi.useFakeTimers(); + try { + useResolvedAssetReadUrlMock.mockImplementation( + (source: string) => ({ + resolvedUrl: + source === '/generated-character-drafts/editor/frame01.png' + ? 'https://oss.example.com/frame01.png?signature=1' + : '', + isResolving: + source === '/generated-character-drafts/editor/frame02.png', + shouldResolve: true, + }), + ); + const layer = createLayer({ + title: '角色动作', + src: '/generated-character-drafts/editor/frame01.png', + mediaType: 'image-sequence', + assetKind: 'character-animation', + thumbnailSrc: '/generated-character-drafts/editor/frame01.png', + imageSequenceFrames: [ + { + frameIndex: 1, + imageSrc: '/generated-character-drafts/editor/frame01.png', + objectKey: 'generated-character-drafts/editor/frame01.png', + width: 1024, + height: 1024, + }, + { + frameIndex: 2, + imageSrc: '/generated-character-drafts/editor/frame02.png', + objectKey: 'generated-character-drafts/editor/frame02.png', + width: 1024, + height: 1024, + }, + ], + durationSeconds: 0.1, + }); + + renderWorldView({ layers: [layer] }); + + const layerButton = screen.getByRole('button', { name: '选择角色动作' }); + const firstFrame = within(layerButton).getByAltText( + '画布序列帧:角色动作', + ); + + fireEvent.load(firstFrame); + expect( + layerButton.querySelector('.image-canvas-editor__layer-image-loading'), + ).toBeNull(); + + act(() => { + vi.advanceTimersByTime(60); + }); + + expect(within(layerButton).getByText('2/2')).toBeTruthy(); + expect( + within(layerButton) + .getByAltText('画布序列帧:角色动作') + .getAttribute('src'), + ).toBe('https://oss.example.com/frame01.png?signature=1'); + expect( + layerButton.querySelector('.image-canvas-editor__layer-image-loading'), + ).toBeNull(); + expect(useResolvedAssetReadUrlMock).toHaveBeenCalledWith( + '/generated-character-drafts/editor/frame02.png', + expect.objectContaining({ + objectKey: 'generated-character-drafts/editor/frame02.png', + }), + ); + } finally { + vi.useRealTimers(); + } + }); + it('keeps saved generator placeholders hidden after a result layer exists', () => { renderWorldView({ layers: [createLayer({ id: 'layer-generated', title: '生成图片' })], diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index a58457986..c21917eb4 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -15,7 +15,7 @@ import { Volume2, VolumeX, } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, @@ -700,6 +700,78 @@ function getLayerImageSequenceFrames(layer: CanvasLayer) { : []; } +function getImageSequenceFrameKey( + layer: CanvasLayer, + frame: ReturnType[number], + index: number, +) { + return [ + layer.id, + layer.taskId ?? layer.resourceId ?? '', + frame.frameIndex, + frame.objectKey ?? '', + frame.imageSrc, + index, + ].join(':'); +} + +function ImageCanvasImageSequenceFrame({ + layer, + frame, + frameKey, + isVisible, + isLoaded, + mediaTransform, + onFrameReady, +}: { + layer: CanvasLayer; + frame: ReturnType[number]; + frameKey: string; + isVisible: boolean; + isLoaded: boolean; + mediaTransform?: string; + onFrameReady: (frameKey: string) => void; +}) { + const { resolvedUrl } = useResolvedAssetReadUrl(frame.imageSrc, { + objectKey: frame.objectKey, + refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`, + }); + const frameImageRef = useRef(null); + + useEffect(() => { + if (resolvedUrl && isImageAlreadyDecoded(frameImageRef.current)) { + onFrameReady(frameKey); + } + }, [frameKey, onFrameReady, resolvedUrl]); + + if (!resolvedUrl) { + return null; + } + + return ( + onFrameReady(frameKey)} + onError={() => onFrameReady(frameKey)} + /> + ); +} + function ImageCanvasImageSequenceLayer({ layer, mediaTransform, @@ -708,26 +780,57 @@ function ImageCanvasImageSequenceLayer({ mediaTransform?: string; }) { const frames = getLayerImageSequenceFrames(layer); + const frameItems = frames.map((frame, index) => ({ + frame, + index, + key: getImageSequenceFrameKey(layer, frame, index), + })); + const frameSequenceKey = frameItems.map((item) => item.key).join('|'); + const firstFrameKey = frameItems[0]?.key ?? ''; const [frameIndex, setFrameIndex] = useState(0); const [isPlaying, setIsPlaying] = useState(frames.length > 1); - const currentFrame = frames[Math.min(frameIndex, frames.length - 1)]; - const hasFrameSource = Boolean(currentFrame?.imageSrc?.trim()); - const { resolvedUrl } = useResolvedAssetReadUrl(currentFrame?.imageSrc, { - refreshKey: `${layer.taskId ?? layer.resourceId}:${ - currentFrame?.frameIndex ?? frameIndex + 1 - }`, - }); - const frameImageRef = useRef(null); - const [isFrameLoaded, setIsFrameLoaded] = useState(false); + const currentFrameItem = + frameItems[Math.min(frameIndex, frameItems.length - 1)]; + const currentFrameKey = currentFrameItem?.key ?? ''; + const [loadedFrameKeys, setLoadedFrameKeys] = useState>( + () => new Set(), + ); + const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey); + const visibleFrameLoaded = Boolean( + visibleFrameKey && loadedFrameKeys.has(visibleFrameKey), + ); + const hasFrameSource = frameItems.some((item) => item.frame.imageSrc.trim()); + + const handleFrameReady = useCallback((readyFrameKey: string) => { + setLoadedFrameKeys((currentKeys) => { + if (currentKeys.has(readyFrameKey)) { + return currentKeys; + } + const nextKeys = new Set(currentKeys); + nextKeys.add(readyFrameKey); + return nextKeys; + }); + }, []); useEffect(() => { setFrameIndex(0); setIsPlaying(frames.length > 1); - }, [layer.taskId, layer.resourceId, frames.length]); + setLoadedFrameKeys(new Set()); + setVisibleFrameKey(firstFrameKey); + }, [firstFrameKey, frameSequenceKey, frames.length]); useEffect(() => { - setIsFrameLoaded(isImageAlreadyDecoded(frameImageRef.current)); - }, [resolvedUrl]); + if (!currentFrameKey) { + setVisibleFrameKey(''); + return; + } + if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) { + return; + } + setVisibleFrameKey((currentKey) => + currentKey === currentFrameKey ? currentKey : currentFrameKey, + ); + }, [currentFrameKey, loadedFrameKeys, visibleFrameKey]); useEffect(() => { if (!isPlaying || frames.length <= 1) { @@ -749,28 +852,24 @@ function ImageCanvasImageSequenceLayer({ return ( <> - {hasFrameSource && (!resolvedUrl || !isFrameLoaded) ? ( + {hasFrameSource && !visibleFrameLoaded ? ( ) : null} - {resolvedUrl ? ( - setIsFrameLoaded(true)} - onError={() => setIsFrameLoaded(true)} + {frameItems.map((item) => ( + - ) : null} + ))} event.stopPropagation()} @@ -794,7 +893,9 @@ function ImageCanvasImageSequenceLayer({ )} - {frames.length ? frameIndex + 1 : 0}/{frames.length || 0} + {`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${ + frames.length || 0 + }`} > diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index 41325c9d0..081f85abc 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -224,6 +224,15 @@ describe('useImageCanvasAssetExportWorkflow', () => { expect( zip.file('导出项目-画布素材/sequences/005-角色动作/manifest.txt'), ).toBeTruthy(); + expect( + zip.file('导出项目-画布素材/sequences/005-角色动作/skeleton.json'), + ).toBeTruthy(); + expect( + await readZipText( + zip, + '导出项目-画布素材/sequences/005-角色动作/README.md', + ), + ).toContain('Spine JSON'); expect(zip.file('导出项目-画布素材/images/006-失败图.png')).toBeNull(); const metadata = JSON.parse( @@ -277,6 +286,23 @@ describe('useImageCanvasAssetExportWorkflow', () => { expect(sequenceMetadata.taskId).toBeUndefined(); expect(sequenceMetadata.previewVideoPath).toBeUndefined(); expect(sequenceMetadata.failedFrames).toEqual([]); + const sequenceSpineJson = JSON.parse( + await readZipText( + zip, + '导出项目-画布素材/sequences/005-角色动作/skeleton.json', + ), + ); + expect(sequenceSpineJson.skeleton.images).toBe('./frames/'); + expect( + sequenceSpineJson.skins[0].attachments.character.animation_0001.path, + ).toBe('frame-01'); + expect( + sequenceSpineJson.animations.animation.slots.character.attachment, + ).toEqual([ + { time: 0, name: 'animation_0001' }, + { time: 2, name: 'animation_0002' }, + { time: 4, name: 'animation_0001' }, + ]); expect(metadata.layers[6].file).toBeNull(); expect(metadata.layers[6].exportError).toContain('404'); expect(metadata.failedImages).toHaveLength(1); @@ -455,11 +481,28 @@ describe('useImageCanvasAssetExportWorkflow', () => { await waitFor(() => { expect(exportedBlob).toBeTruthy(); }); - expect(downloadName).toBe('角色动作 跑步-序列帧.zip'); + expect(downloadName).toBe('角色动作 跑步-SpineJSON.zip'); const zip = await JSZip.loadAsync(exportedBlob!); expect(zip.file('frames/frame-01.png')).toBeTruthy(); expect(zip.file('frames/frame-02.png')).toBeTruthy(); - expect(await readZipText(zip, 'manifest.txt')).toContain('类型:序列帧'); + expect(await readZipText(zip, 'manifest.txt')).toContain( + 'Spine JSON:skeleton.json', + ); + expect(await readZipText(zip, 'README.md')).toContain('Spine JSON'); + const spineJson = JSON.parse(await readZipText(zip, 'skeleton.json')); + expect(spineJson.skeleton).toMatchObject({ + images: './frames/', + width: 1024, + height: 1024, + }); + expect(spineJson.skins[0].attachments.character.animation_0001).toEqual({ + type: 'region', + path: 'frame-01', + x: 0, + y: 512, + width: 1024, + height: 1024, + }); const metadata = JSON.parse(await readZipText(zip, 'metadata.json')); expect(metadata.title).toBe('角色动作/跑步'); expect(metadata.visible.type).toBe('角色动作序列帧'); diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index 533089caa..dcf9ba463 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -10,6 +10,9 @@ import { blobToUint8Array, buildLayerExportMetadata, buildLayerVisibleExportMetadata, + buildSpineImageSequenceJson, + buildSpineImageSequenceReadme, + type ExportedImageSequenceFrame, formatExportDate, getImageSequenceFrameFileName, getLayerAssetExtensionFromTypeOrSrc, @@ -60,15 +63,20 @@ async function buildImageSequenceZip(layer: CanvasLayer) { frameIndex: number; error: string; }> = []; + const exportedFrames: ExportedImageSequenceFrame[] = []; let successCount = 0; for (const [index, frame] of frames.entries()) { try { const blob = await readLayerImageSequenceFrameBlob(layer, frame); - framesFolder.file( - getImageSequenceFrameFileName(frame, index, blob.type), - await blobToUint8Array(blob), - ); + const fileName = getImageSequenceFrameFileName(frame, index, blob.type); + framesFolder.file(fileName, await blobToUint8Array(blob)); + exportedFrames.push({ + frameIndex: frame.frameIndex || index + 1, + fileName, + width: frame.width, + height: frame.height, + }); successCount += 1; } catch (error) { failedFrames.push({ @@ -96,12 +104,22 @@ async function buildImageSequenceZip(layer: CanvasLayer) { 2, ), ); + zip.file( + 'skeleton.json', + JSON.stringify( + buildSpineImageSequenceJson({ layer, frames: exportedFrames }), + null, + 2, + ), + ); + zip.file('README.md', buildSpineImageSequenceReadme(layer)); zip.file( 'manifest.txt', [ `素材:${layer.title}`, `类型:序列帧`, `帧数:${successCount}/${frames.length}`, + 'Spine JSON:skeleton.json', layer.durationSeconds ? `时长:${layer.durationSeconds}s` : null, failedFrames.length ? `失败帧数:${failedFrames.length}` : null, ] @@ -129,7 +147,7 @@ export function useImageCanvasAssetExportWorkflow({ .then((zipBlob) => { const downloaded = triggerBrowserDownload( zipBlob, - `${sanitizeExportFilePart(layer.title, '角色动作')}-序列帧.zip`, + `${sanitizeExportFilePart(layer.title, '角色动作')}-SpineJSON.zip`, ); if (!downloaded) { setAssetExportStatus({ @@ -231,6 +249,7 @@ export function useImageCanvasAssetExportWorkflow({ frameIndex: number; error: string; }> = []; + const exportedFrames: ExportedImageSequenceFrame[] = []; let successCount = 0; for (const [frameArrayIndex, frame] of frames.entries()) { @@ -239,14 +258,18 @@ export function useImageCanvasAssetExportWorkflow({ layer, frame, ); - framesFolder.file( - getImageSequenceFrameFileName( - frame, - frameArrayIndex, - frameBlob.type, - ), - await blobToUint8Array(frameBlob), + const fileName = getImageSequenceFrameFileName( + frame, + frameArrayIndex, + frameBlob.type, ); + framesFolder.file(fileName, await blobToUint8Array(frameBlob)); + exportedFrames.push({ + frameIndex: frame.frameIndex || frameArrayIndex + 1, + fileName, + width: frame.width, + height: frame.height, + }); successCount += 1; } catch (error) { failedFrames.push({ @@ -275,12 +298,25 @@ export function useImageCanvasAssetExportWorkflow({ 2, ), ); + sequenceFolder.file( + 'skeleton.json', + JSON.stringify( + buildSpineImageSequenceJson({ layer, frames: exportedFrames }), + null, + 2, + ), + ); + sequenceFolder.file( + 'README.md', + buildSpineImageSequenceReadme(layer), + ); sequenceFolder.file( 'manifest.txt', [ `素材:${layer.title}`, `类型:序列帧`, `帧数:${successCount}/${frames.length}`, + 'Spine JSON:skeleton.json', layer.durationSeconds ? `时长:${layer.durationSeconds}s` : null, diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index 29b76d57b..f4b677275 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -154,6 +154,47 @@ export function useImageCanvasAssetLibrary({ selectedAssetIds, ); + const applyAssetLibrarySnapshot = useCallback( + ( + library: Awaited>, + options: { preferDefaultUploadFolder?: boolean } = {}, + ) => { + const nextLibrary = normalizeAssetLibrary(library); + setAssetFolders(nextLibrary.folders); + setAssets(nextLibrary.assets); + const defaultFolder = + nextLibrary.folders.find((folder) => folder.systemDefault) ?? + nextLibrary.folders[0]; + setActiveUploadFolderId((currentFolderId) => { + if ( + !options.preferDefaultUploadFolder && + nextLibrary.folders.some((folder) => folder.id === currentFolderId) + ) { + return currentFolderId; + } + return defaultFolder?.id ?? 'project'; + }); + }, + [], + ); + + const refreshAssetLibrary = useCallback(async () => { + if (!canAccessProtectedData) { + return; + } + try { + applyAssetLibrarySnapshot(await loadEditorAssetLibrary()); + } catch (error: unknown) { + if (isEditorAuthError(error)) { + openEditorLoginModal(); + } + } + }, [ + applyAssetLibrarySnapshot, + canAccessProtectedData, + openEditorLoginModal, + ]); + useEffect(() => { if (!canAccessProtectedData) { return undefined; @@ -164,15 +205,7 @@ export function useImageCanvasAssetLibrary({ if (cancelled) { return; } - const nextLibrary = normalizeAssetLibrary(library); - setAssetFolders(nextLibrary.folders); - setAssets(nextLibrary.assets); - const defaultFolder = nextLibrary.folders.find( - (folder) => folder.systemDefault, - ); - setActiveUploadFolderId( - defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project', - ); + applyAssetLibrarySnapshot(library, { preferDefaultUploadFolder: true }); }) .catch((error: unknown) => { if (!cancelled && isEditorAuthError(error)) { @@ -182,7 +215,7 @@ export function useImageCanvasAssetLibrary({ return () => { cancelled = true; }; - }, [canAccessProtectedData, openEditorLoginModal]); + }, [applyAssetLibrarySnapshot, canAccessProtectedData, openEditorLoginModal]); const resolveAssetFolderId = useCallback( (clientX: number, clientY: number) => { @@ -603,6 +636,7 @@ export function useImageCanvasAssetLibrary({ handleAssetMarqueePointerDown, handleAssetMarqueePointerMove, handleAssetMarqueePointerUp, + refreshAssetLibrary, }; } diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 6db08db9e..4e268ff0a 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -10,6 +10,7 @@ import { import { useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiClientError } from '../../services/apiClient'; import type { CanvasGenerationDialogState, CanvasLayer, @@ -490,6 +491,59 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); + it('submits quick-edit canvas generators with the source image reference', async () => { + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '把图1改成雨天' }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '把图1改成雨天', + kind: 'quick-edit', + model: 'gpt-image-2', + aspectRatio: '4:3', + imageSize: '1K', + referenceImageSrcs: ['https://assets.example.test/source.png'], + sourceResourceId: 'resource-source', + assetLabel: '源图 快速编辑', + }), + ); + }); + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-quick-edit-1:源图 快速编辑:resource-source', + ); + }); + expect(screen.getByTestId('quick-edit').textContent).toBe('-'); + }); + it('refreshes the wallet balance after inline image generation succeeds', async () => { const refreshWalletBalance = vi.fn(); generateEditorImageMock.mockResolvedValueOnce( @@ -1852,6 +1906,32 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); }); + it('does not resubmit character animation while it is already generating', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); + + expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled(); + }); + it('creates an action layer for dialog-driven character animation submissions', async () => { generateEditorCharacterAnimationMock.mockResolvedValueOnce({ taskId: 'animation-task-dialog', @@ -1938,6 +2018,41 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); + it('shows a readable conflict message for failed character animation submissions', async () => { + generateEditorCharacterAnimationMock.mockRejectedValueOnce( + new ApiClientError({ + message: '请求冲突(requestId: wallet-one)', + status: 409, + code: 'CONFLICT', + details: { provider: 'profile-wallet' }, + }), + ); + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); + }); + + await waitFor(() => { + expect(screen.getByTestId('character-animation').textContent).toBe( + 'character-layer:failed:跑步:泥点余额不足,请充值后再生成:-', + ); + }); + }); + it('uses a fallback error message for failed character animation submissions', async () => { generateEditorCharacterAnimationMock.mockRejectedValueOnce(new Error('')); render( diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index cf3e14023..c9d5e97ba 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -1366,6 +1366,62 @@ export function useImageCanvasGenerationSubmissionWorkflow({ sourceLayer: submissionPlan.sourceLayer, generationInputs: submissionPlan.generationInputs, }); + } else if (submissionPlan.kind === 'quick-edit') { + const imageGenerationInput = + await normalizeImageGenerationReferenceImages(submissionPlan.input); + const canvasCompletionPlaceholder = + getGeneratingDialogPlaceholder(dialog); + const generated = await runEditorGenerationWithWalletRefresh( + generateEditorImage({ + ...imageGenerationInput, + projectId, + assetKind: submissionPlan.result.assetKind, + generationInputs: submissionPlan.result.generationInputs, + assetFolderId, + assetLabel: submissionPlan.result.title, + sourceResourceId: submissionPlan.sourceLayer.resourceId, + ...(projectId && canvasCompletionPlaceholder + ? { + canvasCompletion: { + dialogId: canvasDialog?.id, + title: submissionPlan.result.title, + placeholder: canvasCompletionPlaceholder, + }, + } + : {}), + }), + onWalletBalanceMayHaveChanged, + ); + if ( + await applyQueuedEditorGenerationProject( + generated, + projectId, + applyProjectSnapshot, + onWalletBalanceMayHaveChanged, + ) + ) { + return; + } + if (submissionPlan.rememberImageModel) { + rememberImageModel(submissionPlan.rememberImageModel); + } + if (generated.project && applyProjectSnapshot) { + applyProjectSnapshot(generated.project); + if (generated.asset) { + upsertGeneratedAsset?.(generated.asset); + } + return; + } + addQuickEditResultLayer( + generated, + submissionPlan.sourceLayer, + submissionPlan.result.generationInputs, + 'quick-edit', + { + frame: canvasCompletionPlaceholder, + dialogId: canvasDialog?.id, + }, + ); } else if (submissionPlan.kind === 'video') { const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); @@ -1576,6 +1632,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({ if (!characterAnimationPanel || !characterAnimationSourceLayer) { return; } + if ( + characterAnimationPanel.status === 'generating' || + (characterAnimationDialog?.mode === 'character-animation' && + characterAnimationDialog.status === 'generating') + ) { + return; + } const submissionPlan = buildCharacterAnimationSubmissionPlan({ panel: characterAnimationPanel, sourceLayer: characterAnimationSourceLayer, @@ -1681,10 +1744,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ characterAnimationPanel.durationSeconds, status: 'failed', composerOpen: true, - errorMessage: - error instanceof Error && error.message.trim() - ? error.message - : '生成角色动画失败', + errorMessage: resolveImageGenerationErrorMessage( + error, + '生成角色动画失败', + ), })); return; } @@ -1693,10 +1756,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ? { ...currentPanel, status: 'failed', - errorMessage: - error instanceof Error && error.message.trim() - ? error.message - : '生成角色动画失败', + errorMessage: resolveImageGenerationErrorMessage( + error, + '生成角色动画失败', + ), } : currentPanel, ); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index bf94a17ee..74d69df72 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -181,6 +181,11 @@ function GenerationWorkflowHarness({ : '-'} {activeDialog?.prompt || '-'} + + {activeDialog + ? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}` + : '-'} + {dialogs.canvasGenerationDialogs .map( @@ -400,6 +405,22 @@ function GenerationWorkflowHarness({ > 填写生成提示词 + + dialogs.setGenerateDialog((currentDialog) => + currentDialog + ? { + ...currentDialog, + aspectRatio: '2:3', + imageSize: '2K', + } + : currentDialog, + ) + } + > + 选择2比3和2K + { @@ -761,8 +782,12 @@ describe('useImageCanvasGenerationWorkflow', () => { fireEvent.click(screen.getByRole('button', { name: '打开用户快照改造' })); - expect(screen.getByTestId('quick-edit').textContent).toBe( - 'layer-internal-prompt:idle:用户的视频描述', + expect(screen.getByTestId('quick-edit').textContent).toBe('-'); + expect(screen.getByTestId('dialog').textContent).toBe( + 'quick-edit:idle:open:-:placeholder', + ); + expect(screen.getByTestId('dialog-prompt').textContent).toBe( + '用户的视频描述', ); }); @@ -781,18 +806,19 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); - it('keeps the active canvas generator when opening redraw for another layer', () => { + it('opens image redraw as a new canvas generator next to the source layer', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); - expect(screen.getByTestId('dialog').textContent).toBe('-'); - expect(screen.getByTestId('quick-edit').textContent).toBe( - 'layer-source:idle:-', + expect(screen.getByTestId('dialog').textContent).toBe( + 'quick-edit:idle:open:-:placeholder', ); + expect(screen.getByTestId('quick-edit').textContent).toBe('-'); + expect(screen.getByTestId('placeholder').textContent).toContain('1024:768'); expect(screen.getByTestId('generation-dialogs').textContent).toBe( - 'generation-dialog-1:generate:closed', + 'generation-dialog-1:generate:closed|generation-dialog-2:quick-edit:open', ); }); @@ -885,6 +911,41 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('reuses the last image ratio and size when reopening character generation and submitting', async () => { + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '一张生成图' }), + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择2比3和2K' })); + await waitFor(() => { + expect(screen.getByTestId('dialog-image-options').textContent).toBe( + 'character:gemini-3.1-flash-image-preview:2:3:2K', + ); + }); + + fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); + expect(screen.getByTestId('dialog-image-options').textContent).toBe( + 'character:gemini-3.1-flash-image-preview:2:3:2K', + ); + + fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' })); + fireEvent.click(screen.getByRole('button', { name: '提交生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '一张生成图', + kind: 'character', + model: 'gemini-3.1-flash-image-preview', + aspectRatio: '2:3', + imageSize: '2K', + }), + ); + }); + }); + it('adds picked canvas layers as normal generation references and submits them', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '参考生成图' }), diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 69c2192af..5836af849 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -49,6 +49,7 @@ import { createGenerateDialogDraft, createIconGenerationDialogDraft, createPublicationGenerationDialogDraft, + createQuickEditGenerationDialogDraft, createQuickEditPanelDraft, createRedrawPanelDraft, createSoundEffectGenerationDialogDraft, @@ -68,9 +69,13 @@ import { CHARACTER_ANIMATION_MODEL, DEFAULT_ICON_DESCRIPTIONS, DEFAULT_IMAGE_MODEL, + EDITOR_IMAGE_DIMENSION_OPTIONS, ICON_DESCRIPTION_LIMIT, IMAGE_MODEL_GPT_IMAGE_2, isCanvasGenerationDialog, + normalizeEditorImageModel, + resizeGenerationPlaceholderToImageSelection, + resolveEditorImageGenerationPixelSize, } from './ImageCanvasGenerationModel'; import { centerViewportOnPlacement, @@ -109,6 +114,12 @@ type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null; +type RememberedImageGenerationOptions = { + model: string; + aspectRatio: string; + imageSize: string; +}; + const LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY = 'genarrative.imageCanvas.lastCharacterSpecReference'; const LAST_ICON_SPEC_REFERENCE_CACHE_KEY = @@ -119,6 +130,56 @@ const INVALID_CHARACTER_SPEC_WARNING = const INVALID_ICON_SPEC_WARNING = '选择的图片不是图标规范图,请选择生成规范里的图标规范。'; +function normalizeRememberedImageGenerationOptions({ + model, + aspectRatio, + imageSize, +}: Partial): RememberedImageGenerationOptions { + const normalizedModel = normalizeEditorImageModel(model); + const dimensionOptions = + EDITOR_IMAGE_DIMENSION_OPTIONS[ + normalizedModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS + ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL]; + const aspectRatios = dimensionOptions.aspectRatios as readonly string[]; + const imageSizes = dimensionOptions.imageSizes as readonly string[]; + + return { + model: normalizedModel, + aspectRatio: + aspectRatio && aspectRatios.includes(aspectRatio) + ? aspectRatio + : (dimensionOptions.aspectRatios[0] ?? '1:1'), + imageSize: + imageSize && imageSizes.includes(imageSize) + ? imageSize + : (dimensionOptions.imageSizes.find((size) => size === '1K') ?? + dimensionOptions.imageSizes[0] ?? + '1K'), + }; +} + +function applyRememberedImageGenerationOptions( + draft: Omit, + options: RememberedImageGenerationOptions, +): Omit { + return resizeGenerationPlaceholderToImageSelection({ + ...draft, + ...normalizeRememberedImageGenerationOptions(options), + }) as Omit; +} + +function shouldRememberImageGenerationOptions( + dialog: GenerateDialogState | null, +): dialog is GenerateDialogState & { + mode: 'generate' | 'character' | 'icon'; +} { + return ( + dialog?.mode === 'generate' || + dialog?.mode === 'character' || + dialog?.mode === 'icon' + ); +} + function appendQuickEditSelectionPrompt(prompt: string, markNumber: number) { const nextLine = `对${markNumber}号红色圈选框里的内容做以下修改:`; const trimmedPrompt = prompt.trimEnd(); @@ -417,7 +478,10 @@ export function useImageCanvasGenerationWorkflow({ const quickEditSelectionMarkCounterRef = useRef(0); const [characterAnimationPanel, setCharacterAnimationPanel] = useState(null); - const [lastImageModel, setLastImageModel] = useState(DEFAULT_IMAGE_MODEL); + const [rememberedImageOptions, setRememberedImageOptions] = + useState(() => + normalizeRememberedImageGenerationOptions({ model: DEFAULT_IMAGE_MODEL }), + ); const [initialCharacterSpecReference] = useState(() => readCachedGenerationReference(LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY), @@ -492,6 +556,29 @@ export function useImageCanvasGenerationWorkflow({ } }, [generateDialog]); + useEffect(() => { + if (!shouldRememberImageGenerationOptions(generateDialog)) { + return; + } + const nextOptions = normalizeRememberedImageGenerationOptions({ + model: generateDialog.imageModel, + aspectRatio: generateDialog.aspectRatio, + imageSize: generateDialog.imageSize, + }); + setRememberedImageOptions((currentOptions) => + currentOptions.model === nextOptions.model && + currentOptions.aspectRatio === nextOptions.aspectRatio && + currentOptions.imageSize === nextOptions.imageSize + ? currentOptions + : nextOptions, + ); + }, [ + generateDialog?.aspectRatio, + generateDialog?.imageModel, + generateDialog?.imageSize, + generateDialog?.mode, + ]); + useEffect(() => { if (!referencePickWarning) { return undefined; @@ -618,13 +705,17 @@ export function useImageCanvasGenerationWorkflow({ const openGenerateDialog = useCallback(() => { openPlacedCanvasGenerationDialog( - createGenerateDialogDraft({ canvasSize, viewport }), + applyRememberedImageGenerationOptions( + createGenerateDialogDraft({ canvasSize, viewport }), + rememberedImageOptions, + ), ); activateCanvasGenerationEntry('generate'); }, [ activateCanvasGenerationEntry, canvasSize, openPlacedCanvasGenerationDialog, + rememberedImageOptions, viewport, ]); @@ -665,11 +756,14 @@ export function useImageCanvasGenerationWorkflow({ ); const openCharacterGenerationDialog = useCallback(() => { - const draft = createCharacterGenerationDialogDraft({ - canvasSize, - viewport, - imageModel: lastImageModel, - }); + const draft = applyRememberedImageGenerationOptions( + createCharacterGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: rememberedImageOptions.model, + }), + rememberedImageOptions, + ); openPlacedCanvasGenerationDialog({ ...draft, characterSpecReference: @@ -679,17 +773,20 @@ export function useImageCanvasGenerationWorkflow({ }, [ activateCanvasGenerationEntry, canvasSize, - lastImageModel, openPlacedCanvasGenerationDialog, + rememberedImageOptions, viewport, ]); const openIconGenerationDialog = useCallback(() => { - const draft = createIconGenerationDialogDraft({ - canvasSize, - viewport, - imageModel: lastImageModel, - }); + const draft = applyRememberedImageGenerationOptions( + createIconGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: rememberedImageOptions.model, + }), + rememberedImageOptions, + ); openPlacedCanvasGenerationDialog({ ...draft, iconSpecReference: @@ -699,8 +796,8 @@ export function useImageCanvasGenerationWorkflow({ }, [ activateCanvasGenerationEntry, canvasSize, - lastImageModel, openPlacedCanvasGenerationDialog, + rememberedImageOptions, viewport, ]); @@ -876,15 +973,43 @@ export function useImageCanvasGenerationWorkflow({ setActiveTool('music'); return; } - archiveActiveCanvasGenerationDialog(); + if ( + sourceLayer.mediaType === undefined || + sourceLayer.mediaType === 'image' + ) { + const redrawPanel = createRedrawPanelDraft(sourceLayer); + const redrawSize = resolveEditorImageGenerationPixelSize({ + model: redrawPanel.model, + aspectRatio: redrawPanel.aspectRatio, + imageSize: redrawPanel.imageSize, + }); + openPlacedCanvasGenerationDialog({ + ...createQuickEditGenerationDialogDraft({ + sourceLayer, + prompt: redrawPanel.prompt, + model: redrawPanel.model, + aspectRatio: redrawPanel.aspectRatio, + imageSize: redrawPanel.imageSize, + frame: { + width: redrawSize.width, + height: redrawSize.height, + }, + }), + composerOpen: true, + }); + setQuickEditPanel(null); + selectSingleLayer(null); + setActiveTool('generate'); + return; + } setGenerateDialog(null); setQuickEditPanel(createRedrawPanelDraft(sourceLayer)); selectSingleLayer(sourceLayer.id); setActiveTool('generate'); }, [ - archiveActiveCanvasGenerationDialog, openCanvasGenerationDialog, + openPlacedCanvasGenerationDialog, selectSingleLayer, setActiveTool, setGenerateDialog, @@ -1238,6 +1363,15 @@ export function useImageCanvasGenerationWorkflow({ [setGenerateDialog], ); + const rememberImageModel = useCallback((model: string) => { + setRememberedImageOptions((currentOptions) => + normalizeRememberedImageGenerationOptions({ + ...currentOptions, + model, + }), + ); + }, []); + const generationSubmissionWorkflow = useImageCanvasGenerationSubmissionWorkflow({ layers, @@ -1263,7 +1397,7 @@ export function useImageCanvasGenerationWorkflow({ fitLayers, setActiveTool, setActiveSidebarPanel, - rememberImageModel: setLastImageModel, + rememberImageModel, projectId, assetFolderId, upsertGeneratedAsset, @@ -1742,7 +1876,7 @@ export function useImageCanvasGenerationWorkflow({ updateSpecFormValue, updateIconDescriptionsText, updateCharacterAnimationDuration, - rememberImageModel: setLastImageModel, + rememberImageModel, submitCharacterAnimation, hideGeneratedLayerPanelAfterBlur, closeGenerateComposer, @@ -1824,6 +1958,7 @@ export function useImageCanvasGenerationWorkflow({ updateCharacterAnimationDuration, updateIconDescriptionsText, updateSpecFormValue, + rememberImageModel, ], ); } diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index 99ee9ebd9..131610bdc 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -332,6 +332,7 @@ describe('useImageCanvasLayerCommands', () => { expect.objectContaining({ id: 'third' }), ); expect(screen.getByTestId('context').textContent).toBe('closed'); + expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); }); it('deletes selected and direct layers while running delete side effects', () => { diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index 47aac3c18..d16c1e741 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -410,7 +410,14 @@ export function useImageCanvasLayerCommands({ const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); exportLayerImage(targetLayer ?? null); setContextMenu(null); - }, [exportLayerImage, getContextTargetLayerIds, layers, setContextMenu]); + setImageContextMenu(null); + }, [ + exportLayerImage, + getContextTargetLayerIds, + layers, + setContextMenu, + setImageContextMenu, + ]); const deleteLayerById = useCallback( (targetLayerId: string | null) => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index f5148ec59..7ac2c3853 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1142,14 +1142,19 @@ function renderStatefulLoggedOutHomeView( > > = {}, isDesktopLayout = false, + options: { + initialActiveTab?: RpgEntryHomeViewProps['activeTab']; + allowHiddenActiveTab?: boolean; + } = {}, ) { const authSpies = { openLoginModal: vi.fn(), }; function StatefulLoggedOutHomeView() { - const [activeTab, setActiveTab] = - useState('category'); + const [activeTab, setActiveTab] = useState< + RpgEntryHomeViewProps['activeTab'] + >(options.initialActiveTab ?? 'category'); return ( { expect(openLoginModal).toHaveBeenCalledTimes(1); }); -test('logged out bottom nav turns active recommend tab into next action', () => { - const { container } = renderLoggedOutHomeView(vi.fn()); +test('logged out mobile bottom nav only shows profile tab', () => { + const { container } = renderLoggedOutHomeView(vi.fn(), {}, 'profile'); const nav = container.querySelector('.platform-bottom-nav'); expect(nav).toBeTruthy(); expect(nav?.classList.contains('platform-bottom-nav')).toBe(true); + expect(nav?.classList.contains('grid-cols-1')).toBe(true); const buttons = within(nav as HTMLElement).getAllByRole('button'); - expect(buttons.map((button) => button.textContent)).toEqual([ - '下一个', - '发现', - '我的', - ]); - expect(buttons[0]?.querySelector('.lucide-chevron-down')).toBeTruthy(); - expect(buttons[1]?.querySelector('.lucide-compass')).toBeTruthy(); - expect(buttons[2]?.querySelector('.lucide-user-round')).toBeTruthy(); + expect(buttons.map((button) => button.textContent)).toEqual(['我的']); + expect(buttons[0]?.querySelector('.lucide-user-round')).toBeTruthy(); + expect( + within(nav as HTMLElement).queryByRole('button', { name: '推荐' }), + ).toBeNull(); + expect( + within(nav as HTMLElement).queryByRole('button', { name: '发现' }), + ).toBeNull(); expect( within(nav as HTMLElement).queryByRole('button', { name: '创作' }), ).toBeNull(); @@ -3809,24 +3816,24 @@ test('logged out bottom nav turns active recommend tab into next action', () => ).toBeTruthy(); }); -test('logged in mobile bottom nav hides draft and project entries', () => { +test('logged in mobile bottom nav only shows profile tab', () => { const { container } = renderLoggedInHomeView({ + activeTab: 'profile', hasUnreadDraftUpdate: true, draftTabContent: 草稿内容, }); const nav = container.querySelector('.platform-bottom-nav'); expect(nav).toBeTruthy(); + expect(nav?.classList.contains('grid-cols-1')).toBe(true); + const buttons = within(nav as HTMLElement).getAllByRole('button'); + expect(buttons.map((button) => button.textContent)).toEqual(['我的']); expect( - within(nav as HTMLElement).getByRole('button', { name: '推荐' }), - ).toBeTruthy(); + within(nav as HTMLElement).queryByRole('button', { name: '推荐' }), + ).toBeNull(); expect( - within(nav as HTMLElement).getByRole('button', { name: '发现' }), - ).toBeTruthy(); - expect( - within(nav as HTMLElement).getByRole('button', { name: '我的' }), - ).toBeTruthy(); - + within(nav as HTMLElement).queryByRole('button', { name: '发现' }), + ).toBeNull(); expect( within(nav as HTMLElement).queryByRole('button', { name: '草稿,有新草稿', @@ -4106,7 +4113,6 @@ test('mobile discover search submits public work code', async () => { const onSearchPublicCode = vi.fn(); renderStatefulLoggedOutHomeView({ onSearchPublicCode }); - await user.click(screen.getByRole('button', { name: '发现' })); const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); @@ -4145,7 +4151,6 @@ test('discover search fuzzy matches public work id, name, author and description onOpenGalleryDetail, onSearchPublicCode, }); - await user.click(screen.getByRole('button', { name: '发现' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { throw new Error('缺少发现面板'); @@ -4212,7 +4217,6 @@ test('mobile discover keeps edutainment works in the last dedicated channel only onOpenChildMotionDemo, onSearchPublicCode, }); - await user.click(screen.getByRole('button', { name: '发现' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { throw new Error('缺少发现面板'); @@ -4285,10 +4289,12 @@ test('desktop discover shows child motion demo in edutainment channel', async () const user = userEvent.setup(); const onOpenChildMotionDemo = vi.fn(); - renderStatefulLoggedOutHomeView({ - onOpenChildMotionDemo, - }); - await user.click(screen.getByRole('button', { name: '发现' })); + renderStatefulLoggedOutHomeView( + { + onOpenChildMotionDemo, + }, + true, + ); await user.click(screen.getByRole('tab', { name: '寓教于乐' })); const warmupButton = screen.getByRole('button', { name: /热身关卡/u }); @@ -4316,7 +4322,6 @@ test('mobile discover hides edutainment channel and work when switch is disabled latestEntries: [edutainmentEntry], onSearchPublicCode, }); - await user.click(screen.getByRole('button', { name: '发现' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { throw new Error('缺少发现面板'); @@ -4353,7 +4358,6 @@ test('mobile discover keeps baby object match works in edutainment channel only' onOpenGalleryDetail, onSearchPublicCode, }); - await user.click(screen.getByRole('button', { name: '发现' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { throw new Error('缺少发现面板'); @@ -4388,7 +4392,6 @@ test('discover search keeps public code fallback when local works do not match', latestEntries: [puzzlePublicEntry], onSearchPublicCode, }); - await user.click(screen.getByRole('button', { name: '发现' })); const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述'); @@ -4406,7 +4409,6 @@ test('public gallery cards hide work code until detail is opened', async () => { latestEntries: [puzzlePublicEntry], onOpenGalleryDetail, }); - await user.click(screen.getByRole('button', { name: '发现' })); expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); expect( @@ -4436,26 +4438,34 @@ test('public gallery cards hide phone masked author and public user code', async }, true, ); - await user.click(screen.getByRole('button', { name: '发现' })); const card = screen.getByRole('button', { name: /喜气洋洋/u }); expect(card).toBeTruthy(); - expect(within(card).getByText('公开作者')).toBeTruthy(); + expect(await within(card).findByText('公开作者')).toBeTruthy(); expect(within(card).queryByText('158****3533 · SY-00000003')).toBeNull(); expect(within(card).queryByText('158****3533')).toBeNull(); expect(within(card).queryByText('SY-00000003')).toBeNull(); }); -test('logged out mobile shell defaults to discover tab', () => { - const { container } = renderStatefulLoggedOutHomeView({ - latestEntries: [puzzlePublicEntry], - }); +test('logged out mobile shell defaults to profile tab when active tab is hidden', async () => { + const { container } = renderStatefulLoggedOutHomeView( + { + latestEntries: [puzzlePublicEntry], + }, + false, + { + allowHiddenActiveTab: false, + }, + ); - const activePanel = container.querySelector('.platform-tab-panel--active'); - expect(activePanel?.id).toBe('platform-tab-panel-category'); + await waitFor(() => { + expect(container.querySelector('.platform-tab-panel--active')?.id).toBe( + 'platform-tab-panel-profile', + ); + }); expect( - screen.getByPlaceholderText('搜索作品号、名称、作者、描述'), - ).toBeTruthy(); + screen.queryByPlaceholderText('搜索作品号、名称、作者、描述'), + ).toBeNull(); expect(container.querySelector('.platform-mobile-topbar')).toBeTruthy(); expect( container.querySelector('.platform-mobile-entry-shell--recommend'), @@ -4463,18 +4473,15 @@ test('logged out mobile shell defaults to discover tab', () => { }); test('logged out recommend tab opens embedded runtime without login modal', async () => { - const user = userEvent.setup(); - const { container, openLoginModal } = renderStatefulLoggedOutHomeView({ - latestEntries: [puzzlePublicEntry], - activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', - }); - const bottomNav = container.querySelector('.platform-bottom-nav'); - if (!bottomNav) { - throw new Error('缺少底部导航'); - } - - await user.click( - within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), + const { container, openLoginModal } = renderStatefulLoggedOutHomeView( + { + latestEntries: [puzzlePublicEntry], + activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', + }, + false, + { + initialActiveTab: 'home', + }, ); expect(openLoginModal).not.toHaveBeenCalled(); @@ -4489,20 +4496,17 @@ test('logged out recommend tab opens embedded runtime without login modal', asyn }); test('logged out recommend runtime keeps detail callback idle', async () => { - const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); - const { openLoginModal } = renderStatefulLoggedOutHomeView({ - latestEntries: [puzzlePublicEntry], - activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', - onOpenGalleryDetail, - }); - const bottomNav = document.querySelector('.platform-bottom-nav'); - if (!bottomNav) { - throw new Error('缺少底部导航'); - } - - await user.click( - within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), + const { openLoginModal } = renderStatefulLoggedOutHomeView( + { + latestEntries: [puzzlePublicEntry], + activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', + onOpenGalleryDetail, + }, + false, + { + initialActiveTab: 'home', + }, ); expect(openLoginModal).not.toHaveBeenCalled(); @@ -4596,9 +4600,8 @@ test('mobile recommend meta matches active wooden fish runtime entry', () => { expect(within(meta).getByText('莲台木鱼')).toBeTruthy(); }); -test('logged out desktop recommend rail enters runtime without login modal', async () => { +test('logged out desktop rail hides recommend entry', () => { mockDesktopLayout(); - const user = userEvent.setup(); const openLoginModal = vi.fn(); const { container } = renderLoggedOutHomeView( @@ -4616,10 +4619,11 @@ test('logged out desktop recommend rail enters runtime without login modal', asy throw new Error('缺少桌面侧边栏'); } - await user.click( - within(desktopRail as HTMLElement).getByRole('button', { name: '推荐' }), - ); - + expect( + within(desktopRail as HTMLElement).queryByRole('button', { + name: '推荐', + }), + ).toBeNull(); expect(openLoginModal).not.toHaveBeenCalled(); expect(screen.queryByTestId('recommend-runtime')).toBeNull(); expect(container.querySelector('.platform-desktop-shell')).toBeTruthy(); @@ -5379,8 +5383,7 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc vi.useRealTimers(); }); -test('logged out active recommend bottom tab selects next work without login', async () => { - const user = userEvent.setup(); +test('logged out active recommend bottom nav hides next action', () => { const onSelectNextRecommendEntry = vi.fn(); const openLoginModal = vi.fn(); @@ -5390,9 +5393,8 @@ test('logged out active recommend bottom tab selects next work without login', a onSelectNextRecommendEntry, }); - await user.click(screen.getByRole('button', { name: '下一个' })); - - expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: '下一个' })).toBeNull(); + expect(onSelectNextRecommendEntry).not.toHaveBeenCalled(); expect(openLoginModal).not.toHaveBeenCalled(); }); @@ -5487,9 +5489,6 @@ test('mobile discover recommend feed only rotates the card closest to screen cen renderStatefulLoggedOutHomeView({ latestEntries: [firstEntry, secondEntry], }); - act(() => { - screen.getByRole('button', { name: '发现' }).click(); - }); const tabPanel = document.querySelector('.platform-tab-panel--active'); const firstCard = screen.getByRole('button', { name: /中心拼图一/u }); @@ -5662,7 +5661,6 @@ test('mobile today channel only shows newly published works from today', async ( latestEntries: [yesterdayEntry, updatedTodayEntry, todayEntry], }); - await user.click(screen.getByRole('button', { name: '发现' })); await user.click(screen.getByRole('tab', { name: '今日' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { @@ -5816,7 +5814,6 @@ test('mobile home moves category shelf into game category channel', async () => expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull(); expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull(); - await user.click(screen.getByRole('button', { name: '发现' })); await user.click(screen.getByRole('tab', { name: '分类' })); expect(screen.getAllByText('分类').length).toBeGreaterThan(0); @@ -5842,7 +5839,6 @@ test('mobile game category list orders works by composite public metric', async latestEntries: [puzzlePublicEntry, hotRankEntry], }); - await user.click(screen.getByRole('button', { name: '发现' })); await user.click(screen.getByRole('tab', { name: '分类' })); await user.click(screen.getByRole('button', { name: '奇幻' })); @@ -5874,7 +5870,6 @@ test('mobile game category filter dialog filters by play type', async () => { latestEntries: [puzzlePublicEntry, match3dEntry], }); - await user.click(screen.getByRole('button', { name: '发现' })); await user.click(screen.getByRole('tab', { name: '分类' })); await user.click(screen.getByRole('button', { name: '奇幻' })); @@ -5909,7 +5904,6 @@ test('mobile game category keeps filter controls when current filter becomes emp latestEntries: [puzzlePublicEntry], }); - await user.click(screen.getByRole('button', { name: '发现' })); await user.click(screen.getByRole('tab', { name: '分类' })); await user.click(screen.getByRole('button', { name: '奇幻' })); await user.click(screen.getByRole('button', { name: /筛选/u })); @@ -5955,7 +5949,6 @@ test('bottom category tab becomes ranking and switches ranking metrics', async ( latestEntries: [remixRankEntry, hotRankEntry, newRankEntry], }); - await user.click(screen.getByRole('button', { name: '发现' })); await user.click(screen.getByRole('tab', { name: '排行' })); expect(await screen.findByRole('tab', { name: '热门榜' })).toBeTruthy(); @@ -5989,7 +5982,6 @@ test('ranking rows limit displayed work name and show two short tags on the thir latestEntries: [longTextRankEntry], }); - await user.click(screen.getByRole('button', { name: '发现' })); await user.click(screen.getByRole('tab', { name: '排行' })); const rankingPanel = document.getElementById('platform-tab-panel-category'); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 15cca86d1..dce9c1f00 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -2899,13 +2899,7 @@ export function RpgEntryHomeView({ hasManualCategoryTagSelectionRef.current = true; setSelectedCategoryTag(tag); }, []); - const visibleMobileTabs = useMemo( - () => - isAuthenticated - ? ['home', 'category', 'profile'] - : ['home', 'category', 'profile'], - [isAuthenticated], - ); + const visibleMobileTabs = useMemo(() => ['profile'], []); const visibleDesktopNavTabs = useMemo( () => (isAuthenticated @@ -2962,12 +2956,11 @@ export function RpgEntryHomeView({ return; } if (!visibleMobileTabs.includes(activeTab) && !isDesktopLayout) { - onTabChange(isAuthenticated ? 'home' : 'category'); + onTabChange('profile'); } }, [ activeTab, allowHiddenActiveTab, - isAuthenticated, isDesktopLayout, onTabChange, visibleMobileTabs, @@ -5115,7 +5108,7 @@ export function RpgEntryHomeView({ {visibleMobileTabs.map((tab) => ( { afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it('passes through base64 image data urls without fetching', async () => { @@ -43,4 +45,32 @@ describe('editorImageReference', () => { ), ).resolves.toBe('data:image/png;base64,large-reference'); }); + + it('falls back to the original generation reference when browser image decoding stalls', async () => { + vi.useFakeTimers(); + vi.stubGlobal('document', { + createElement: vi.fn(), + } as unknown as Document); + vi.stubGlobal( + 'Image', + class PendingImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + set src(_value: string) { + void _value; + } + } as unknown as typeof Image, + ); + + const result = compressEditorImageReferenceDataUrlForGeneration( + 'data:image/png;base64,pending-reference', + ); + + vi.advanceTimersByTime(251); + + await expect(result).resolves.toBe( + 'data:image/png;base64,pending-reference', + ); + }); }); diff --git a/src/services/image-editor/editorImageReference.ts b/src/services/image-editor/editorImageReference.ts index ce23faa35..37d1c6619 100644 --- a/src/services/image-editor/editorImageReference.ts +++ b/src/services/image-editor/editorImageReference.ts @@ -3,6 +3,7 @@ import { readAssetBytes } from '../assetReadUrlService'; const GENERATION_REFERENCE_MAX_EDGE = 1280; const GENERATION_REFERENCE_QUALITY = 0.82; const GENERATION_REFERENCE_MIME_TYPE = 'image/jpeg'; +const GENERATION_REFERENCE_IMAGE_LOAD_TIMEOUT_MS = 250; type EditorImageReferenceCompressionOptions = { maxEdge?: number; @@ -69,8 +70,19 @@ function loadImageDataUrl(dataUrl: string) { } const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => reject(new Error('参考图读取失败')); + const timeoutId = globalThis.setTimeout(() => { + image.onload = null; + image.onerror = null; + reject(new Error('参考图读取超时')); + }, GENERATION_REFERENCE_IMAGE_LOAD_TIMEOUT_MS); + const finish = (callback: () => void) => { + globalThis.clearTimeout(timeoutId); + image.onload = null; + image.onerror = null; + callback(); + }; + image.onload = () => finish(() => resolve(image)); + image.onerror = () => finish(() => reject(new Error('参考图读取失败'))); image.src = dataUrl; }); } diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 449a9b34a..75e00ed44 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -220,6 +220,7 @@ describe('editorProjectClient', () => { { resourceId: 'resource-public-1', projectId: 'editor-project-1', + ownerUserId: 'user-public-author', label: '公开角色素材', imageSrc: '/generated-editor-assets/public.png', width: 512, @@ -234,6 +235,7 @@ describe('editorProjectClient', () => { expect(resources).toHaveLength(1); expect(resources[0]?.resourceId).toBe('resource-public-1'); + expect(resources[0]?.ownerUserId).toBe('user-public-author'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/showcase/resources', { method: 'GET' }, diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index b0ce14151..d3aa441b4 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -59,6 +59,7 @@ export type EditorProjectResourceSourceType = export type EditorProjectResourceSnapshot = { resourceId: string; projectId: string; + ownerUserId?: string | null; imageSrc: string; objectKey?: string | null; assetObjectId?: string | null; diff --git a/tools/spine-json-export-validator/README.md b/tools/spine-json-export-validator/README.md new file mode 100644 index 000000000..c1bf82c4b --- /dev/null +++ b/tools/spine-json-export-validator/README.md @@ -0,0 +1,19 @@ +# Spine JSON 导出验证器 + +这是图片画布序列帧导出包的独立验证项目。它只读取导出的 ZIP,在浏览器里用 `@esotericsoftware/spine-webgl` 解析 `skeleton.json`,加载同级 `frames/` 图片并渲染预览。 + +## 命令 + +```bash +npm --prefix tools/spine-json-export-validator install +npm run spine-export-validator:dev +npm run spine-export-validator:typecheck +npm run spine-export-validator:build +``` + +验证器支持两类导出包: + +- 单个角色动作导出的 `*-SpineJSON.zip`,根目录包含 `skeleton.json` 和 `frames/`。 +- 画布素材 ZIP 中的 `sequences/<编号-标题>/skeleton.json`。 + +当前只验证 region attachment 和 slot attachment timeline,对 mesh、path、boundingbox、clipping 等 Spine 高级 attachment 会报错。 diff --git a/tools/spine-json-export-validator/index.html b/tools/spine-json-export-validator/index.html new file mode 100644 index 000000000..5b588494e --- /dev/null +++ b/tools/spine-json-export-validator/index.html @@ -0,0 +1,48 @@ + + + + + + + Spine JSON 导出验证 + + + + + + Spine JSON 导出验证 + 导入画布导出的 ZIP,检查 skeleton.json 与 frames 渲染结果。 + + + 选择 ZIP + + + + + + + + 拖入 ZIP 开始验证 + + + + + + + + + diff --git a/tools/spine-json-export-validator/package-lock.json b/tools/spine-json-export-validator/package-lock.json new file mode 100644 index 000000000..372be76ec --- /dev/null +++ b/tools/spine-json-export-validator/package-lock.json @@ -0,0 +1,1237 @@ +{ + "name": "@genarrative/spine-json-export-validator", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@genarrative/spine-json-export-validator", + "version": "0.1.0", + "dependencies": { + "@esotericsoftware/spine-webgl": "4.3.8", + "jszip": "^3.10.1" + }, + "devDependencies": { + "typescript": "~5.8.2", + "vite": "^6.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esotericsoftware/spine-core": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@esotericsoftware/spine-core/-/spine-core-4.3.8.tgz", + "integrity": "sha512-0eqpIequTVuLAuhFZ1/ztuKHFpKjtSeT9IJRFL8MVpOdG44oSwQ6Ukm4aYbyITx+4SqJp88aHQY850oyQw2qvg==", + "license": "LicenseRef-LICENSE" + }, + "node_modules/@esotericsoftware/spine-webgl": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@esotericsoftware/spine-webgl/-/spine-webgl-4.3.8.tgz", + "integrity": "sha512-mdemFNDZghs0xswbLgyQc9dUsjwEsSTF6F+FzdFgI9vXpRjUAdQCBUEylCwNvLLD71EFGRq5oInQYbftlKgK9A==", + "license": "LicenseRef-LICENSE", + "dependencies": { + "@esotericsoftware/spine-core": "4.3.8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/tools/spine-json-export-validator/package.json b/tools/spine-json-export-validator/package.json new file mode 100644 index 000000000..b5dd817f2 --- /dev/null +++ b/tools/spine-json-export-validator/package.json @@ -0,0 +1,19 @@ +{ + "name": "@genarrative/spine-json-export-validator", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@esotericsoftware/spine-webgl": "4.3.8", + "jszip": "^3.10.1" + }, + "devDependencies": { + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/tools/spine-json-export-validator/src/main.ts b/tools/spine-json-export-validator/src/main.ts new file mode 100644 index 000000000..d82a624a6 --- /dev/null +++ b/tools/spine-json-export-validator/src/main.ts @@ -0,0 +1,724 @@ +import JSZip from 'jszip'; +import type { JSZipObject } from 'jszip'; +import { + AnimationState, + AnimationStateData, + GLTexture, + ManagedWebGLRenderingContext, + Physics, + RegionAttachment, + ResizeMode, + SceneRenderer, + Skeleton, + SkeletonJson, + TextureRegion, +} from '@esotericsoftware/spine-webgl'; +import type { + AttachmentLoader, + BoundingBoxAttachment, + ClippingAttachment, + MeshAttachment, + PathAttachment, + PointAttachment, + Sequence, + Skin, +} from '@esotericsoftware/spine-webgl'; + +import './style.css'; + +const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg']; +const SKELETON_FILE_NAME = 'skeleton.json'; + +type Tone = 'info' | 'success' | 'error'; + +type JsonRecord = Record; + +type SkeletonCandidate = { + path: string; + baseDir: string; + json: JsonRecord; +}; + +type RegionExportInfo = { + paths: string[]; + attachmentCount: number; + unsupportedTypes: string[]; +}; + +type LoadedFrame = { + zipPath: string; + image: HTMLImageElement | ImageBitmap; + width: number; + height: number; +}; + +type ActiveSkeleton = { + skeleton: Skeleton; + state: AnimationState; + textures: GLTexture[]; + frames: LoadedFrame[]; +}; + +const zipInput = queryElement('#zipInput'); +const dropZone = queryElement('#dropZone'); +const previewCanvas = queryElement('#previewCanvas'); +const emptyState = queryElement('#emptyState'); +const statusBox = queryElement('#statusBox'); +const skeletonSelect = queryElement('#skeletonSelect'); +const summaryList = queryElement('#summaryList'); +const logList = queryElement('#logList'); + +let activeZip: JSZip | null = null; +let activeCandidates: SkeletonCandidate[] = []; +let activePreview: SpinePreview | null = null; + +function queryElement(selector: string) { + const element = document.querySelector(selector); + if (!element) { + throw new Error(`Missing element: ${selector}`); + } + return element; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeZipPath(path: string) { + const parts: string[] = []; + for (const part of path.replace(/\\/gu, '/').split('/')) { + if (!part || part === '.') { + continue; + } + if (part === '..') { + parts.pop(); + continue; + } + parts.push(part); + } + return parts.join('/'); +} + +function joinZipPath(...parts: string[]) { + return normalizeZipPath(parts.join('/')); +} + +function getBaseDir(path: string) { + const normalized = normalizeZipPath(path); + const slashIndex = normalized.lastIndexOf('/'); + return slashIndex >= 0 ? normalized.slice(0, slashIndex) : ''; +} + +function stripExtension(path: string) { + return path.replace(/\.[^.\\/]+$/u, ''); +} + +function hasImageExtension(path: string) { + const extension = path.split('.').pop()?.toLowerCase(); + return extension ? IMAGE_EXTENSIONS.includes(extension) : false; +} + +function toFrameKey(path: string) { + return stripExtension(normalizeZipPath(path)).toLowerCase(); +} + +function setStatus(tone: Tone, message: string) { + statusBox.dataset.tone = tone; + statusBox.textContent = message; +} + +function clearLogs() { + logList.replaceChildren(); +} + +function addLog(message: string, tone: Tone = 'info') { + const item = document.createElement('li'); + item.dataset.tone = tone; + item.textContent = message; + logList.prepend(item); +} + +function renderSummary(rows: Array<[string, string]>) { + const fragment = document.createDocumentFragment(); + for (const [label, value] of rows) { + const term = document.createElement('dt'); + term.textContent = label; + const description = document.createElement('dd'); + description.textContent = value; + fragment.append(term, description); + } + summaryList.replaceChildren(fragment); +} + +function getSkeletonImagesPath(json: JsonRecord) { + const skeleton = isRecord(json.skeleton) ? json.skeleton : null; + return typeof skeleton?.images === 'string' ? skeleton.images : './'; +} + +function collectRegionExportInfo(json: JsonRecord): RegionExportInfo { + const paths = new Set(); + const unsupportedTypes = new Set(); + let attachmentCount = 0; + const skins = Array.isArray(json.skins) ? json.skins : []; + + for (const skin of skins) { + if (!isRecord(skin) || !isRecord(skin.attachments)) { + continue; + } + for (const slotAttachments of Object.values(skin.attachments)) { + if (!isRecord(slotAttachments)) { + continue; + } + for (const [attachmentName, attachmentValue] of Object.entries( + slotAttachments, + )) { + if (!isRecord(attachmentValue)) { + continue; + } + attachmentCount += 1; + const attachmentType = + typeof attachmentValue.type === 'string' + ? attachmentValue.type + : 'region'; + if (attachmentType !== 'region') { + unsupportedTypes.add(attachmentType); + continue; + } + const path = + typeof attachmentValue.path === 'string' + ? attachmentValue.path + : attachmentName; + paths.add(path); + } + } + } + + return { + paths: [...paths], + attachmentCount, + unsupportedTypes: [...unsupportedTypes], + }; +} + +function collectAnimationNames(json: JsonRecord) { + return isRecord(json.animations) ? Object.keys(json.animations) : []; +} + +function resolveImageEntry({ + zip, + imageRoot, + attachmentPath, +}: { + zip: JSZip; + imageRoot: string; + attachmentPath: string; +}) { + const normalizedRoot = normalizeZipPath(imageRoot); + const normalizedAttachment = normalizeZipPath(attachmentPath); + const directCandidates = hasImageExtension(normalizedAttachment) + ? [joinZipPath(normalizedRoot, normalizedAttachment)] + : IMAGE_EXTENSIONS.map((extension) => + joinZipPath(normalizedRoot, `${normalizedAttachment}.${extension}`), + ); + const filesByLowerPath = new Map(); + + zip.forEach((path, entry) => { + if (!entry.dir) { + filesByLowerPath.set(normalizeZipPath(path).toLowerCase(), entry); + } + }); + + for (const candidate of directCandidates) { + const entry = filesByLowerPath.get(candidate.toLowerCase()); + if (entry) { + return entry; + } + } + + const rootPrefix = normalizedRoot ? `${normalizedRoot}/` : ''; + const expectedKey = toFrameKey(normalizedAttachment); + let fallback: JSZipObject | null = null; + zip.forEach((path, entry) => { + if (fallback || entry.dir) { + return; + } + const normalizedPath = normalizeZipPath(path); + if (!normalizedPath.startsWith(rootPrefix)) { + return; + } + const relativePath = normalizedPath.slice(rootPrefix.length); + if (toFrameKey(relativePath) === expectedKey) { + fallback = entry; + } + }); + return fallback; +} + +async function loadImageFromZipEntry(entry: JSZipObject): Promise { + const blob = await entry.async('blob'); + if (typeof createImageBitmap === 'function') { + try { + const bitmap = await createImageBitmap(blob); + return { + zipPath: normalizeZipPath(entry.name), + image: bitmap, + width: bitmap.width, + height: bitmap.height, + }; + } catch { + // Some browsers reject SVG or unusual PNG chunks here; HTMLImageElement is the safer fallback. + } + } + + const image = new Image(); + const objectUrl = URL.createObjectURL(blob); + try { + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error(`图片解码失败:${entry.name}`)); + image.src = objectUrl; + }); + } finally { + URL.revokeObjectURL(objectUrl); + } + + return { + zipPath: normalizeZipPath(entry.name), + image, + width: image.naturalWidth || image.width, + height: image.naturalHeight || image.height, + }; +} + +async function loadFramesForCandidate(zip: JSZip, candidate: SkeletonCandidate) { + const exportInfo = collectRegionExportInfo(candidate.json); + const imageRoot = joinZipPath( + candidate.baseDir, + getSkeletonImagesPath(candidate.json), + ); + const frames = new Map(); + const missingFrames: string[] = []; + + for (const attachmentPath of exportInfo.paths) { + const frameKey = toFrameKey(attachmentPath); + if (frames.has(frameKey)) { + continue; + } + const entry = resolveImageEntry({ zip, imageRoot, attachmentPath }); + if (!entry) { + missingFrames.push(attachmentPath); + continue; + } + frames.set(frameKey, await loadImageFromZipEntry(entry)); + } + + return { exportInfo, imageRoot, frames, missingFrames }; +} + +async function discoverSkeletonCandidates(zip: JSZip) { + const candidates: SkeletonCandidate[] = []; + const invalidJsonPaths: string[] = []; + const entries: JSZipObject[] = []; + + zip.forEach((path, entry) => { + if (entry.dir) { + return; + } + const normalizedPath = normalizeZipPath(path); + if (normalizedPath.split('/').pop() === SKELETON_FILE_NAME) { + entries.push(entry); + } + }); + + for (const entry of entries) { + try { + const text = await entry.async('text'); + const json = JSON.parse(text) as unknown; + if (!isRecord(json)) { + invalidJsonPaths.push(entry.name); + continue; + } + candidates.push({ + path: normalizeZipPath(entry.name), + baseDir: getBaseDir(entry.name), + json, + }); + } catch { + invalidJsonPaths.push(entry.name); + } + } + + return { candidates, invalidJsonPaths }; +} + +function populateSkeletonSelect(candidates: SkeletonCandidate[]) { + const fragment = document.createDocumentFragment(); + for (const [index, candidate] of candidates.entries()) { + const option = document.createElement('option'); + option.value = String(index); + option.textContent = candidate.path; + fragment.append(option); + } + skeletonSelect.replaceChildren(fragment); + skeletonSelect.disabled = candidates.length <= 1; +} + +function closeLoadedFrames(frames: LoadedFrame[]) { + for (const frame of frames) { + if ('close' in frame.image && typeof frame.image.close === 'function') { + frame.image.close(); + } + } +} + +class LooseFrameAttachmentLoader implements AttachmentLoader { + readonly textures: GLTexture[] = []; + + constructor( + private readonly context: ManagedWebGLRenderingContext, + private readonly frames: Map, + ) {} + + newRegionAttachment( + skin: Skin, + placeholder: string, + name: string, + path: string, + sequence: Sequence, + ) { + for (let index = 0; index < sequence.regions.length; index += 1) { + const sequencePath = sequence.hasPathSuffix() + ? sequence.getPath(path, index) + : path; + sequence.regions[index] = this.createRegion(sequencePath); + } + return new RegionAttachment(name, sequence); + } + + newMeshAttachment( + skin: Skin, + placeholder: string, + name: string, + path: string, + sequence: Sequence, + ): MeshAttachment { + throw new Error(`暂不支持 mesh attachment:${name}`); + } + + newBoundingBoxAttachment( + skin: Skin, + placeholder: string, + name: string, + ): BoundingBoxAttachment { + throw new Error(`暂不支持 boundingbox attachment:${name}`); + } + + newPathAttachment( + skin: Skin, + placeholder: string, + name: string, + ): PathAttachment { + throw new Error(`暂不支持 path attachment:${name}`); + } + + newPointAttachment( + skin: Skin, + placeholder: string, + name: string, + ): PointAttachment { + throw new Error(`暂不支持 point attachment:${name}`); + } + + newClippingAttachment( + skin: Skin, + placeholder: string, + name: string, + ): ClippingAttachment { + throw new Error(`暂不支持 clipping attachment:${name}`); + } + + private createRegion(path: string) { + const frame = this.frames.get(toFrameKey(path)); + if (!frame) { + throw new Error(`找不到贴图帧:${path}`); + } + + const texture = new GLTexture(this.context, frame.image, false); + this.textures.push(texture); + + const region = new TextureRegion(); + region.texture = texture; + region.u = 0; + region.v = 0; + region.u2 = 1; + region.v2 = 1; + region.width = frame.width; + region.height = frame.height; + region.originalWidth = frame.width; + region.originalHeight = frame.height; + region.offsetX = 0; + region.offsetY = 0; + region.degrees = 0; + return region; + } +} + +class SpinePreview { + private readonly context: ManagedWebGLRenderingContext; + private readonly renderer: SceneRenderer; + private activeSkeleton: ActiveSkeleton | null = null; + private frameHandle: number | null = null; + private lastFrameMs = performance.now(); + + constructor(private readonly canvas: HTMLCanvasElement) { + this.context = new ManagedWebGLRenderingContext(canvas, { + alpha: true, + premultipliedAlpha: false, + }); + this.renderer = new SceneRenderer(canvas, this.context); + this.startLoop(); + } + + load({ + json, + frames, + }: { + json: JsonRecord; + frames: Map; + }) { + this.clearActiveSkeleton(); + const loader = new LooseFrameAttachmentLoader(this.context, frames); + const skeletonData = new SkeletonJson(loader).readSkeletonData(json); + const skeleton = new Skeleton(skeletonData); + if (skeletonData.defaultSkin) { + skeleton.setSkin(skeletonData.defaultSkin); + } + skeleton.setupPose(); + + const state = new AnimationState(new AnimationStateData(skeletonData)); + const firstAnimation = skeletonData.animations[0]; + if (firstAnimation) { + state.setAnimation(0, firstAnimation, true); + state.apply(skeleton); + } + skeleton.updateWorldTransform(Physics.none); + + this.activeSkeleton = { + skeleton, + state, + textures: loader.textures, + frames: [...frames.values()], + }; + this.fitCamera(); + } + + dispose() { + this.clearActiveSkeleton(); + if (this.frameHandle !== null) { + cancelAnimationFrame(this.frameHandle); + this.frameHandle = null; + } + this.renderer.dispose(); + this.context.dispose(); + } + + private clearActiveSkeleton() { + if (!this.activeSkeleton) { + return; + } + for (const texture of this.activeSkeleton.textures) { + texture.dispose(); + } + closeLoadedFrames(this.activeSkeleton.frames); + this.activeSkeleton = null; + } + + private startLoop() { + const tick = (timestamp: number) => { + const delta = Math.min((timestamp - this.lastFrameMs) / 1000, 0.064); + this.lastFrameMs = timestamp; + this.render(delta); + this.frameHandle = requestAnimationFrame(tick); + }; + this.frameHandle = requestAnimationFrame(tick); + } + + private render(delta: number) { + const gl = this.context.gl; + this.fitCamera(); + gl.clearColor(0.04, 0.05, 0.07, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + const activeSkeleton = this.activeSkeleton; + if (!activeSkeleton) { + return; + } + + activeSkeleton.state.update(delta); + activeSkeleton.state.apply(activeSkeleton.skeleton); + activeSkeleton.skeleton.update(delta); + activeSkeleton.skeleton.updateWorldTransform(Physics.none); + + this.renderer.begin(); + this.renderer.drawSkeleton(activeSkeleton.skeleton); + this.renderer.end(); + } + + private fitCamera() { + const activeSkeleton = this.activeSkeleton; + const cssWidth = Math.max(1, this.canvas.clientWidth || this.canvas.width); + const cssHeight = Math.max(1, this.canvas.clientHeight || this.canvas.height); + + if (!activeSkeleton) { + this.renderer.camera.position.set(0, 0, 0); + this.renderer.camera.setViewport(cssWidth, cssHeight); + this.renderer.resize(ResizeMode.Stretch); + return; + } + + const bounds = activeSkeleton.skeleton.getBoundsRect(); + const fallbackWidth = activeSkeleton.skeleton.data.width || cssWidth; + const fallbackHeight = activeSkeleton.skeleton.data.height || cssHeight; + const boundsWidth = bounds.width > 0 ? bounds.width : fallbackWidth; + const boundsHeight = bounds.height > 0 ? bounds.height : fallbackHeight; + const centerX = bounds.x + boundsWidth / 2; + const centerY = bounds.y + boundsHeight / 2; + let viewportWidth = Math.max(1, boundsWidth * 1.18); + let viewportHeight = Math.max(1, boundsHeight * 1.18); + const canvasRatio = cssWidth / cssHeight; + const viewportRatio = viewportWidth / viewportHeight; + + if (viewportRatio > canvasRatio) { + viewportHeight = viewportWidth / canvasRatio; + } else { + viewportWidth = viewportHeight * canvasRatio; + } + + this.renderer.camera.position.set(centerX, centerY, 0); + this.renderer.camera.setViewport(viewportWidth, viewportHeight); + this.renderer.resize(ResizeMode.Stretch); + } +} + +async function loadCandidate(index: number) { + if (!activeZip) { + return; + } + const candidate = activeCandidates[index]; + if (!candidate) { + return; + } + + try { + setStatus('info', '正在加载 Spine JSON'); + const { exportInfo, imageRoot, frames, missingFrames } = + await loadFramesForCandidate(activeZip, candidate); + const animationNames = collectAnimationNames(candidate.json); + + if (missingFrames.length) { + throw new Error(`缺少贴图帧:${missingFrames.join(', ')}`); + } + if (!exportInfo.paths.length) { + throw new Error('未找到 region attachment'); + } + if (exportInfo.unsupportedTypes.length) { + addLog( + `发现暂不支持的 attachment:${exportInfo.unsupportedTypes.join(', ')}`, + 'error', + ); + } + + if (!activePreview) { + activePreview = new SpinePreview(previewCanvas); + } + activePreview.load({ json: candidate.json, frames }); + emptyState.hidden = true; + + renderSummary([ + ['JSON', candidate.path], + ['贴图目录', imageRoot || '.'], + ['动画', animationNames.join(', ') || '-'], + ['Region', String(exportInfo.attachmentCount)], + ['帧数', String(frames.size)], + ]); + setStatus('success', '导出包可被 Spine JS runtime 解析并渲染'); + addLog(`已渲染:${candidate.path}`, 'success'); + } catch (error) { + setStatus('error', error instanceof Error ? error.message : '加载失败'); + addLog(error instanceof Error ? error.message : '加载失败', 'error'); + emptyState.hidden = false; + } +} + +async function loadZipFile(file: File) { + clearLogs(); + renderSummary([]); + setStatus('info', '正在读取 ZIP'); + emptyState.hidden = false; + emptyState.textContent = '正在读取 ZIP'; + + try { + const zip = await JSZip.loadAsync(file); + const { candidates, invalidJsonPaths } = + await discoverSkeletonCandidates(zip); + activeZip = zip; + activeCandidates = candidates; + populateSkeletonSelect(candidates); + + for (const invalidPath of invalidJsonPaths) { + addLog(`JSON 解析失败:${invalidPath}`, 'error'); + } + + if (!candidates.length) { + throw new Error('ZIP 中没有 skeleton.json'); + } + + setStatus('info', `找到 ${candidates.length} 个 skeleton.json`); + addLog(`已读取:${file.name}`); + skeletonSelect.value = '0'; + await loadCandidate(0); + } catch (error) { + activeZip = null; + activeCandidates = []; + populateSkeletonSelect([]); + setStatus('error', error instanceof Error ? error.message : 'ZIP 读取失败'); + emptyState.textContent = '拖入 ZIP 开始验证'; + addLog(error instanceof Error ? error.message : 'ZIP 读取失败', 'error'); + } +} + +zipInput.addEventListener('change', () => { + const file = zipInput.files?.[0]; + if (file) { + void loadZipFile(file); + } +}); + +skeletonSelect.addEventListener('change', () => { + void loadCandidate(Number(skeletonSelect.value)); +}); + +for (const eventName of ['dragenter', 'dragover']) { + dropZone.addEventListener(eventName, (event) => { + event.preventDefault(); + dropZone.dataset.dragging = 'true'; + }); +} + +for (const eventName of ['dragleave', 'drop']) { + dropZone.addEventListener(eventName, (event) => { + event.preventDefault(); + dropZone.dataset.dragging = 'false'; + }); +} + +dropZone.addEventListener('drop', (event) => { + const file = event.dataTransfer?.files[0]; + if (file) { + void loadZipFile(file); + } +}); + +window.addEventListener('beforeunload', () => { + activePreview?.dispose(); +}); diff --git a/tools/spine-json-export-validator/src/style.css b/tools/spine-json-export-validator/src/style.css new file mode 100644 index 000000000..0e7ec7b51 --- /dev/null +++ b/tools/spine-json-export-validator/src/style.css @@ -0,0 +1,263 @@ +:root { + color: #20242c; + background: #eef1f5; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +button, +input, +select { + font: inherit; +} + +.app { + min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr; +} + +.app-toolbar { + min-height: 72px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + border-bottom: 1px solid #d8dde6; + background: #fbfcfe; +} + +.title-group { + min-width: 0; +} + +.title-group h1 { + margin: 0; + font-size: 20px; + line-height: 1.25; + font-weight: 700; + letter-spacing: 0; +} + +.title-group p { + margin: 4px 0 0; + color: #697283; + font-size: 13px; + line-height: 1.35; +} + +.file-action { + position: relative; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 0 16px; + border: 1px solid #2f6fed; + border-radius: 6px; + color: #ffffff; + background: #2f6fed; + cursor: pointer; +} + +.file-action input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +.workspace { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; +} + +.preview-pane { + position: relative; + min-width: 0; + min-height: 0; + background: + linear-gradient(45deg, #dfe4ec 25%, transparent 25%), + linear-gradient(-45deg, #dfe4ec 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #dfe4ec 75%), + linear-gradient(-45deg, transparent 75%, #dfe4ec 75%); + background-color: #f7f9fc; + background-position: + 0 0, + 0 10px, + 10px -10px, + -10px 0; + background-size: 20px 20px; + overflow: hidden; +} + +.preview-pane[data-dragging="true"] { + outline: 3px solid #2f6fed; + outline-offset: -3px; +} + +#previewCanvas { + display: block; + width: 100%; + height: 100%; +} + +.empty-state { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + min-width: 180px; + padding: 12px 16px; + border: 1px solid #d8dde6; + border-radius: 8px; + color: #596273; + background: rgba(255, 255, 255, 0.92); + text-align: center; + font-size: 14px; +} + +.empty-state[hidden] { + display: none; +} + +.inspector { + min-width: 0; + padding: 16px; + border-left: 1px solid #d8dde6; + background: #ffffff; + overflow: auto; +} + +.status-box { + min-height: 42px; + display: flex; + align-items: center; + padding: 10px 12px; + border: 1px solid #d8dde6; + border-radius: 6px; + color: #334155; + background: #f8fafc; + font-size: 14px; + line-height: 1.35; +} + +.status-box[data-tone="success"] { + border-color: #91d2aa; + color: #19643a; + background: #eefaf2; +} + +.status-box[data-tone="error"] { + border-color: #f1a0a0; + color: #9e2626; + background: #fff3f3; +} + +.field-label, +.log-title { + display: block; + margin: 16px 0 8px; + color: #4b5563; + font-size: 12px; + font-weight: 700; +} + +#skeletonSelect { + width: 100%; + min-height: 38px; + padding: 0 10px; + border: 1px solid #cbd3df; + border-radius: 6px; + background: #ffffff; + color: #20242c; +} + +.summary-list { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 8px 10px; + margin: 16px 0 0; + font-size: 13px; +} + +.summary-list dt { + color: #6b7280; +} + +.summary-list dd { + min-width: 0; + margin: 0; + color: #1f2937; + overflow-wrap: anywhere; +} + +.log-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 8px; +} + +.log-list li { + padding: 8px 10px; + border: 1px solid #e0e5ed; + border-radius: 6px; + background: #f8fafc; + color: #4b5563; + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.log-list li[data-tone="error"] { + border-color: #f1a0a0; + color: #9e2626; + background: #fff3f3; +} + +.log-list li[data-tone="success"] { + border-color: #91d2aa; + color: #19643a; + background: #eefaf2; +} + +@media (max-width: 860px) { + .app-toolbar { + align-items: flex-start; + flex-direction: column; + } + + .file-action { + width: 100%; + } + + .workspace { + grid-template-columns: 1fr; + grid-template-rows: minmax(360px, 58vh) auto; + } + + .inspector { + border-left: 0; + border-top: 1px solid #d8dde6; + } +} diff --git a/tools/spine-json-export-validator/tsconfig.json b/tools/spine-json-export-validator/tsconfig.json new file mode 100644 index 000000000..640e9c273 --- /dev/null +++ b/tools/spine-json-export-validator/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "Bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"] +}
{dashboard?.range.periodLabel ?? '运营指标'}
导入画布导出的 ZIP,检查 skeleton.json 与 frames 渲染结果。