Treat character-action generations as video & UI tweaks
Handle character-action generation results as real video assets: use backend previewVideoPath as layer src, set mediaType="video" and assetKind="character-animation", and store the first frame only as thumbnail/poster. Update docs to document the new behavior and preserve backend frame extraction/green-screen workflows. Update UI/UX: compact parameter labels (e.g. "16:9·1K"), quick-edit treats original image as an implicit last reference (hidden from the reference bar) and allows editable bottom capsules, and ensure exports preserve true media file types. Change upload toolbar icon to Upload and add tests to cover the icon and video-layer behavior; also add serialization/hydration test for video thumbnails and adjust many integration/unit tests to reflect new labels, sizes and generation semantics. Files touched include multiple docs under docs/, edited components and tests under src/components/image-editor/, and minor stylesheet updates.
This commit is contained in:
@@ -15,6 +15,14 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 图片画布角色动作结果不能把首帧当主媒体
|
||||
|
||||
- 现象:画板里 `生成角色动作` 返回后显示成一张不可播放图片,点击下载或素材 ZIP 导出时拿到的也是 PNG,而不是动作视频。
|
||||
- 原因:后端已经生成 `previewVideoPath`,并继续执行抽帧、绿幕去背和帧素材 OSS 落盘;前端落层时却把 `frames[0].imageSrc` 当作图层主 `src`,且没有设置 `mediaType: "video"`,导致预览和导出都按图片处理。
|
||||
- 处理:角色动作结果图层主 `src` 必须使用 `previewVideoPath`,`mediaType` 固定为 `video`,`assetKind` 固定为 `character-animation`;首帧透明 PNG 只写入 `thumbnailSrc`,用于 `<video poster>` 和后续动作素材二次生成源。不得移除后端原有视频生成、抽帧、绿幕去背和帧落盘流程。
|
||||
- 验证:`ImageCanvasGenerationLayerModel` 应断言动作结果 `src` 为预览视频且 `thumbnailSrc` 为首帧;画布集成测试应出现 `<video controls poster=...>`;导出模型应按 `mediaType="video"` 输出 mp4/webm/mov 等真实视频扩展名。
|
||||
- 关联:`src/components/image-editor/ImageCanvasGenerationLayerModel.ts`、`src/components/image-editor/ImageCanvasWorldView.tsx`、`src/components/image-editor/ImageCanvasExportModel.ts`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## Vidu 文生音频线上网关可能要求 sound 字段
|
||||
|
||||
- 现象:画板点击 `生成游戏音效` 后,请求返回 `Failed to deserialize the JSON body into the target type: missing field sound`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -40,14 +40,14 @@
|
||||
- 生成视频:`你希望生成什么视频?`
|
||||
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
|
||||
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
|
||||
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终自动作为最后一张参考图展示,不占用额外参考图额度。
|
||||
10. 快速编辑最多允许额外绑定 8 张参考图;原图始终自动作为最后一张隐式参考图提交,不占用额外参考图额度,也不在参考图条里固定展示 `图x`。
|
||||
|
||||
## 参数交互
|
||||
|
||||
画面比例、大小尺寸、模型、分辨率、时长等生成参数统一为 Lovart 式底部胶囊按钮:
|
||||
|
||||
```text
|
||||
左下:[比例 · 尺寸 ˄] 右下:[模型 ˄] [生成12泥点]
|
||||
左下:[比例·尺寸 ˄] 右下:[模型 ˄] [生成12泥点]
|
||||
```
|
||||
|
||||
- 面板内不显示 `画面比例`、`大小尺寸`、`模型` 等字段标题,只显示当前选择值。
|
||||
@@ -60,13 +60,13 @@
|
||||
- 模型子面板每行固定一个模型,不用方框包裹模型名;模型名不换行,前置对应模型类型图标,选中项在模型名后用对号标记。
|
||||
- 比例选项卡片内展示对应比例的线框。
|
||||
- 父级面板、底部选项框、弹出子面板字号保持一致。
|
||||
- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p`、`16:9 · 1K`。
|
||||
- 底部组合值使用 `·` 分隔;图片类左下角不留空格,统一为 `x:y·xK`,例如 `16:9·1K`;视频、角色动作等多参数仍显示为 `16:9 · 4秒 · 480p`。
|
||||
- 底部参数热区与生成按钮等高,默认不显示阴影;悬停显示轻量阴影;箭头默认向下,展开后旋转向上。
|
||||
- 底边栏中会在上方弹出二级选项的入口不再依赖点击展开。鼠标悬停到入口即可打开二级面板,鼠标离开入口和二级面板后自动收起;当前范围包括 `生成规范` 和 `生成音乐`。
|
||||
- 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。
|
||||
- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。
|
||||
- 生成规范类图片固定使用 `16:9 · 2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
|
||||
- 快速编辑的尺寸和模型沿用原图生成配置,只在面板底部以禁用态胶囊按钮展示,不允许在快速编辑面板内修改。
|
||||
- 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
|
||||
- 快速编辑默认从原图分辨率和模型初始化比例、尺寸和模型,但底部胶囊必须可点击修改;左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。
|
||||
- 不再在底部常驻展开全部可选项。
|
||||
|
||||
## 泥点显示
|
||||
@@ -93,7 +93,7 @@
|
||||
- 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。
|
||||
- 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,角色动作使用角色动作图标和动作角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。
|
||||
- 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,快速编辑用闪光图标,不能默认全部回退为图片图标。
|
||||
- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9 · 2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。
|
||||
- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。
|
||||
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换到 `720p` 后同步为 `1280 x 720`;调整参数时保持占位中心点不变。
|
||||
- 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。
|
||||
- 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。
|
||||
@@ -174,8 +174,8 @@
|
||||
- 底边栏 `生成规范` / `生成音乐` 悬停即可看到上方选项面板;鼠标从按钮移动到选项面板时不闪退,离开按钮和选项面板后收起。
|
||||
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
|
||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||
- 生成规范类图片底部展示禁用态参数按钮 `16:9 · 2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
|
||||
- 快速编辑底部展示禁用态原图尺寸和原图模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;额外参考图最多 8 张,原图显示为最后一个 `图x`。
|
||||
- 生成规范类图片底部展示禁用态参数按钮 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮;提交参数也固定为这三项,不出现可展开选项。
|
||||
- 快速编辑底部展示当前选择的比例 / 尺寸和模型,视觉对齐可编辑面板的比例 / 尺寸 / 模型按钮,并允许展开修改;额外参考图最多 8 张,原图作为隐式最后参考图参与提交但不在参考图条里固定显示 `图x`。
|
||||
- 快速编辑提交前必须把提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写成最后一张原图编号,例如额外 1 张参考图时改为 `图2`。
|
||||
- 快速编辑提交给后端的参考图顺序必须是额外参考图在前、原图在最后。
|
||||
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
|
||||
|
||||
@@ -162,4 +162,6 @@
|
||||
- 视频生成完成后,后端按面板选择抽取对应帧数:`32`、`40` 或 `48`。
|
||||
- 每帧必须执行绿幕去背,输出透明背景 PNG。
|
||||
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
|
||||
- 画板前端首版只展示生成完成结果摘要,不把帧序列自动铺到画布上;后续若要展示逐帧图层,必须继续复用画布图层与素材库资源模型。
|
||||
- 画板前端回填角色动作结果时,必须以 `previewVideoPath` 创建 `mediaType: "video"`、`assetKind: "character-animation"` 图层,复用视频素材的 `<video controls preload="metadata" playsInline>` 预览和视频下载 / 导出口径;不得把 `frames[0].imageSrc` 作为图层主 `src`。
|
||||
- `frames[0].imageSrc` 只作为动作视频图层的 `thumbnailSrc` / poster,以及后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、绿幕去背和帧素材落盘流程。
|
||||
- 画板前端不把帧序列自动铺到画布上;后续若要展示逐帧图层,必须继续复用画布图层与素材库资源模型。
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* @vitest-environment jsdom */
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
|
||||
import type {
|
||||
GenerateDialogState,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
|
||||
|
||||
function createDialog(
|
||||
patch: Partial<GenerateDialogState> = {},
|
||||
@@ -157,7 +157,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '生成图片尺寸 16:9 · 1K',
|
||||
name: '生成图片尺寸 16:9·1K',
|
||||
}),
|
||||
);
|
||||
const dimensionPanel = screen.getByRole('menu', {
|
||||
@@ -189,7 +189,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '生成图片尺寸 16:9 · 1K',
|
||||
name: '生成图片尺寸 16:9·1K',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
|
||||
@@ -151,6 +151,31 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses an upload-specific icon for the upload tool', () => {
|
||||
const specToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const musicToolWrapRef = createRef<HTMLSpanElement>();
|
||||
const publicationToolWrapRef = createRef<HTMLSpanElement>();
|
||||
render(
|
||||
<ImageCanvasBottomToolbarView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
musicToolWrapRef={musicToolWrapRef}
|
||||
publicationToolWrapRef={publicationToolWrapRef}
|
||||
effectiveTool="select"
|
||||
onSwitchTool={vi.fn()}
|
||||
onOpenToolOptions={vi.fn()}
|
||||
onCloseToolOptions={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
|
||||
)
|
||||
.getByRole('button', { name: '上传工具' })
|
||||
.querySelector('.lucide-upload'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens and closes bottom option tools on hover', () => {
|
||||
const openToolOptions = vi.fn();
|
||||
const closeToolOptions = vi.fn();
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
Grid2X2,
|
||||
Hand,
|
||||
ImageIcon,
|
||||
ImagePlus,
|
||||
Megaphone,
|
||||
MousePointer2,
|
||||
Music,
|
||||
Upload,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
@@ -36,7 +36,7 @@ const canvasTools: Array<{
|
||||
}> = [
|
||||
{ id: 'select', label: '选择工具', icon: MousePointer2 },
|
||||
{ id: 'hand', label: '抓手工具', icon: Hand },
|
||||
{ id: 'upload', label: '上传工具', icon: ImagePlus },
|
||||
{ id: 'upload', label: '上传工具', icon: Upload },
|
||||
{
|
||||
id: 'generate',
|
||||
label: '生成图片',
|
||||
|
||||
@@ -400,7 +400,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
);
|
||||
expect(
|
||||
within(generateDialog).getByRole('button', {
|
||||
name: '生成图片尺寸 1:1 · 1K',
|
||||
name: '生成图片尺寸 1:1·1K',
|
||||
}).className,
|
||||
).toContain('platform-inline-option-button');
|
||||
expect(
|
||||
@@ -1200,7 +1200,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
});
|
||||
expect(
|
||||
within(characterPanel).getByRole('button', {
|
||||
name: '生成图片尺寸 1:1 · 1K',
|
||||
name: '生成图片尺寸 1:1·1K',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
@@ -1229,7 +1229,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(
|
||||
within(iconPanel).getByRole('button', {
|
||||
name: '生成图片尺寸 1:1 · 1K',
|
||||
name: '生成图片尺寸 1:1·1K',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
@@ -2547,8 +2547,14 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText,
|
||||
).toBe(precisePrompt);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('画布图片:角色动作')).toBeTruthy();
|
||||
expect(screen.getByLabelText('画布视频:角色动作')).toBeTruthy();
|
||||
});
|
||||
const actionVideo = screen.getByLabelText('画布视频:角色动作');
|
||||
expect(actionVideo.tagName.toLowerCase()).toBe('video');
|
||||
expect(actionVideo.getAttribute('poster')).toBe(
|
||||
'/generated-character-drafts/editor/frame1.png',
|
||||
);
|
||||
expect(actionVideo.hasAttribute('controls')).toBe(true);
|
||||
expect(screen.getByText('动作')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -2684,7 +2690,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('opens quick edit from the floating toolbar, keeps the source as the last reference, and generates from a new frame', async () => {
|
||||
it('opens quick edit from the floating toolbar, hides the implicit source reference, and generates from a new frame', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-quick-edit',
|
||||
title: '快速编辑画布',
|
||||
@@ -2759,22 +2765,22 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(quickPanel.className).toContain(
|
||||
'image-canvas-editor__quick-edit-panel',
|
||||
);
|
||||
expect(within(quickPanel).getByText('魔法森林')).toBeTruthy();
|
||||
expect(within(quickPanel).queryByText('魔法森林')).toBeNull();
|
||||
expect(
|
||||
(
|
||||
within(quickPanel).getByRole('button', {
|
||||
name: '快速编辑尺寸 1536x1024',
|
||||
name: '快速编辑尺寸 3:2·1K',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
within(quickPanel).getByRole('button', {
|
||||
name: '快速编辑模型 gpt-image-2',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
expect(within(quickPanel).getByLabelText('原图 图1')).toBeTruthy();
|
||||
).toBe(false);
|
||||
expect(within(quickPanel).queryByLabelText('原图 图1')).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(quickPanel).getByRole('button', {
|
||||
@@ -2800,7 +2806,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
name: '快速编辑图片',
|
||||
});
|
||||
expect(within(updatedQuickPanel).getByText('星空参考')).toBeTruthy();
|
||||
expect(within(updatedQuickPanel).getByLabelText('原图 图2')).toBeTruthy();
|
||||
expect(within(updatedQuickPanel).queryByLabelText('原图 图2')).toBeNull();
|
||||
|
||||
fireEvent.change(within(updatedQuickPanel).getByLabelText('快速编辑提示词'), {
|
||||
target: { value: '参考原图增加萤火虫' },
|
||||
@@ -2813,7 +2819,9 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '参考图2增加萤火虫',
|
||||
size: '1536x1024',
|
||||
size: '1024x683',
|
||||
aspectRatio: '3:2',
|
||||
imageSize: '1K',
|
||||
kind: 'quick-edit',
|
||||
model: 'gpt-image-2',
|
||||
referenceImageSrcs: [
|
||||
@@ -2844,8 +2852,8 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
.closest('button') as HTMLElement;
|
||||
expect(Number.parseFloat(generatedLayer.style.left)).toBe(1688);
|
||||
expect(Number.parseFloat(generatedLayer.style.top)).toBe(140);
|
||||
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1536);
|
||||
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
|
||||
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
|
||||
expect(Number.parseFloat(generatedLayer.style.height)).toBe(683);
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-quick-edit',
|
||||
@@ -2853,11 +2861,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
layers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: '魔法森林 快速编辑',
|
||||
resourceId: 'resource-editor-project-quick-edit-1536',
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
originalWidth: 1536,
|
||||
originalHeight: 1024,
|
||||
resourceId: 'resource-editor-project-quick-edit-1024',
|
||||
width: 1024,
|
||||
height: 683,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 683,
|
||||
x: 1688,
|
||||
y: 140,
|
||||
}),
|
||||
@@ -2918,7 +2926,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' }));
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
|
||||
expect(within(panel).getByText('右键图片')).toBeTruthy();
|
||||
expect(within(panel).queryByText('右键图片')).toBeNull();
|
||||
fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), {
|
||||
target: { value: '换成夜晚' },
|
||||
});
|
||||
@@ -2930,6 +2938,8 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
prompt: '换成夜晚',
|
||||
referenceImageSrcs: ['data:image/png;base64,Y29udGV4dA=='],
|
||||
size: '1024x1024',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
model: 'gpt-image-2',
|
||||
kind: 'quick-edit',
|
||||
projectId: 'editor-project-context-quick-edit',
|
||||
@@ -3118,6 +3128,8 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect.objectContaining({
|
||||
prompt: '把画面改成黄昏光线',
|
||||
size: '1024x1024',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
kind: 'quick-edit',
|
||||
model: 'gpt-image-2',
|
||||
referenceImageSrcs: ['data:image/png;base64,ZmFrZS1pbWFnZQ=='],
|
||||
|
||||
@@ -295,6 +295,52 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes and hydrates video thumbnails for character animation layers', () => {
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-action',
|
||||
resourceId: 'resource-action',
|
||||
title: '角色动作',
|
||||
src: '/generated-character-drafts/editor/preview.mp4',
|
||||
mediaType: 'video',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 420,
|
||||
height: 420,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
zIndex: 6,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'character-animation',
|
||||
durationSeconds: 4,
|
||||
};
|
||||
|
||||
const snapshot = serializeLayer(layer);
|
||||
expect(snapshot.thumbnailSrc).toBe(
|
||||
'/generated-character-drafts/editor/frame01.png',
|
||||
);
|
||||
|
||||
const hydrated = hydrateLayer(
|
||||
snapshot,
|
||||
new Map([
|
||||
[
|
||||
'resource-action',
|
||||
{
|
||||
imageSrc: layer.src,
|
||||
assetKind: 'character-animation',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
mediaType: 'video',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
assetKind: 'character-animation',
|
||||
durationSeconds: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('restores audio generation dialogs from saved layout', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-audio',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorAssetLibrarySnapshot,
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorProjectLayerSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
@@ -165,6 +165,7 @@ export function serializeLayer(
|
||||
zIndex: layer.zIndex,
|
||||
sourceType: layer.sourceType,
|
||||
mediaType: layer.mediaType,
|
||||
thumbnailSrc: layer.thumbnailSrc,
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
model: layer.model,
|
||||
@@ -520,6 +521,7 @@ export function hydrateLayer(
|
||||
snapshot.mediaType === 'video' || snapshot.mediaType === 'audio'
|
||||
? snapshot.mediaType
|
||||
: 'image',
|
||||
thumbnailSrc: stringOrNull(snapshot.thumbnailSrc),
|
||||
prompt: stringOrNull(snapshot.prompt),
|
||||
actualPrompt: stringOrNull(snapshot.actualPrompt),
|
||||
model: stringOrNull(snapshot.model),
|
||||
|
||||
@@ -279,6 +279,8 @@ export type QuickEditPanelState = {
|
||||
sourceLayerId: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
model: string;
|
||||
quickEditReferences?: CharacterReferenceImage[];
|
||||
status: 'idle' | 'generating' | 'failed';
|
||||
|
||||
@@ -56,6 +56,13 @@ describe('ImageCanvasExportModel', () => {
|
||||
'/video.png',
|
||||
),
|
||||
).toBe('mp4');
|
||||
expect(
|
||||
getLayerAssetExtensionFromTypeOrSrc(
|
||||
'video',
|
||||
'',
|
||||
'/generated-character-drafts/editor/preview.mp4',
|
||||
),
|
||||
).toBe('mp4');
|
||||
expect(
|
||||
getLayerAssetExtensionFromTypeOrSrc(
|
||||
'audio',
|
||||
|
||||
@@ -24,7 +24,6 @@ import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAn
|
||||
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
|
||||
import { ImageCanvasCropExpandPanelView } from './ImageCanvasCropExpandPanelView';
|
||||
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
|
||||
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
CharacterAnimationPanelState,
|
||||
@@ -43,13 +42,14 @@ import {
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
|
||||
SOUND_EFFECT_DURATION_OPTIONS,
|
||||
EDITOR_VIDEO_DURATION_OPTIONS,
|
||||
EDITOR_VIDEO_MODEL_OPTIONS,
|
||||
resizeGenerationPlaceholderToVideoSelection,
|
||||
SOUND_EFFECT_DURATION_OPTIONS,
|
||||
SPEC_TYPE_LABEL,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
|
||||
import { ImageCanvasPublicationMaterialsDemoPanelView } from './ImageCanvasPublicationMaterialsDemoPanelView';
|
||||
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
|
||||
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
@@ -1146,6 +1146,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
setQuickEditPanel={setQuickEditPanel}
|
||||
onSubmit={onSubmitQuickEdit}
|
||||
/>
|
||||
|
||||
@@ -5,10 +5,6 @@ import type {
|
||||
CharacterAnimationPanelState,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
import {
|
||||
appendCharacterReference,
|
||||
appendGenerationReference,
|
||||
@@ -37,6 +33,9 @@ import {
|
||||
updateIconDescriptionsTextInDialog,
|
||||
updateSpecFormDialogValue,
|
||||
} from './ImageCanvasGenerationDialogModel';
|
||||
import {
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
return {
|
||||
@@ -365,6 +364,8 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
sourceLayerId: 'layer-source',
|
||||
prompt: '',
|
||||
size: '1024x768',
|
||||
aspectRatio: '4:3',
|
||||
imageSize: '1K',
|
||||
model: 'gpt-image-2',
|
||||
quickEditReferences: [],
|
||||
status: 'idle',
|
||||
|
||||
@@ -11,22 +11,24 @@ import type {
|
||||
SpecGenerationType,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
appendLimitedQuickEditReferences,
|
||||
AUDIO_FRAME_DISPLAY_SIZE,
|
||||
AUDIO_FRAME_ORIGINAL_SIZE,
|
||||
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
||||
CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE,
|
||||
CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE,
|
||||
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
||||
appendLimitedQuickEditReferences,
|
||||
createCanvasLayerReference,
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
|
||||
DEFAULT_PUBLICATION_GAME_INFO,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
DEFAULT_SPEC_FORM_VALUES,
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
normalizeEditorImageModel,
|
||||
PUBLICATION_FRAME_ORIGINAL_SIZE,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
@@ -567,6 +569,14 @@ export function createEditDialogDraft(
|
||||
export function createQuickEditPanelDraft(
|
||||
sourceLayer: CanvasLayer,
|
||||
): QuickEditPanelState {
|
||||
const aspectRatio = inferEditorImageAspectRatio(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const imageSize = inferEditorImageSizeLabel(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
return {
|
||||
mode: 'quick-edit',
|
||||
sourceLayerId: sourceLayer.id,
|
||||
@@ -575,6 +585,8 @@ export function createQuickEditPanelDraft(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
),
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
model: normalizeEditorImageModel(sourceLayer.model),
|
||||
quickEditReferences: [],
|
||||
status: 'idle',
|
||||
@@ -586,12 +598,22 @@ export function createQuickEditGenerationDialogDraft({
|
||||
prompt,
|
||||
status = 'idle',
|
||||
references = [],
|
||||
model,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
frame,
|
||||
}: {
|
||||
sourceLayer: CanvasLayer;
|
||||
prompt: string;
|
||||
status?: CanvasGenerationDialogState['status'];
|
||||
references?: NonNullable<QuickEditPanelState['quickEditReferences']>;
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
frame?: { width: number; height: number };
|
||||
}): Omit<CanvasGenerationDialogState, 'id'> {
|
||||
const frameWidth = frame?.width ?? sourceLayer.width;
|
||||
const frameHeight = frame?.height ?? sourceLayer.height;
|
||||
return {
|
||||
mode: 'quick-edit',
|
||||
prompt,
|
||||
@@ -599,18 +621,16 @@ export function createQuickEditGenerationDialogDraft({
|
||||
composerOpen: false,
|
||||
sourceLayerId: sourceLayer.id,
|
||||
generationReferences: references.slice(0, 8),
|
||||
imageModel: normalizeEditorImageModel(sourceLayer.model),
|
||||
imageSize: formatImageSizeValue(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
),
|
||||
imageModel: normalizeEditorImageModel(model ?? sourceLayer.model),
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
placeholder: {
|
||||
x: sourceLayer.x + sourceLayer.width + 32,
|
||||
y: sourceLayer.y,
|
||||
width: sourceLayer.width,
|
||||
height: sourceLayer.height,
|
||||
originalWidth: sourceLayer.originalWidth,
|
||||
originalHeight: sourceLayer.originalHeight,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
originalWidth: frameWidth,
|
||||
originalHeight: frameHeight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* @vitest-environment jsdom */
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
@@ -74,7 +74,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1 · 1K' }),
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
|
||||
);
|
||||
const panel = screen.getByRole('menu', { name: '生成图片尺寸选项' });
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 16:9 · 2K' }),
|
||||
screen.getByRole('button', { name: '生成图片尺寸 16:9·2K' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('2K');
|
||||
@@ -116,7 +116,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1 · 1K' }),
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
@@ -183,7 +183,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1 · 1K' }),
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -263,7 +263,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1 · 1K' }),
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
|
||||
);
|
||||
const panel = screen.getByRole('menu', { name: '生成图片尺寸选项' });
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '16:9' }));
|
||||
|
||||
@@ -16,9 +16,11 @@ import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
getEditorImageModelDisplayName,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
normalizeEditorImageModel,
|
||||
resizeGenerationPlaceholderToImageSelection,
|
||||
resolveEditorImageSizeLabel,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
|
||||
@@ -66,23 +68,17 @@ function normalizeImageDialogSelection(dialog: GenerateDialogState) {
|
||||
aspectRatio:
|
||||
dialog.aspectRatio && aspectRatios.includes(dialog.aspectRatio)
|
||||
? dialog.aspectRatio
|
||||
: options.aspectRatios[0],
|
||||
: (options.aspectRatios[0] ?? '1:1'),
|
||||
imageSize:
|
||||
dialog.imageSize && imageSizes.includes(dialog.imageSize)
|
||||
? dialog.imageSize
|
||||
: (options.imageSizes.find((size) => size === '1K') ??
|
||||
options.imageSizes[0]),
|
||||
options.imageSizes[0] ??
|
||||
'1K'),
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function getImageModelLabel(value: string) {
|
||||
return (
|
||||
EDITOR_IMAGE_MODEL_OPTIONS.find((option) => option.value === value)
|
||||
?.label ?? value
|
||||
);
|
||||
}
|
||||
|
||||
function OptionChoice({
|
||||
children,
|
||||
selected,
|
||||
@@ -124,7 +120,11 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selection = normalizeImageDialogSelection(dialog);
|
||||
const selectedModelLabel = getImageModelLabel(selection.model);
|
||||
const selectedModelLabel = getEditorImageModelDisplayName(selection.model);
|
||||
const selectedSizeLabel = resolveEditorImageSizeLabel({
|
||||
aspectRatio: selection.aspectRatio,
|
||||
imageSize: selection.imageSize,
|
||||
});
|
||||
const isGenerating = dialog.status === 'generating';
|
||||
const labelPrefix = optionLabelPrefix ?? '生成图片';
|
||||
const dismissOpenPanel = useCallback(() => setOpenPanel(null), []);
|
||||
@@ -168,13 +168,14 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
currentDialog.aspectRatio &&
|
||||
nextAspectRatios.includes(currentDialog.aspectRatio)
|
||||
? currentDialog.aspectRatio
|
||||
: nextOptions.aspectRatios[0],
|
||||
: (nextOptions.aspectRatios[0] ?? '1:1'),
|
||||
imageSize:
|
||||
currentDialog.imageSize &&
|
||||
nextImageSizes.includes(currentDialog.imageSize)
|
||||
? currentDialog.imageSize
|
||||
: (nextOptions.imageSizes.find((size) => size === '1K') ??
|
||||
nextOptions.imageSizes[0]),
|
||||
nextOptions.imageSizes[0] ??
|
||||
'1K'),
|
||||
};
|
||||
return resizeGenerationPlaceholderToImageSelection(nextDialog);
|
||||
});
|
||||
@@ -191,13 +192,13 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
<PlatformInlineOptionButton
|
||||
ref={dimensionsButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
|
||||
aria-label={`${labelPrefix}尺寸 ${selection.aspectRatio} · ${selection.imageSize}`}
|
||||
aria-label={`${labelPrefix}尺寸 ${selectedSizeLabel}`}
|
||||
aria-expanded={openPanel === 'dimensions'}
|
||||
disabled={isGenerating}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => togglePanel('dimensions')}
|
||||
>
|
||||
{selection.aspectRatio} · {selection.imageSize}
|
||||
{selectedSizeLabel}
|
||||
</PlatformInlineOptionButton>
|
||||
{openPanel === 'dimensions'
|
||||
? renderEditorPortal(
|
||||
|
||||
@@ -466,7 +466,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates character animation result layers with action metadata', () => {
|
||||
it('creates character animation result layers with playable video preview and frame metadata', () => {
|
||||
const layer = createCharacterAnimationResultLayer({
|
||||
generated: {
|
||||
taskId: 'character-animation-task-1',
|
||||
@@ -508,7 +508,9 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
id: 'layer-character-animation-15',
|
||||
resourceId: 'local-resource-character-animation-15',
|
||||
title: '角色动作',
|
||||
src: '/generated-character-drafts/editor/frame1.png',
|
||||
src: '/generated-character-drafts/editor/preview.mp4',
|
||||
mediaType: 'video',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame1.png',
|
||||
x: -222,
|
||||
y: -242,
|
||||
width: 1024,
|
||||
@@ -523,8 +525,8 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
provider: 'ark',
|
||||
taskId: 'character-animation-task-1',
|
||||
assetKind: 'character-animation',
|
||||
durationSeconds: 4,
|
||||
});
|
||||
expect(layer?.mediaType).toBeUndefined();
|
||||
expect(layer?.generationInputs?.fields[0]?.value).toBe('待机动作');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,8 @@ type VideoResultLayerOptions = {
|
||||
viewport: CanvasViewport;
|
||||
generationInputs: CanvasGenerationInputs;
|
||||
frame?: GenerateDialogState['placeholder'];
|
||||
sourceLayer?: CanvasLayer;
|
||||
assetKind?: CanvasLayer['assetKind'];
|
||||
};
|
||||
|
||||
type AudioResultLayerOptions = {
|
||||
@@ -80,6 +82,7 @@ type CharacterAnimationResultLayerOptions = {
|
||||
viewport: CanvasViewport;
|
||||
generationInputs: CanvasGenerationInputs;
|
||||
frame?: GenerateDialogState['placeholder'];
|
||||
sourceLayer?: CanvasLayer;
|
||||
};
|
||||
|
||||
function getViewportWorldCenter({
|
||||
@@ -188,8 +191,13 @@ export function createQuickEditResultLayer({
|
||||
mode = 'quick-edit',
|
||||
frame,
|
||||
}: QuickEditResultLayerOptions): CanvasLayer {
|
||||
const originalWidth = generated.width || sourceLayer.originalWidth || 1024;
|
||||
const originalHeight = generated.height || sourceLayer.originalHeight || 1024;
|
||||
const originalWidth =
|
||||
frame?.originalWidth || generated.width || sourceLayer.originalWidth || 1024;
|
||||
const originalHeight =
|
||||
frame?.originalHeight ||
|
||||
generated.height ||
|
||||
sourceLayer.originalHeight ||
|
||||
1024;
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
@@ -350,6 +358,8 @@ export function createVideoResultLayer({
|
||||
viewport,
|
||||
generationInputs,
|
||||
frame,
|
||||
sourceLayer,
|
||||
assetKind = 'video',
|
||||
}: VideoResultLayerOptions): CanvasLayer {
|
||||
const originalWidth = generated.width || 1280;
|
||||
const originalHeight = generated.height || 720;
|
||||
@@ -374,7 +384,7 @@ export function createVideoResultLayer({
|
||||
title,
|
||||
src: generated.videoSrc,
|
||||
mediaType: 'video',
|
||||
assetKind: 'video',
|
||||
assetKind,
|
||||
x: frameX ?? worldCenter.x - width / 2,
|
||||
y: frameY ?? worldCenter.y - height / 2,
|
||||
width,
|
||||
@@ -390,6 +400,8 @@ export function createVideoResultLayer({
|
||||
taskId: generated.taskId,
|
||||
objectKey: generated.objectKey,
|
||||
assetObjectId: generated.assetObjectId,
|
||||
sourceResourceId: sourceLayer?.resourceId,
|
||||
groupId: sourceLayer?.groupId,
|
||||
generationInputs,
|
||||
};
|
||||
}
|
||||
@@ -455,6 +467,7 @@ export function createCharacterAnimationResultLayer({
|
||||
viewport,
|
||||
generationInputs,
|
||||
frame,
|
||||
sourceLayer,
|
||||
}: CharacterAnimationResultLayerOptions): CanvasLayer | null {
|
||||
const previewFrame = generated.frames[0];
|
||||
if (!previewFrame) {
|
||||
@@ -481,7 +494,9 @@ export function createCharacterAnimationResultLayer({
|
||||
id: `layer-character-animation-${generatedIndex}`,
|
||||
resourceId: `local-resource-character-animation-${generatedIndex}`,
|
||||
title,
|
||||
src: previewFrame.imageSrc,
|
||||
src: generated.previewVideoPath,
|
||||
mediaType: 'video',
|
||||
thumbnailSrc: previewFrame.imageSrc,
|
||||
x: frameX ?? worldCenter.x - width / 2,
|
||||
y: frameY ?? worldCenter.y - height / 2,
|
||||
width,
|
||||
@@ -496,6 +511,9 @@ export function createCharacterAnimationResultLayer({
|
||||
provider: 'ark',
|
||||
taskId: generated.taskId,
|
||||
assetKind: 'character-animation',
|
||||
durationSeconds: generated.durationSeconds,
|
||||
sourceResourceId: sourceLayer?.resourceId,
|
||||
groupId: sourceLayer?.groupId,
|
||||
generationInputs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
VIDEO_MODEL_KLING_3,
|
||||
VIDEO_MODEL_KLING_3_OMNI,
|
||||
VIDEO_MODEL_SEEDANCE_2,
|
||||
VIDEO_MODEL_SEEDANCE_2_FAST,
|
||||
VIDEO_MODEL_VEO_3_1,
|
||||
VIDEO_MODEL_VEO_3_1_FAST,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
@@ -248,12 +247,6 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-sky',
|
||||
},
|
||||
{
|
||||
title: '参考图 2',
|
||||
label: '原图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -358,6 +351,18 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the extracted transparent frame when reusing a playable character animation layer', () => {
|
||||
expect(
|
||||
resolveCharacterAnimationSourceImageSrc({
|
||||
...buildSourceLayer(),
|
||||
assetKind: 'character-animation',
|
||||
mediaType: 'video',
|
||||
src: '/generated-character-drafts/editor/preview.mp4',
|
||||
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
||||
}),
|
||||
).toBe('/generated-character-drafts/editor/frame01.png');
|
||||
});
|
||||
|
||||
it('maps generation dialog mode and authorization errors to user-facing copy', () => {
|
||||
const iconDialog: CanvasGenerationDialogState = {
|
||||
id: 'dialog-icon',
|
||||
|
||||
@@ -123,11 +123,11 @@ export const QUICK_EDIT_MODEL_OPTIONS = [
|
||||
export const EDITOR_IMAGE_MODEL_OPTIONS = QUICK_EDIT_MODEL_OPTIONS;
|
||||
export const EDITOR_IMAGE_DIMENSION_OPTIONS = {
|
||||
[IMAGE_MODEL_NANOBANANA2]: {
|
||||
aspectRatios: ['1:1', '2:3', '3:2', '9:16', '16:9'],
|
||||
aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'],
|
||||
imageSizes: ['0.5K', '1K', '2K'],
|
||||
},
|
||||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||||
aspectRatios: ['1:1', '2:3', '3:2', '9:16', '16:9'],
|
||||
aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'],
|
||||
imageSizes: ['1K', '2K'],
|
||||
},
|
||||
} as const;
|
||||
@@ -193,6 +193,44 @@ export function resolveEditorImageGenerationPixelSize({
|
||||
return resolveImageFrameSizeFromRatio({ aspectRatio, imageSize });
|
||||
}
|
||||
|
||||
export function resolveEditorImageSizeLabel({
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
}: {
|
||||
aspectRatio: string | null | undefined;
|
||||
imageSize: string | null | undefined;
|
||||
}) {
|
||||
return `${aspectRatio?.trim() || '1:1'}·${getSafeImageSizeValue(imageSize)}`;
|
||||
}
|
||||
|
||||
export function inferEditorImageAspectRatio(width: number, height: number) {
|
||||
const safeWidth = Number.isFinite(width) && width > 0 ? Math.round(width) : 1;
|
||||
const safeHeight =
|
||||
Number.isFinite(height) && height > 0 ? Math.round(height) : 1;
|
||||
const commonRatios = EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
IMAGE_MODEL_NANOBANANA2
|
||||
].aspectRatios as readonly string[];
|
||||
const sourceRatio = safeWidth / safeHeight;
|
||||
return commonRatios.reduce((bestRatio, ratio) => {
|
||||
const bestNumbers = resolveAspectRatioNumbers(bestRatio);
|
||||
const ratioNumbers = resolveAspectRatioNumbers(ratio);
|
||||
const bestDiff = Math.abs(bestNumbers.width / bestNumbers.height - sourceRatio);
|
||||
const ratioDiff = Math.abs(ratioNumbers.width / ratioNumbers.height - sourceRatio);
|
||||
return ratioDiff < bestDiff ? ratio : bestRatio;
|
||||
}, commonRatios[0] ?? '1:1');
|
||||
}
|
||||
|
||||
export function inferEditorImageSizeLabel(width: number, height: number) {
|
||||
const longEdge = Math.max(width, height);
|
||||
if (longEdge <= 768) {
|
||||
return '0.5K';
|
||||
}
|
||||
if (longEdge > 1536) {
|
||||
return '2K';
|
||||
}
|
||||
return '1K';
|
||||
}
|
||||
|
||||
export function resizeGenerationPlaceholderToImageSelection(
|
||||
dialog: GenerateDialogState,
|
||||
): GenerateDialogState {
|
||||
@@ -439,6 +477,16 @@ export function normalizeEditorImageModel(
|
||||
return normalizedModel;
|
||||
}
|
||||
|
||||
export function getEditorImageModelDisplayName(
|
||||
model: string | null | undefined,
|
||||
) {
|
||||
const normalizedModel = normalizeEditorImageModel(model);
|
||||
return (
|
||||
EDITOR_IMAGE_MODEL_OPTIONS.find((option) => option.value === normalizedModel)
|
||||
?.label ?? normalizedModel
|
||||
);
|
||||
}
|
||||
|
||||
export function buildQuickEditModelOptions(currentModel: string) {
|
||||
const normalizedCurrentModel = normalizeEditorImageModel(currentModel);
|
||||
const options = [...QUICK_EDIT_MODEL_OPTIONS];
|
||||
@@ -556,7 +604,7 @@ export function formatLayerImageType(layer: CanvasLayer) {
|
||||
return '角色图片';
|
||||
}
|
||||
if (layer.assetKind === 'character-animation') {
|
||||
return '角色动作图片';
|
||||
return '角色动作视频';
|
||||
}
|
||||
if (layer.assetKind === 'icon') {
|
||||
return '图标素材图片';
|
||||
@@ -688,6 +736,9 @@ export function calculateEditorBackgroundMusicPrice(
|
||||
}
|
||||
|
||||
export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) {
|
||||
if (layer.assetKind === 'character-animation' && layer.thumbnailSrc?.trim()) {
|
||||
return layer.thumbnailSrc.trim();
|
||||
}
|
||||
// 中文注释:角色图已持久化到 OSS 时优先传 objectKey,避免把大 Data URL 塞进 JSON 请求体触发 body limit。
|
||||
return layer.objectKey?.trim() || layer.src;
|
||||
}
|
||||
@@ -1017,7 +1068,7 @@ export function buildEditGenerationInputs(
|
||||
export function buildQuickEditGenerationInputs(
|
||||
title: '快速编辑提示词' | '重绘提示词',
|
||||
prompt: string,
|
||||
sourceLayer: CanvasLayer,
|
||||
_sourceLayer: CanvasLayer,
|
||||
references: CharacterReferenceImage[] | undefined,
|
||||
): CanvasGenerationInputs {
|
||||
const limitedReferences = (references ?? []).slice(
|
||||
@@ -1027,13 +1078,10 @@ export function buildQuickEditGenerationInputs(
|
||||
return {
|
||||
fields: createGenerationInputField(title, prompt),
|
||||
references: [
|
||||
// 中文注释:原图作为隐式最后参考图参与生成,但信息面板只展示用户额外选择的参考图。
|
||||
...limitedReferences.flatMap((reference, index) =>
|
||||
createGenerationInputReference(`参考图 ${index + 1}`, reference),
|
||||
),
|
||||
...createLayerGenerationInputReference(
|
||||
`参考图 ${limitedReferences.length + 1}`,
|
||||
sourceLayer,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
calculateEditorSoundEffectPrice,
|
||||
calculateEditorVideoPrice,
|
||||
DEFAULT_BACKGROUND_MUSIC_MODEL,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
DEFAULT_BACKGROUND_MUSIC_MODEL,
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
@@ -60,6 +60,10 @@ function isSeedanceVideoModel(model: string) {
|
||||
return model === 'seedance2.0' || model === 'seedance2.0-fast';
|
||||
}
|
||||
|
||||
export function isSeedanceEditorVideoModel(model: string) {
|
||||
return isSeedanceVideoModel(model);
|
||||
}
|
||||
|
||||
function estimateReferenceRequestBytes(references: Array<{ src: string }>) {
|
||||
// 中文注释:JSON 包装字段还有少量额外字节;这里按 src 字符串长度估算,前后端都会再兜底校验 64MB。
|
||||
return references.reduce((sum, reference) => sum + reference.src.length, 0);
|
||||
|
||||
@@ -67,6 +67,24 @@ describe('ImageCanvasMetadataModalView', () => {
|
||||
expect(within(dialog).getByText('generated/object.png')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders nanobanana2 instead of the raw Gemini image model id', () => {
|
||||
render(
|
||||
<ImageCanvasMetadataModalView
|
||||
layer={createLayer({
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '图片信息' });
|
||||
|
||||
expect(within(dialog).getByText('nanobanana2')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).queryByText('gemini-3.1-flash-image-preview'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders upload fallback values and closes through the modal shell', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { formatLayerImageType } from './ImageCanvasGenerationModel';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
formatLayerImageType,
|
||||
getEditorImageModelDisplayName,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { formatCanvasDurationMetric } from './ImageCanvasMediaModel';
|
||||
|
||||
export type ImageCanvasMetadataModalViewProps = {
|
||||
@@ -84,7 +87,7 @@ export function ImageCanvasMetadataModalView({
|
||||
)}
|
||||
</dd>
|
||||
<dt>Model</dt>
|
||||
<dd>{layer.model ?? '-'}</dd>
|
||||
<dd>{layer.model ? getEditorImageModelDisplayName(layer.model) : '-'}</dd>
|
||||
{layer.mediaType === 'audio' ? (
|
||||
<>
|
||||
<dt>时长</dt>
|
||||
|
||||
@@ -32,10 +32,12 @@ function QuickEditPanelHarness({
|
||||
initialPanel,
|
||||
onSubmit = vi.fn(),
|
||||
onRequestUpload = vi.fn(),
|
||||
onRememberImageModel = vi.fn(),
|
||||
}: {
|
||||
initialPanel: QuickEditPanelState;
|
||||
onSubmit?: () => void;
|
||||
onRequestUpload?: (target: 'quick-edit-reference') => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
}) {
|
||||
const [panel, setPanel] = useState<QuickEditPanelState | null>(initialPanel);
|
||||
const [isReferenceMenuOpen, setIsReferenceMenuOpen] = useState(false);
|
||||
@@ -54,11 +56,14 @@ function QuickEditPanelHarness({
|
||||
setIsPickingQuickEditReferenceFromCanvas={setIsPickingReference}
|
||||
renderEditorPortal={(node) => node}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
setQuickEditPanel={setPanel}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<output aria-label="当前提示词">{panel.prompt}</output>
|
||||
<output aria-label="当前尺寸">{panel.size}</output>
|
||||
<output aria-label="当前比例">{panel.aspectRatio ?? '-'}</output>
|
||||
<output aria-label="当前图片规格">{panel.imageSize ?? '-'}</output>
|
||||
<output aria-label="当前模型">{panel.model}</output>
|
||||
<output aria-label="当前状态">{panel.status}</output>
|
||||
<output aria-label="当前错误">{panel.errorMessage ?? '-'}</output>
|
||||
@@ -75,17 +80,21 @@ function QuickEditPanelHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasQuickEditPanelView', () => {
|
||||
it('updates prompt and clears failed state while keeping fixed options readonly', () => {
|
||||
it('updates prompt and clears failed state while keeping options editable', () => {
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
<QuickEditPanelHarness
|
||||
initialPanel={{
|
||||
sourceLayerId: 'layer-a',
|
||||
prompt: '旧提示',
|
||||
size: '1024x1024',
|
||||
model: 'nano',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
status: 'failed',
|
||||
errorMessage: '失败原因',
|
||||
}}
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -95,23 +104,21 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
|
||||
expect(screen.getByLabelText('当前提示词').textContent).toBe('新提示');
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1024x1024');
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('nano');
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||
'gemini-3.1-flash-image-preview',
|
||||
);
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '快速编辑尺寸 1024x1024',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '快速编辑模型 nano',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑尺寸 1:1·1K' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '比例 16:9' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '尺寸 2K' }));
|
||||
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
|
||||
expect(screen.getByLabelText('当前图片规格').textContent).toBe('2K');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑模型 nanobanana2' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2');
|
||||
});
|
||||
|
||||
it('submits without rendering a standalone close button', () => {
|
||||
@@ -122,6 +129,8 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
sourceLayerId: 'layer-a',
|
||||
prompt: '提示',
|
||||
size: '1024x1024',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
model: 'nano',
|
||||
status: 'idle',
|
||||
}}
|
||||
@@ -144,6 +153,8 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
sourceLayerId: 'layer-a',
|
||||
prompt: '重绘提示',
|
||||
size: '1024x1024',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
model: 'gpt-image-2',
|
||||
status: 'idle',
|
||||
mode: 'redraw',
|
||||
@@ -156,13 +167,15 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders extra references before the source image and removes references from the slot', () => {
|
||||
it('renders only extra references and keeps the implicit source image out of the strip', () => {
|
||||
render(
|
||||
<QuickEditPanelHarness
|
||||
initialPanel={{
|
||||
sourceLayerId: 'layer-a',
|
||||
prompt: '把原图改成雨天',
|
||||
size: '1024x1024',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
model: 'gpt-image-2',
|
||||
status: 'failed',
|
||||
errorMessage: '失败原因',
|
||||
@@ -174,14 +187,14 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('图1')).toBeTruthy();
|
||||
expect(screen.getByLabelText('原图 图2')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('原图 图2')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除图1' }));
|
||||
|
||||
expect(screen.getByLabelText('参考图数量').textContent).toBe('0');
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
expect(screen.getByLabelText('原图 图1')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('原图 图1')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens reference source menu and dispatches canvas picking or upload actions', () => {
|
||||
@@ -192,6 +205,8 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
sourceLayerId: 'layer-a',
|
||||
prompt: '提示',
|
||||
size: '1024x1024',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
model: 'gpt-image-2',
|
||||
status: 'idle',
|
||||
quickEditReferences: [],
|
||||
@@ -217,6 +232,8 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
sourceLayerId: 'layer-a',
|
||||
prompt: '提示',
|
||||
size: '1024x1024',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
model: 'gpt-image-2',
|
||||
status: 'idle',
|
||||
quickEditReferences: Array.from({ length: 8 }, (_, index) => ({
|
||||
@@ -233,6 +250,6 @@ describe('ImageCanvasQuickEditPanelView', () => {
|
||||
name: '添加快速编辑参考图',
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.getByLabelText('原图 图9')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('原图 图9')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user