合并 master 更新到 AI 游戏创作分支
合入 master 的画布 Agent、精选素材审核、外部编辑器 API 与认证投影更新 保留当前分支的 AI 游戏创作壳、配置项和 LLM 运行接口改造 解决图片编辑器、LLM、去背景完成、外部生成排序和文档冲突 沿用 master 的 SpacetimeDB auth_store_projection 迁移与生成绑定
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView';
|
||||
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
|
||||
|
||||
function createClient(): EditorAgentConversationClient {
|
||||
return {
|
||||
listConversations: vi.fn().mockResolvedValue([
|
||||
{
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
createConversation: vi.fn().mockResolvedValue({
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '新对话',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:01:00.000Z',
|
||||
updatedAt: '2026-07-03T00:01:00.000Z',
|
||||
}),
|
||||
getConversation: vi.fn().mockResolvedValue({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-old',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '已经看到画布内容',
|
||||
attachments: [],
|
||||
generations: [],
|
||||
status: 'completed',
|
||||
createdAt: '2026-07-03T00:00:10.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:10.000Z',
|
||||
}),
|
||||
deleteConversation: vi.fn().mockResolvedValue({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
}),
|
||||
streamMessage: vi
|
||||
.fn()
|
||||
.mockImplementation(async (_conversationId, _payload, options) => {
|
||||
options.onEvent?.({
|
||||
event: 'stage',
|
||||
data: { conversationId: _conversationId, stage: 'responding' },
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'message_delta',
|
||||
data: {
|
||||
conversationId: _conversationId,
|
||||
messageId: 'assistant-stream',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
textDelta: '收到,我会参考这张图。',
|
||||
},
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'done',
|
||||
data: { conversationId: _conversationId, title: null },
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('EditorAgentConversationPanelView', () => {
|
||||
it('manages conversations, attachments and message sending inside the panel', async () => {
|
||||
const client = createClient();
|
||||
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
projectId="project-1"
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
layers={[
|
||||
{
|
||||
id: 'layer-1',
|
||||
resourceId: 'resource-1',
|
||||
title: '角色图层',
|
||||
src: '/generated/role.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
{
|
||||
id: 'layer-local-only',
|
||||
resourceId: '',
|
||||
title: '未入库图层',
|
||||
src: '/local-only.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 256,
|
||||
height: 256,
|
||||
originalWidth: 256,
|
||||
originalHeight: 256,
|
||||
zIndex: 2,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
]}
|
||||
assets={[
|
||||
{
|
||||
id: 'asset-1',
|
||||
label: '素材库角色',
|
||||
src: '/assets/role.png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
folderId: 'project',
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: 'uploaded',
|
||||
persisted: true,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
|
||||
await waitFor(() => {
|
||||
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
||||
const attachmentDialog = screen.getByRole('dialog', {
|
||||
name: '选择图片附件',
|
||||
});
|
||||
expect(
|
||||
within(attachmentDialog).queryByRole('checkbox', {
|
||||
name: '选择画布图片 未入库图层',
|
||||
}),
|
||||
).toBeNull();
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('checkbox', {
|
||||
name: '选择画布图片 角色图层',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
expect(screen.getByText('角色图层')).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '参考附件做像素风' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy();
|
||||
});
|
||||
expect(client.streamMessage).toHaveBeenCalledWith(
|
||||
'conversation-2',
|
||||
expect.objectContaining({
|
||||
text: '参考附件做像素风',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends selected attachments even when the text input is empty', async () => {
|
||||
const client = createClient();
|
||||
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
projectId="project-1"
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
layers={[
|
||||
{
|
||||
id: 'layer-1',
|
||||
resourceId: 'resource-1',
|
||||
title: '角色图层',
|
||||
src: '/generated/role.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
||||
const attachmentDialog = screen.getByRole('dialog', {
|
||||
name: '选择图片附件',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('checkbox', {
|
||||
name: '选择画布图片 角色图层',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.streamMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps streamed reply and generating state when the panel is collapsed and reopened', async () => {
|
||||
const client = createClient();
|
||||
let finishStream = () => {};
|
||||
vi.mocked(client.streamMessage).mockImplementation(
|
||||
async (conversationId, _payload, options) => {
|
||||
options.onEvent?.({
|
||||
event: 'message_delta',
|
||||
data: {
|
||||
conversationId,
|
||||
messageId: 'assistant-stream',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
textDelta: '我来生成图片。',
|
||||
},
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'stage',
|
||||
data: { conversationId, stage: 'generating' },
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'tool_started',
|
||||
data: {
|
||||
conversationId,
|
||||
messageId: 'assistant-stream',
|
||||
toolCallId: 'tool-call-generating',
|
||||
toolName: 'generate_image',
|
||||
taskId: 'task-generating',
|
||||
model: 'gpt-image-2',
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
finishStream = resolve;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<EditorAgentConversationPanelView
|
||||
projectId="project-1"
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '生成一张图片' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('我来生成图片。')).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText('生成中')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<EditorAgentConversationPanelView
|
||||
projectId="project-1"
|
||||
open={false}
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '打开画布 Agent' })).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<EditorAgentConversationPanelView
|
||||
projectId="project-1"
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('我来生成图片。')).toBeTruthy();
|
||||
expect(screen.getByText('生成中')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
finishStream();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps wheel scrolling inside the message history and input', async () => {
|
||||
const client = createClient();
|
||||
const parentWheel = vi.fn();
|
||||
|
||||
render(
|
||||
<div onWheel={parentWheel}>
|
||||
<EditorAgentConversationPanelView
|
||||
projectId="project-1"
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
const messageWheel = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
deltaY: 120,
|
||||
});
|
||||
fireEvent(
|
||||
screen.getByRole('log', { name: '画布 Agent 消息流' }),
|
||||
messageWheel,
|
||||
);
|
||||
expect(parentWheel).not.toHaveBeenCalled();
|
||||
expect(messageWheel.defaultPrevented).toBe(false);
|
||||
|
||||
const inputWheel = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
deltaY: 120,
|
||||
});
|
||||
fireEvent(screen.getByLabelText('发送给画布 Agent'), inputWheel);
|
||||
expect(parentWheel).not.toHaveBeenCalled();
|
||||
expect(inputWheel.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('confirms conversation deletion from an independent dialog', async () => {
|
||||
const client = createClient();
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
projectId="project-1"
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除当前对话' }));
|
||||
const confirmDialog = screen.getByRole('dialog', { name: '删除对话' });
|
||||
fireEvent.click(
|
||||
within(confirmDialog).getByRole('button', { name: '确认删除' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.deleteConversation).toHaveBeenCalledWith('conversation-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -60,10 +60,7 @@ type ImageCanvasAssetFolderSectionViewProps = {
|
||||
startRenamingAsset: (asset: EditorAsset) => void;
|
||||
commitAssetRename: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset: (asset: EditorAsset) => void;
|
||||
setAssetPublicShowcaseEnabled: (
|
||||
asset: EditorAsset,
|
||||
publicShowcaseEnabled: boolean,
|
||||
) => void;
|
||||
submitAssetToShowcase: (asset: EditorAsset) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
onDownloadAsset: (asset: EditorAsset) => void;
|
||||
@@ -95,7 +92,7 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
startRenamingAsset,
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
submitAssetToShowcase,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset,
|
||||
@@ -255,7 +252,7 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
startRenamingAsset={startRenamingAsset}
|
||||
commitAssetRename={commitAssetRename}
|
||||
deleteUploadedAsset={deleteUploadedAsset}
|
||||
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
|
||||
submitAssetToShowcase={submitAssetToShowcase}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
onDownloadAsset={onDownloadAsset}
|
||||
|
||||
@@ -90,10 +90,7 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
|
||||
startRenamingAsset: (asset: EditorAsset) => void;
|
||||
commitAssetRename: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset: (asset: EditorAsset) => void;
|
||||
setAssetPublicShowcaseEnabled: (
|
||||
asset: EditorAsset,
|
||||
publicShowcaseEnabled: boolean,
|
||||
) => void;
|
||||
submitAssetToShowcase: (asset: EditorAsset) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
onDownloadAsset: (asset: EditorAsset) => void;
|
||||
@@ -141,7 +138,7 @@ export function ImageCanvasAssetLibraryPanelView({
|
||||
startRenamingAsset,
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
submitAssetToShowcase,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset,
|
||||
@@ -233,7 +230,7 @@ export function ImageCanvasAssetLibraryPanelView({
|
||||
startRenamingAsset={startRenamingAsset}
|
||||
commitAssetRename={commitAssetRename}
|
||||
deleteUploadedAsset={deleteUploadedAsset}
|
||||
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
|
||||
submitAssetToShowcase={submitAssetToShowcase}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
onDownloadAsset={onDownloadAsset}
|
||||
|
||||
@@ -24,18 +24,23 @@ vi.mock('../common/PlatformMediaFrame', () => ({
|
||||
objectKey?: string | null;
|
||||
alt: string;
|
||||
previewOverlay?: ReactNode;
|
||||
}) => (
|
||||
<div className="platform-media-frame">
|
||||
{src?.trim() ? (
|
||||
}) => {
|
||||
const imageSrc =
|
||||
src?.trim() ||
|
||||
(objectKey?.trim() ? `https://signed.example.com/${objectKey.trim()}` : '');
|
||||
return (
|
||||
<div className="platform-media-frame">
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={src}
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
data-object-key={objectKey?.trim() || undefined}
|
||||
/>
|
||||
) : null}
|
||||
{previewOverlay}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -75,7 +80,7 @@ function renderAssetRow({
|
||||
toggleAssetSelected = vi.fn(),
|
||||
commitAssetRename = vi.fn(),
|
||||
deleteUploadedAsset = vi.fn(),
|
||||
setAssetPublicShowcaseEnabled = vi.fn(),
|
||||
submitAssetToShowcase = vi.fn(),
|
||||
onDownloadAsset = vi.fn(),
|
||||
}: {
|
||||
asset?: EditorAsset;
|
||||
@@ -87,10 +92,7 @@ function renderAssetRow({
|
||||
toggleAssetSelected?: (assetId: string) => void;
|
||||
commitAssetRename?: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset?: (asset: EditorAsset) => void;
|
||||
setAssetPublicShowcaseEnabled?: (
|
||||
asset: EditorAsset,
|
||||
publicShowcaseEnabled: boolean,
|
||||
) => void;
|
||||
submitAssetToShowcase?: (asset: EditorAsset) => void;
|
||||
onDownloadAsset?: (asset: EditorAsset) => void;
|
||||
} = {}) {
|
||||
const props = {
|
||||
@@ -110,7 +112,7 @@ function renderAssetRow({
|
||||
startRenamingAsset: vi.fn(),
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
submitAssetToShowcase,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset,
|
||||
@@ -280,17 +282,33 @@ describe('ImageCanvasAssetRowView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders image thumbnails when the persisted asset only has objectKey', () => {
|
||||
renderAssetRow({
|
||||
asset: createAsset({
|
||||
label: '规范图',
|
||||
src: '',
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
mediaType: 'image',
|
||||
}),
|
||||
});
|
||||
|
||||
const image = screen.getByRole('img', { name: '素材:规范图' });
|
||||
|
||||
expect(image.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens asset actions from the right click menu', () => {
|
||||
const asset = createAsset({
|
||||
sourceResourceId: 'resource-asset-1',
|
||||
publicShowcaseEnabled: true,
|
||||
sourceType: 'generated',
|
||||
});
|
||||
const deleteUploadedAsset = vi.fn();
|
||||
const setAssetPublicShowcaseEnabled = vi.fn();
|
||||
const submitAssetToShowcase = vi.fn();
|
||||
renderAssetRow({
|
||||
asset,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
submitAssetToShowcase,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -302,13 +320,12 @@ describe('ImageCanvasAssetRowView', () => {
|
||||
clientY: 80,
|
||||
});
|
||||
|
||||
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
|
||||
name: /公开展示该素材/u,
|
||||
const submitMenuItem = screen.getByRole('menuitem', {
|
||||
name: /提交精选审核/u,
|
||||
});
|
||||
expect(publicMenuItem.getAttribute('aria-checked')).toBe('true');
|
||||
|
||||
fireEvent.click(publicMenuItem);
|
||||
expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false);
|
||||
fireEvent.click(submitMenuItem);
|
||||
expect(submitAssetToShowcase).toHaveBeenCalledWith(asset);
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
|
||||
clientX: 120,
|
||||
@@ -318,45 +335,42 @@ describe('ImageCanvasAssetRowView', () => {
|
||||
expect(deleteUploadedAsset).toHaveBeenCalledWith(asset);
|
||||
});
|
||||
|
||||
it('disables public showcase toggle when the asset is not linked to a project resource', () => {
|
||||
const setAssetPublicShowcaseEnabled = vi.fn();
|
||||
renderAssetRow({ setAssetPublicShowcaseEnabled });
|
||||
it('disables showcase submit when the asset is not generated', () => {
|
||||
const submitAssetToShowcase = vi.fn();
|
||||
renderAssetRow({ submitAssetToShowcase });
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
|
||||
clientX: 120,
|
||||
clientY: 80,
|
||||
});
|
||||
|
||||
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
|
||||
name: /公开展示该素材/u,
|
||||
const submitMenuItem = screen.getByRole('menuitem', {
|
||||
name: /提交精选审核/u,
|
||||
});
|
||||
expect((publicMenuItem as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
fireEvent.click(publicMenuItem);
|
||||
expect(setAssetPublicShowcaseEnabled).not.toHaveBeenCalled();
|
||||
fireEvent.click(submitMenuItem);
|
||||
expect(submitAssetToShowcase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps public showcase toggle enabled for legacy generated assets once a source resource is inferred', () => {
|
||||
it('enables showcase submit for persisted generated assets', () => {
|
||||
const asset = createAsset({
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-inferred-from-media',
|
||||
publicShowcaseEnabled: null,
|
||||
});
|
||||
const setAssetPublicShowcaseEnabled = vi.fn();
|
||||
renderAssetRow({ asset, setAssetPublicShowcaseEnabled });
|
||||
const submitAssetToShowcase = vi.fn();
|
||||
renderAssetRow({ asset, submitAssetToShowcase });
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
|
||||
clientX: 120,
|
||||
clientY: 80,
|
||||
});
|
||||
|
||||
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
|
||||
name: /公开展示该素材/u,
|
||||
const submitMenuItem = screen.getByRole('menuitem', {
|
||||
name: /提交精选审核/u,
|
||||
});
|
||||
expect((publicMenuItem as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(publicMenuItem.getAttribute('aria-checked')).toBe('true');
|
||||
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.click(publicMenuItem);
|
||||
expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false);
|
||||
fireEvent.click(submitMenuItem);
|
||||
expect(submitAssetToShowcase).toHaveBeenCalledWith(asset);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,10 +43,7 @@ export type ImageCanvasAssetRowViewProps = {
|
||||
startRenamingAsset: (asset: EditorAsset) => void;
|
||||
commitAssetRename: (asset: EditorAsset) => void;
|
||||
deleteUploadedAsset: (asset: EditorAsset) => void;
|
||||
setAssetPublicShowcaseEnabled: (
|
||||
asset: EditorAsset,
|
||||
publicShowcaseEnabled: boolean,
|
||||
) => void;
|
||||
submitAssetToShowcase: (asset: EditorAsset) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
onDownloadAsset: (asset: EditorAsset) => void;
|
||||
@@ -69,7 +66,7 @@ export function ImageCanvasAssetRowView({
|
||||
startRenamingAsset,
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
submitAssetToShowcase,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset,
|
||||
@@ -82,8 +79,19 @@ export function ImageCanvasAssetRowView({
|
||||
const isRenaming = renamingAsset?.assetId === asset.id;
|
||||
const isUploadingAsset = asset.uploadStatus === 'uploading';
|
||||
const isFailedUpload = asset.uploadStatus === 'failed';
|
||||
const canShowcaseAsset = Boolean(asset.sourceResourceId);
|
||||
const publicShowcaseEnabled = asset.publicShowcaseEnabled ?? true;
|
||||
const showcaseReviewStatus = asset.showcaseReviewStatus ?? null;
|
||||
const canSubmitShowcaseAsset =
|
||||
asset.sourceType === 'generated' && asset.persisted && !showcaseReviewStatus;
|
||||
const showcaseMenuLabel =
|
||||
showcaseReviewStatus === 'pending'
|
||||
? '精选审核中'
|
||||
: showcaseReviewStatus === 'approved'
|
||||
? asset.showcaseDisplayEnabled === false
|
||||
? '已入选精选,暂未展示'
|
||||
: '已入选精选'
|
||||
: showcaseReviewStatus === 'rejected'
|
||||
? '精选未通过'
|
||||
: '提交精选审核';
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuPosition(null);
|
||||
@@ -444,22 +452,21 @@ export function ImageCanvasAssetRowView({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemcheckbox"
|
||||
role="menuitem"
|
||||
className="image-canvas-editor__asset-context-menu-toggle"
|
||||
aria-checked={publicShowcaseEnabled}
|
||||
disabled={!canShowcaseAsset}
|
||||
disabled={!canSubmitShowcaseAsset}
|
||||
onClick={() => {
|
||||
if (!canShowcaseAsset) {
|
||||
if (!canSubmitShowcaseAsset) {
|
||||
return;
|
||||
}
|
||||
setAssetPublicShowcaseEnabled(asset, !publicShowcaseEnabled);
|
||||
submitAssetToShowcase(asset);
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<span className="image-canvas-editor__asset-context-menu-label">
|
||||
公开展示该素材
|
||||
{showcaseMenuLabel}
|
||||
</span>
|
||||
{publicShowcaseEnabled ? (
|
||||
{showcaseReviewStatus === 'approved' ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__asset-context-menu-check"
|
||||
|
||||
@@ -183,6 +183,48 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
expect(submitButton.textContent).toBe('生成5泥点');
|
||||
});
|
||||
|
||||
it('keeps quick edit to one prompt box and model selection', () => {
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
mode: 'quick-edit',
|
||||
sourceLayerId: 'layer-source',
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'ref-a',
|
||||
label: '旧参考图',
|
||||
src: 'data:image/png;base64,cmVmQQ==',
|
||||
},
|
||||
],
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
|
||||
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: '快速编辑提示词' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: '添加参考图' }),
|
||||
).toBeNull();
|
||||
expect(within(panel).queryByText('旧参考图')).toBeNull();
|
||||
expect(
|
||||
within(panel).queryByRole('button', {
|
||||
name: /快速编辑图片尺寸/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '快速编辑图片模型 gpt-image-2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clicks the parent generation panel to collapse an opened option panel', () => {
|
||||
render(<BasicGenerationHarness />);
|
||||
|
||||
|
||||
@@ -145,13 +145,23 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词');
|
||||
const resolvedPromptPlaceholder =
|
||||
promptPlaceholder ??
|
||||
(isQuickEdit ? '想怎么编辑这张图?' : '今天想生成什么画面?');
|
||||
(isQuickEdit ? '写下每个编号要怎么改' : '今天想生成什么画面?');
|
||||
const resolvedOptionLabelPrefix =
|
||||
optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
|
||||
const resolvedReferenceButtonLabel = referenceButtonLabel;
|
||||
const resolvedReferenceButtonAriaLabel =
|
||||
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
|
||||
const hasReferenceMenu = includeReferences && onRequestUpload;
|
||||
const shouldIncludeReferences = isQuickEdit ? false : includeReferences;
|
||||
const shouldIncludeDimensions = isQuickEdit ? false : includeDimensions;
|
||||
const resolvedSubmitLabel =
|
||||
isQuickEdit && submitLabel === '生成' ? '修改' : submitLabel;
|
||||
const resolvedSubmitAriaLabel =
|
||||
isQuickEdit && submitAriaLabel === '生成' ? '修改' : submitAriaLabel;
|
||||
const resolvedSubmittingStatusLabel =
|
||||
isQuickEdit && submittingStatusLabel === '生成中'
|
||||
? '修改中'
|
||||
: submittingStatusLabel;
|
||||
const hasReferenceMenu = shouldIncludeReferences && onRequestUpload;
|
||||
const finalFormClassName =
|
||||
formClassName ??
|
||||
'image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image';
|
||||
@@ -179,7 +189,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{includeReferences ? (
|
||||
{shouldIncludeReferences ? (
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
{references.map((reference, index) => {
|
||||
const label =
|
||||
@@ -259,7 +269,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions={includeDimensions}
|
||||
includeDimensions={shouldIncludeDimensions}
|
||||
includeModel={includeModel}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
|
||||
@@ -269,8 +279,8 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
model: dialog.imageModel,
|
||||
imageSize: dialog.imageSize,
|
||||
})}
|
||||
submitLabel={submitLabel}
|
||||
submitAriaLabel={submitAriaLabel}
|
||||
submitLabel={resolvedSubmitLabel}
|
||||
submitAriaLabel={resolvedSubmitAriaLabel}
|
||||
submitButtonClassName={submitButtonClassName}
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
@@ -284,7 +294,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
className="image-canvas-editor__generate-status"
|
||||
role="status"
|
||||
>
|
||||
{submittingStatusLabel}
|
||||
{resolvedSubmittingStatusLabel}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{dialog.status === 'failed' ? (
|
||||
|
||||
@@ -23,6 +23,7 @@ type ImageCanvasBottomToolbarViewProps = {
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
effectiveTool: CanvasTool;
|
||||
highlight?: boolean;
|
||||
onSwitchTool: (tool: CanvasTool) => void;
|
||||
onOpenToolOptions: (tool: ToolbarOptionTool) => void;
|
||||
onCloseToolOptions: (tool: ToolbarOptionTool) => void;
|
||||
@@ -70,13 +71,19 @@ export function ImageCanvasBottomToolbarView({
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
effectiveTool,
|
||||
highlight = false,
|
||||
onSwitchTool,
|
||||
onOpenToolOptions,
|
||||
onCloseToolOptions,
|
||||
}: ImageCanvasBottomToolbarViewProps) {
|
||||
return (
|
||||
<div
|
||||
className="image-canvas-editor__bottom-toolbar"
|
||||
className={[
|
||||
'image-canvas-editor__bottom-toolbar',
|
||||
highlight ? 'image-canvas-editor__bottom-toolbar--guided' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="toolbar"
|
||||
aria-label="AI画布工具栏"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
|
||||
@@ -63,6 +63,27 @@ function renderContextMenus(
|
||||
return props;
|
||||
}
|
||||
|
||||
function createDomRect(
|
||||
width: number,
|
||||
height: number,
|
||||
overrides: Partial<DOMRect> = {},
|
||||
): DOMRect {
|
||||
const left = overrides.left ?? 0;
|
||||
const top = overrides.top ?? 0;
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
top,
|
||||
left,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ({}),
|
||||
...overrides,
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
describe('ImageCanvasContextMenusView', () => {
|
||||
it('renders blank canvas commands and forwards the canvas point', () => {
|
||||
const canvasPoint = { x: 18, y: 24 };
|
||||
@@ -162,6 +183,59 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps measured layer menus and submenus inside the viewport', async () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
window.innerWidth = 500;
|
||||
window.innerHeight = 640;
|
||||
const getBoundingClientRectSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getBoundingClientRect(this: HTMLElement) {
|
||||
if (this.classList.contains('image-canvas-editor__context-menu')) {
|
||||
return createDomRect(188, 620);
|
||||
}
|
||||
if (
|
||||
this.classList.contains('image-canvas-editor__context-submenu-panel')
|
||||
) {
|
||||
return createDomRect(160, 72);
|
||||
}
|
||||
if (this.classList.contains('image-canvas-editor__context-submenu')) {
|
||||
return createDomRect(188, 29, { top: 586 });
|
||||
}
|
||||
return createDomRect(0, 0);
|
||||
});
|
||||
|
||||
try {
|
||||
const layer = createLayer({ mediaType: 'image-sequence' });
|
||||
renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
x: 490,
|
||||
y: 500,
|
||||
layerId: layer.id,
|
||||
canvasPoint: { x: 40, y: 42 },
|
||||
},
|
||||
contextMenuLayer: layer,
|
||||
});
|
||||
|
||||
const menu = screen.getByRole('menu', { name: '图片功能面板' });
|
||||
await waitFor(() => {
|
||||
expect(menu.style.left).toBe('304px');
|
||||
expect(menu.style.top).toBe('12px');
|
||||
expect(menu.className).toContain(
|
||||
'image-canvas-editor__context-menu--submenu-left',
|
||||
);
|
||||
expect(menu.className).toContain(
|
||||
'image-canvas-editor__context-menu--submenu-up',
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
getBoundingClientRectSpy.mockRestore();
|
||||
window.innerWidth = originalInnerWidth;
|
||||
window.innerHeight = originalInnerHeight;
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the standalone image menu after opening character animation', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const props = renderContextMenus({
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { clamp, CONTEXT_MENU_VIEWPORT_MARGIN } from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasClipboard,
|
||||
CanvasContextMenuState,
|
||||
@@ -44,10 +51,18 @@ type ImageCanvasContextMenusViewProps = {
|
||||
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
|
||||
};
|
||||
|
||||
type MeasuredMenuLayout = {
|
||||
key: string;
|
||||
x: number;
|
||||
y: number;
|
||||
submenuDirection: 'left' | 'right';
|
||||
submenuVerticalDirection: 'down' | 'up';
|
||||
};
|
||||
|
||||
export function ImageCanvasContextMenusView({
|
||||
viewport,
|
||||
contextMenu,
|
||||
canvasClipboard,
|
||||
canvasClipboard: _canvasClipboard,
|
||||
imageContextMenu,
|
||||
imageContextMenuLayer,
|
||||
contextMenuLayer,
|
||||
@@ -75,10 +90,143 @@ export function ImageCanvasContextMenusView({
|
||||
}: ImageCanvasContextMenusViewProps) {
|
||||
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [measuredMenuLayout, setMeasuredMenuLayout] =
|
||||
useState<MeasuredMenuLayout | null>(null);
|
||||
const closeMenus = useCallback(() => {
|
||||
onCloseContextMenu();
|
||||
onCloseImageContextMenu();
|
||||
}, [onCloseContextMenu, onCloseImageContextMenu]);
|
||||
const activeMenuLayoutKey = contextMenu
|
||||
? [
|
||||
'context',
|
||||
contextMenu.kind,
|
||||
contextMenu.kind === 'layer'
|
||||
? contextMenu.layerId
|
||||
: contextMenu.kind === 'generation-dialog'
|
||||
? contextMenu.dialogId
|
||||
: 'blank',
|
||||
contextMenu.x,
|
||||
contextMenu.y,
|
||||
isImageSequenceLayer ? 'sequence' : 'single',
|
||||
imageContextMenuLayer ? 'image-actions' : 'layer-actions',
|
||||
imageContextMenuLayer?.assetKind ?? 'asset',
|
||||
].join(':')
|
||||
: imageContextMenu
|
||||
? [
|
||||
'image',
|
||||
imageContextMenu.layerId,
|
||||
imageContextMenu.x,
|
||||
imageContextMenu.y,
|
||||
imageContextMenuLayer?.assetKind ?? 'asset',
|
||||
].join(':')
|
||||
: null;
|
||||
const activeMenuX = contextMenu?.x ?? imageContextMenu?.x ?? null;
|
||||
const activeMenuY = contextMenu?.y ?? imageContextMenu?.y ?? null;
|
||||
const currentMeasuredMenuLayout =
|
||||
measuredMenuLayout?.key === activeMenuLayoutKey ? measuredMenuLayout : null;
|
||||
const contextMenuClassName = [
|
||||
'image-canvas-editor__context-menu',
|
||||
currentMeasuredMenuLayout?.submenuDirection === 'left'
|
||||
? 'image-canvas-editor__context-menu--submenu-left'
|
||||
: null,
|
||||
currentMeasuredMenuLayout?.submenuVerticalDirection === 'up'
|
||||
? 'image-canvas-editor__context-menu--submenu-up'
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const menuElement = menuRef.current;
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!activeMenuLayoutKey ||
|
||||
activeMenuX === null ||
|
||||
activeMenuY === null ||
|
||||
!menuElement
|
||||
) {
|
||||
setMeasuredMenuLayout(null);
|
||||
return;
|
||||
}
|
||||
const menuRect = menuElement.getBoundingClientRect();
|
||||
const menuWidth = menuRect.width || menuElement.offsetWidth;
|
||||
const menuHeight = menuRect.height || menuElement.offsetHeight;
|
||||
const x = clamp(
|
||||
activeMenuX,
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
Math.max(
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
window.innerWidth - menuWidth - CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
),
|
||||
);
|
||||
const y = clamp(
|
||||
activeMenuY,
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
Math.max(
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
window.innerHeight - menuHeight - CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
),
|
||||
);
|
||||
const submenuElement = menuElement.querySelector<HTMLElement>(
|
||||
'.image-canvas-editor__context-submenu-panel',
|
||||
);
|
||||
const submenuRect = submenuElement?.getBoundingClientRect();
|
||||
const submenuWidth = submenuRect?.width || submenuElement?.offsetWidth || 0;
|
||||
const submenuHeight =
|
||||
submenuRect?.height || submenuElement?.offsetHeight || 0;
|
||||
const submenuGap = 8;
|
||||
const submenuVerticalOffset = 6;
|
||||
const submenuDirection =
|
||||
submenuWidth > 0 &&
|
||||
x + menuWidth + submenuGap + submenuWidth >
|
||||
window.innerWidth - CONTEXT_MENU_VIEWPORT_MARGIN &&
|
||||
x - submenuGap - submenuWidth >= CONTEXT_MENU_VIEWPORT_MARGIN
|
||||
? 'left'
|
||||
: 'right';
|
||||
const submenuAnchorRect =
|
||||
submenuElement?.parentElement?.getBoundingClientRect();
|
||||
const submenuAnchorOffsetTop =
|
||||
submenuAnchorRect && submenuElement
|
||||
? submenuAnchorRect.top - menuRect.top
|
||||
: 0;
|
||||
const submenuAnchorHeight =
|
||||
submenuAnchorRect?.height ||
|
||||
submenuElement?.parentElement?.offsetHeight ||
|
||||
0;
|
||||
const submenuDefaultBottom =
|
||||
y + submenuAnchorOffsetTop - submenuVerticalOffset + submenuHeight;
|
||||
const submenuFlippedTop =
|
||||
y +
|
||||
submenuAnchorOffsetTop +
|
||||
submenuAnchorHeight +
|
||||
submenuVerticalOffset -
|
||||
submenuHeight;
|
||||
const submenuVerticalDirection =
|
||||
submenuHeight > 0 &&
|
||||
submenuDefaultBottom >
|
||||
window.innerHeight - CONTEXT_MENU_VIEWPORT_MARGIN &&
|
||||
submenuFlippedTop >= CONTEXT_MENU_VIEWPORT_MARGIN
|
||||
? 'up'
|
||||
: 'down';
|
||||
setMeasuredMenuLayout((previous) => {
|
||||
if (
|
||||
previous?.key === activeMenuLayoutKey &&
|
||||
previous.x === x &&
|
||||
previous.y === y &&
|
||||
previous.submenuDirection === submenuDirection &&
|
||||
previous.submenuVerticalDirection === submenuVerticalDirection
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
key: activeMenuLayoutKey,
|
||||
x,
|
||||
y,
|
||||
submenuDirection,
|
||||
submenuVerticalDirection,
|
||||
};
|
||||
});
|
||||
}, [activeMenuLayoutKey, activeMenuX, activeMenuY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu && !imageContextMenu) {
|
||||
@@ -112,14 +260,18 @@ export function ImageCanvasContextMenusView({
|
||||
{contextMenu ? (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="image-canvas-editor__context-menu"
|
||||
className={contextMenuClassName}
|
||||
role="menu"
|
||||
aria-label={
|
||||
contextMenu.kind === 'blank' ? '画布右键菜单' : '图片功能面板'
|
||||
contextMenu.kind === 'blank'
|
||||
? '画布右键菜单'
|
||||
: contextMenu.kind === 'generation-dialog'
|
||||
? '生成器右键菜单'
|
||||
: '图片功能面板'
|
||||
}
|
||||
style={{
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
left: currentMeasuredMenuLayout?.x ?? contextMenu.x,
|
||||
top: currentMeasuredMenuLayout?.y ?? contextMenu.y,
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -164,6 +316,15 @@ export function ImageCanvasContextMenusView({
|
||||
显示画布所有元素
|
||||
</button>
|
||||
</>
|
||||
) : contextMenu.kind === 'generation-dialog' ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="image-canvas-editor__context-menu-danger"
|
||||
onClick={onDeleteContextLayers}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
@@ -370,10 +531,10 @@ export function ImageCanvasContextMenusView({
|
||||
{imageContextMenu && imageContextMenuLayer && !contextMenu ? (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="image-canvas-editor__context-menu"
|
||||
className={contextMenuClassName}
|
||||
style={{
|
||||
left: imageContextMenu.x,
|
||||
top: imageContextMenu.y,
|
||||
left: currentMeasuredMenuLayout?.x ?? imageContextMenu.x,
|
||||
top: currentMeasuredMenuLayout?.y ?? imageContextMenu.y,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -566,14 +566,12 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '生成角色形象' }),
|
||||
await screen.findByRole('dialog', { name: '快速编辑图片' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText('角色设定') as HTMLTextAreaElement).value,
|
||||
).toBe('黑衣剑士');
|
||||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the matching generator from image context quick edit for tagged uploaded images', async () => {
|
||||
it('opens quick edit from image context for tagged uploaded images', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-context-tagged-layer',
|
||||
title: '右键标签素材画布',
|
||||
@@ -617,13 +615,10 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '生成角色形象' }),
|
||||
await screen.findByRole('dialog', { name: '快速编辑图片' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText('角色设定') as HTMLTextAreaElement).value,
|
||||
).toBe('红披风骑士');
|
||||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||||
});
|
||||
|
||||
it('restores publication material composer fields and references from saved generator snapshots', async () => {
|
||||
|
||||
@@ -642,6 +642,45 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('drops restored generator references owned by another user', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-owner',
|
||||
mode: 'generate',
|
||||
prompt: '不应复用别人素材',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'foreign-reference',
|
||||
label: '别人账号的素材',
|
||||
src: 'data:image/png;base64,foreign',
|
||||
resourceId: 'resource-foreign',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { generationDialogs } = splitCanvasLayoutItems(
|
||||
serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [dialog],
|
||||
}),
|
||||
new Map([
|
||||
[
|
||||
'resource-foreign',
|
||||
{
|
||||
imageSrc: '/read/foreign.png',
|
||||
objectKey: 'generated/foreign.png',
|
||||
ownerUserId: 'user-b',
|
||||
},
|
||||
],
|
||||
]),
|
||||
'user-a',
|
||||
);
|
||||
|
||||
expect(generationDialogs).toHaveLength(1);
|
||||
expect(generationDialogs[0]?.generationReferences).toEqual([]);
|
||||
});
|
||||
|
||||
it('snaps moving layers to nearby canvas and layer guides', () => {
|
||||
const movingLayer: CanvasLayer = {
|
||||
id: 'moving',
|
||||
|
||||
@@ -48,6 +48,7 @@ export const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
|
||||
export const CONTEXT_MENU_SIZE = {
|
||||
blank: { width: 188, height: 176 },
|
||||
layer: { width: 188, height: 492 },
|
||||
'generation-dialog': { width: 188, height: 64 },
|
||||
} as const;
|
||||
export const CANVAS_BACKGROUND_OPTIONS = [
|
||||
{ label: '白色', value: '#ffffff' },
|
||||
@@ -315,6 +316,18 @@ function isPersistedReferenceAssetId(assetId: string | null | undefined) {
|
||||
return Boolean(normalizedAssetId && !normalizedAssetId.startsWith('upload-'));
|
||||
}
|
||||
|
||||
function normalizedCurrentUserId(userId: string | null | undefined) {
|
||||
return userId?.trim() || null;
|
||||
}
|
||||
|
||||
function isReferencePointerSrc(src: string | null | undefined) {
|
||||
const normalizedSrc = src?.trim() ?? '';
|
||||
return (
|
||||
normalizedSrc.startsWith('ref:project-resource:') ||
|
||||
normalizedSrc.startsWith('ref:asset:')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveReferencePointer(reference: CharacterReferenceImage) {
|
||||
const resourceId = reference.resourceId?.trim();
|
||||
if (isPersistedReferenceResourceId(resourceId)) {
|
||||
@@ -435,6 +448,7 @@ export function isCanvasGenerationDialogLayoutItem(
|
||||
export function splitCanvasLayoutItems(
|
||||
items: EditorProjectLayerSnapshot[],
|
||||
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
||||
currentUserId?: string | null,
|
||||
): {
|
||||
layerItems: EditorProjectLayerSnapshot[];
|
||||
generationDialogs: CanvasGenerationDialogState[];
|
||||
@@ -444,7 +458,11 @@ export function splitCanvasLayoutItems(
|
||||
|
||||
items.forEach((item) => {
|
||||
if (isCanvasGenerationDialogLayoutItem(item)) {
|
||||
const dialog = hydrateCanvasGenerationDialog(item.dialog, resourcesById);
|
||||
const dialog = hydrateCanvasGenerationDialog(
|
||||
item.dialog,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
);
|
||||
if (dialog) {
|
||||
generationDialogs.push(dialog);
|
||||
}
|
||||
@@ -459,6 +477,7 @@ export function splitCanvasLayoutItems(
|
||||
export function hydrateCanvasGenerationDialog(
|
||||
value: unknown,
|
||||
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
||||
currentUserId?: string | null,
|
||||
): CanvasGenerationDialogState | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
@@ -488,22 +507,27 @@ export function hydrateCanvasGenerationDialog(
|
||||
specReference: hydrateCharacterReference(
|
||||
snapshot.specReference,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
generationReferences: hydrateCharacterReferences(
|
||||
snapshot.generationReferences,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
characterSpecReference: hydrateCharacterReference(
|
||||
snapshot.characterSpecReference,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
characterReferences: hydrateCharacterReferences(
|
||||
snapshot.characterReferences,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
iconSpecReference: hydrateCharacterReference(
|
||||
snapshot.iconSpecReference,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
iconDescriptions: Array.isArray(snapshot.iconDescriptions)
|
||||
? snapshot.iconDescriptions.filter(
|
||||
@@ -520,10 +544,12 @@ export function hydrateCanvasGenerationDialog(
|
||||
publicationReferences: hydrateCharacterReferences(
|
||||
snapshot.publicationReferences,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
uiDesignSpecReference: hydrateCharacterReference(
|
||||
snapshot.uiDesignSpecReference,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
),
|
||||
imageModel: stringOrUndefined(snapshot.imageModel),
|
||||
videoModel:
|
||||
@@ -718,6 +744,11 @@ export function mapAssetLibrarySnapshot(
|
||||
thumbnailSrc: asset.thumbnailSrc ?? undefined,
|
||||
sourceResourceId: asset.sourceResourceId ?? null,
|
||||
publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null,
|
||||
generationCostMudPoints: asset.generationCostMudPoints ?? 0,
|
||||
showcaseId: asset.showcaseId ?? null,
|
||||
showcaseReviewStatus: asset.showcaseReviewStatus ?? null,
|
||||
showcaseDisplayEnabled: asset.showcaseDisplayEnabled ?? null,
|
||||
showcaseLikeCount: asset.showcaseLikeCount ?? null,
|
||||
assetKind: canvasAssetKindOrNull(asset.assetKind),
|
||||
generationInputs: generationInputsOrNull(asset.generationInputs),
|
||||
durationSeconds: asset.durationSeconds ?? undefined,
|
||||
@@ -775,6 +806,7 @@ function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
|
||||
|
||||
export type CanvasLayerResourceMetadata = {
|
||||
resourceId?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
@@ -1171,9 +1203,16 @@ function hydrateSpecFormValues(
|
||||
function resolveHydratedReferenceResource(
|
||||
resourceId: string | undefined,
|
||||
resourcesById: Map<string, CanvasLayerResourceMetadata>,
|
||||
currentUserId?: string | null,
|
||||
) {
|
||||
if (resourceId) {
|
||||
return resourcesById.get(resourceId);
|
||||
const resource = resourcesById.get(resourceId);
|
||||
const currentUser = normalizedCurrentUserId(currentUserId);
|
||||
const ownerUserId = resource?.ownerUserId?.trim() || null;
|
||||
if (currentUser && ownerUserId && ownerUserId !== currentUser) {
|
||||
return null;
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1181,6 +1220,7 @@ function resolveHydratedReferenceResource(
|
||||
function hydrateCharacterReference(
|
||||
value: unknown,
|
||||
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
||||
currentUserId?: string | null,
|
||||
): CharacterReferenceImage | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
@@ -1199,7 +1239,18 @@ function hydrateCharacterReference(
|
||||
const sizeBytes = numberFromSnapshot(snapshot.sizeBytes, 0) || undefined;
|
||||
const durationSeconds =
|
||||
numberFromSnapshot(snapshot.durationSeconds, 0) || undefined;
|
||||
const resource = resolveHydratedReferenceResource(resourceId, resourcesById);
|
||||
const resource = resolveHydratedReferenceResource(
|
||||
resourceId,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
);
|
||||
if (
|
||||
resource === null ||
|
||||
(currentUserId && resourceId && !resource) ||
|
||||
(!resource && isReferencePointerSrc(src))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const objectKey =
|
||||
stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey);
|
||||
const assetObjectId =
|
||||
@@ -1226,10 +1277,15 @@ function hydrateCharacterReference(
|
||||
function hydrateCharacterReferences(
|
||||
value: unknown,
|
||||
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
||||
currentUserId?: string | null,
|
||||
): CharacterReferenceImage[] | undefined {
|
||||
return Array.isArray(value)
|
||||
? value.flatMap((reference) => {
|
||||
const hydrated = hydrateCharacterReference(reference, resourcesById);
|
||||
const hydrated = hydrateCharacterReference(
|
||||
reference,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
);
|
||||
return hydrated ? [hydrated] : [];
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -77,7 +77,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
|
||||
startRenamingAsset: vi.fn(),
|
||||
commitAssetRename: vi.fn(),
|
||||
deleteUploadedAsset: vi.fn(),
|
||||
setAssetPublicShowcaseEnabled: vi.fn(),
|
||||
submitAssetToShowcase: vi.fn(),
|
||||
toggleAssetSelected: vi.fn(),
|
||||
addAssetLayer: vi.fn(),
|
||||
onDownloadAsset: vi.fn(),
|
||||
@@ -136,8 +136,10 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
hoveredLayerId: null,
|
||||
canvasMarquee: null,
|
||||
canvasGenerationDialogs: [],
|
||||
editorAgentAssets: [],
|
||||
taskListRefreshKey: 0,
|
||||
isTaskSidebarOpen: false,
|
||||
isAgentConversationOpen: false,
|
||||
generateDialog: null,
|
||||
cropExpandPanel: null,
|
||||
cropExpandSourceLayer: null,
|
||||
@@ -178,9 +180,11 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onOpenLayerMetadata: vi.fn(),
|
||||
onUpdateLayerAssetKind: vi.fn(),
|
||||
onGenerationFramePointerDown: vi.fn(),
|
||||
onGenerationFrameContextMenu: vi.fn(),
|
||||
onActivateGenerationDialog: vi.fn(),
|
||||
onFocusExternalTask: vi.fn(),
|
||||
onToggleTaskSidebar: vi.fn(),
|
||||
onToggleAgentConversation: vi.fn(),
|
||||
onCropExpandHandlePointerDown: vi.fn(),
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
|
||||
@@ -49,6 +49,11 @@ export type EditorAsset = {
|
||||
assetObjectId?: string;
|
||||
sourceResourceId?: string | null;
|
||||
publicShowcaseEnabled?: boolean | null;
|
||||
generationCostMudPoints?: number;
|
||||
showcaseId?: string | null;
|
||||
showcaseReviewStatus?: string | null;
|
||||
showcaseDisplayEnabled?: boolean | null;
|
||||
showcaseLikeCount?: number | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
durationSeconds?: number;
|
||||
@@ -285,6 +290,13 @@ export type CanvasContextMenuState =
|
||||
y: number;
|
||||
layerId: string;
|
||||
canvasPoint: { x: number; y: number };
|
||||
}
|
||||
| {
|
||||
kind: 'generation-dialog';
|
||||
x: number;
|
||||
y: number;
|
||||
dialogId: string;
|
||||
canvasPoint: { x: number; y: number };
|
||||
};
|
||||
|
||||
export type QuickEditPanelState = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,14 +9,16 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import {
|
||||
createEditorAsset,
|
||||
createEditorProjectResource,
|
||||
type EditorAssetSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
loadEditorProject,
|
||||
loadEditorGenerationPricing,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -48,15 +50,19 @@ import type {
|
||||
EditorAsset,
|
||||
ImageContextMenuState,
|
||||
QuickEditPanelState,
|
||||
SidebarPanel,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
|
||||
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
|
||||
import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
applyEditorGenerationPricingConfig,
|
||||
isCanvasGenerationDialog,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
isCanvasGenerationComposerVisible,
|
||||
resolveQuickEditFocusViewport,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
getCanvasGenerationSelectionId,
|
||||
getSelectedGenerationDialogIds,
|
||||
@@ -90,6 +96,87 @@ const TASK_FOCUS_BOTTOM_INSET = 90;
|
||||
const TASK_FOCUS_DIALOG_BOTTOM_INSET = 280;
|
||||
const TASK_FOCUS_PADDING = 18;
|
||||
|
||||
type CanvasStartupTool =
|
||||
| 'character-spec'
|
||||
| 'icon-spec'
|
||||
| 'ui-design'
|
||||
| 'background-music'
|
||||
| 'image'
|
||||
| 'publication-cover';
|
||||
|
||||
const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
|
||||
'character-spec',
|
||||
'icon-spec',
|
||||
'ui-design',
|
||||
'background-music',
|
||||
'image',
|
||||
'publication-cover',
|
||||
];
|
||||
|
||||
type ImageCanvasEditorViewProps = {
|
||||
onProjectAccessLost?: () => void;
|
||||
};
|
||||
|
||||
function isCanvasStartupTool(value: string | null): value is CanvasStartupTool {
|
||||
return CANVAS_STARTUP_TOOLS.includes(value as CanvasStartupTool);
|
||||
}
|
||||
|
||||
function readCanvasStartupIntent() {
|
||||
if (typeof window === 'undefined') {
|
||||
return { showToolbarGuide: false, tool: null };
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const rawTool = params.get('tool');
|
||||
return {
|
||||
showToolbarGuide: params.get('guide') === 'toolbar',
|
||||
tool: isCanvasStartupTool(rawTool) ? rawTool : null,
|
||||
};
|
||||
}
|
||||
|
||||
function clearCanvasStartupIntent() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('tool');
|
||||
params.delete('guide');
|
||||
const search = params.toString();
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
function openCanvasStartupTool(
|
||||
tool: CanvasStartupTool,
|
||||
generationSurface: ReturnType<typeof useImageCanvasGenerationSurface>,
|
||||
) {
|
||||
if (tool === 'character-spec') {
|
||||
generationSurface.openSpecDialog('character');
|
||||
return;
|
||||
}
|
||||
if (tool === 'icon-spec') {
|
||||
generationSurface.openSpecDialog('ui');
|
||||
return;
|
||||
}
|
||||
if (tool === 'ui-design') {
|
||||
generationSurface.openUiDesignGenerationDialog();
|
||||
return;
|
||||
}
|
||||
if (tool === 'background-music') {
|
||||
generationSurface.openBackgroundMusicGenerationDialog();
|
||||
return;
|
||||
}
|
||||
if (tool === 'publication-cover') {
|
||||
generationSurface.openPublicationGenerationDialog(
|
||||
'publication-cover-image',
|
||||
);
|
||||
return;
|
||||
}
|
||||
generationSurface.openGenerateDialog();
|
||||
}
|
||||
|
||||
function resolveLayerProjectResourceImageSrc(layer: CanvasLayer) {
|
||||
const objectKey = layer.objectKey?.trim();
|
||||
if (objectKey) {
|
||||
@@ -200,7 +287,9 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
|
||||
};
|
||||
}
|
||||
|
||||
export function ImageCanvasEditorView() {
|
||||
export function ImageCanvasEditorView({
|
||||
onProjectAccessLost,
|
||||
}: ImageCanvasEditorViewProps = {}) {
|
||||
const authUi = useAuthUi();
|
||||
const [, setGenerationPricingVersion] = useState(0);
|
||||
const [walletBalanceLabel, setWalletBalanceLabel] = useState<string | null>(
|
||||
@@ -227,8 +316,11 @@ export function ImageCanvasEditorView() {
|
||||
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const startupIntentConsumedRef = useRef(false);
|
||||
const selectedLayerIdRef = useRef<string | null>(null);
|
||||
const showRechargeEntry = shouldShowRechargeEntry();
|
||||
const currentEditorUserId = authUi?.user?.id ?? null;
|
||||
const [isToolbarGuideVisible, setIsToolbarGuideVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -379,7 +471,7 @@ export function ImageCanvasEditorView() {
|
||||
});
|
||||
}, [authUi]);
|
||||
useEffect(() => {
|
||||
if (!authUi?.canAccessProtectedData || !authUi.user) {
|
||||
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
|
||||
setWalletBalanceLabel(null);
|
||||
setIsWalletBalanceLoading(false);
|
||||
return;
|
||||
@@ -509,7 +601,7 @@ export function ImageCanvasEditorView() {
|
||||
toggleAssetFolder,
|
||||
commitNewAssetFolder,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
submitAssetToShowcase,
|
||||
startRenamingFolder,
|
||||
commitFolderRename,
|
||||
deleteAssetFolder,
|
||||
@@ -900,17 +992,23 @@ export function ImageCanvasEditorView() {
|
||||
setViewport,
|
||||
],
|
||||
);
|
||||
const { projectId, appendCanvasLayersWithResources, applyProjectSnapshot } =
|
||||
useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
openEditorLoginModal,
|
||||
});
|
||||
const {
|
||||
projectId,
|
||||
isProjectReady,
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
} = useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
currentUserId: currentEditorUserId,
|
||||
openEditorLoginModal,
|
||||
onProjectAccessLost,
|
||||
});
|
||||
const applyGeneratedProjectSnapshot = useCallback(
|
||||
(project: EditorProjectSnapshot) => {
|
||||
applyProjectSnapshot(project);
|
||||
@@ -918,6 +1016,20 @@ export function ImageCanvasEditorView() {
|
||||
},
|
||||
[applyProjectSnapshot, refreshAssetLibrary],
|
||||
);
|
||||
const handleEditorAgentGenerationResult = useCallback(
|
||||
(event: EditorAgentGenerationResultEvent) => {
|
||||
const hasGeneratedResource = event.images.some((image) =>
|
||||
image.resourceId?.trim(),
|
||||
);
|
||||
if (!projectId || !hasGeneratedResource) {
|
||||
return;
|
||||
}
|
||||
void loadEditorProject(projectId)
|
||||
.then(applyGeneratedProjectSnapshot)
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[applyGeneratedProjectSnapshot, projectId],
|
||||
);
|
||||
const persistUpdatedLayerResource = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (!projectId) {
|
||||
@@ -1043,11 +1155,50 @@ export function ImageCanvasEditorView() {
|
||||
persistGeneratedAsset,
|
||||
persistUpdatedLayerResource,
|
||||
projectId,
|
||||
currentUserId: currentEditorUserId,
|
||||
assetFolderId: activeUploadFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot: applyGeneratedProjectSnapshot,
|
||||
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
|
||||
});
|
||||
const [isAgentConversationOpen, setIsAgentConversationOpen] = useState(false);
|
||||
const toggleAgentConversation = useCallback(() => {
|
||||
const nextOpen = !isAgentConversationOpen;
|
||||
if (nextOpen) {
|
||||
setActiveSidebarPanel(null);
|
||||
if (generationSurface.isTaskSidebarOpen) {
|
||||
generationSurface.toggleTaskSidebar();
|
||||
}
|
||||
}
|
||||
setIsAgentConversationOpen(nextOpen);
|
||||
}, [generationSurface, isAgentConversationOpen, setActiveSidebarPanel]);
|
||||
const toggleTaskSidebar = useCallback(() => {
|
||||
if (!generationSurface.isTaskSidebarOpen) {
|
||||
setActiveSidebarPanel(null);
|
||||
setIsAgentConversationOpen(false);
|
||||
}
|
||||
generationSurface.toggleTaskSidebar();
|
||||
}, [generationSurface, setActiveSidebarPanel]);
|
||||
const toggleCanvasSidebarPanel = useCallback(
|
||||
(panel: SidebarPanel) => {
|
||||
setIsAgentConversationOpen(false);
|
||||
if (generationSurface.isTaskSidebarOpen) {
|
||||
generationSurface.toggleTaskSidebar();
|
||||
}
|
||||
toggleSidebarPanel(panel);
|
||||
},
|
||||
[generationSurface, toggleSidebarPanel],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (generationSurface.isTaskSidebarOpen && isAgentConversationOpen) {
|
||||
setIsAgentConversationOpen(false);
|
||||
}
|
||||
}, [generationSurface.isTaskSidebarOpen, isAgentConversationOpen]);
|
||||
useEffect(() => {
|
||||
if (activeSidebarPanel && isAgentConversationOpen) {
|
||||
setIsAgentConversationOpen(false);
|
||||
}
|
||||
}, [activeSidebarPanel, isAgentConversationOpen]);
|
||||
const {
|
||||
setQuickEditPanel,
|
||||
setCropExpandPanel,
|
||||
@@ -1074,6 +1225,7 @@ export function ImageCanvasEditorView() {
|
||||
setIsPickingUiDesignSpecFromCanvas,
|
||||
openCharacterAnimationPanel,
|
||||
openRedrawPanel,
|
||||
openQuickEditPanel,
|
||||
openCropExpandPanel,
|
||||
removeSelectedLayerBackground,
|
||||
extractUiDesignAssets,
|
||||
@@ -1110,6 +1262,35 @@ export function ImageCanvasEditorView() {
|
||||
setQuickEditPanelRef.current = setQuickEditPanel;
|
||||
appendUiAssetExtractionReferencesRef.current =
|
||||
appendUiAssetExtractionReferences;
|
||||
useEffect(() => {
|
||||
if (!isProjectReady || startupIntentConsumedRef.current) {
|
||||
return;
|
||||
}
|
||||
const startupIntent = readCanvasStartupIntent();
|
||||
if (!startupIntent.showToolbarGuide && !startupIntent.tool) {
|
||||
startupIntentConsumedRef.current = true;
|
||||
return;
|
||||
}
|
||||
startupIntentConsumedRef.current = true;
|
||||
clearCanvasStartupIntent();
|
||||
if (startupIntent.tool) {
|
||||
setIsToolbarGuideVisible(false);
|
||||
openCanvasStartupTool(startupIntent.tool, generationSurface);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
startupIntent.showToolbarGuide &&
|
||||
layersRef.current.length === 0 &&
|
||||
canvasGenerationDialogsRef.current.length === 0
|
||||
) {
|
||||
setIsToolbarGuideVisible(true);
|
||||
}
|
||||
}, [generationSurface, isProjectReady]);
|
||||
useEffect(() => {
|
||||
if (isToolbarGuideVisible && isCanvasGenerationDialog(generateDialog)) {
|
||||
setIsToolbarGuideVisible(false);
|
||||
}
|
||||
}, [generateDialog, isToolbarGuideVisible]);
|
||||
const clearGenerationPanelsAfterBlur = useCallback(() => {
|
||||
setQuickEditPanel(null);
|
||||
setCropExpandPanel(null);
|
||||
@@ -1124,11 +1305,14 @@ export function ImageCanvasEditorView() {
|
||||
clearCanvasFocus,
|
||||
handleCanvasContextMenu,
|
||||
handleLayerContextMenu,
|
||||
handleGenerationFrameContextMenu,
|
||||
} = useImageCanvasStageController({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
selectedLayerId,
|
||||
selectedLayerIds,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
imageContextMenu,
|
||||
setImageContextMenu,
|
||||
contextMenu,
|
||||
@@ -1676,7 +1860,7 @@ export function ImageCanvasEditorView() {
|
||||
startRenamingAsset,
|
||||
commitAssetRename,
|
||||
deleteUploadedAsset,
|
||||
setAssetPublicShowcaseEnabled,
|
||||
submitAssetToShowcase,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset: downloadAsset,
|
||||
@@ -1735,8 +1919,11 @@ export function ImageCanvasEditorView() {
|
||||
hoveredLayerId,
|
||||
canvasMarquee,
|
||||
canvasGenerationDialogs,
|
||||
editorAgentAssets: assets,
|
||||
isToolbarGuideVisible,
|
||||
taskListRefreshKey: generationSurface.taskListRefreshKey,
|
||||
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
|
||||
isAgentConversationOpen,
|
||||
generateDialog,
|
||||
cropExpandPanel: generationSurface.cropExpandPanel,
|
||||
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
|
||||
@@ -1782,11 +1969,14 @@ export function ImageCanvasEditorView() {
|
||||
},
|
||||
onUpdateLayerAssetKind: updateLayerAssetKind,
|
||||
onGenerationFramePointerDown: handleGenerationFramePointerDown,
|
||||
onGenerationFrameContextMenu: handleGenerationFrameContextMenu,
|
||||
onActivateGenerationDialog: activateCanvasGenerationDialog,
|
||||
onFocusExternalTask: focusExternalGenerationTask,
|
||||
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
|
||||
onEditorAgentGenerationResult: handleEditorAgentGenerationResult,
|
||||
onToggleTaskSidebar: toggleTaskSidebar,
|
||||
onToggleAgentConversation: toggleAgentConversation,
|
||||
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
|
||||
onOpenQuickEditPanel: openLayerGenerationDialog,
|
||||
onOpenQuickEditPanel: openQuickEditPanel,
|
||||
onOpenRedrawPanel: openRedrawPanel,
|
||||
onOpenCropExpandPanel: openCropExpandPanel,
|
||||
onRemoveBackground: removeSelectedLayerBackground,
|
||||
@@ -1830,7 +2020,7 @@ export function ImageCanvasEditorView() {
|
||||
onToggleBackgroundSettings: toggleBackgroundSettings,
|
||||
onApplyCanvasBackgroundColor: applyCanvasBackgroundColor,
|
||||
onCanvasBackgroundHexChange: handleCanvasBackgroundHexChange,
|
||||
onToggleSidebarPanel: toggleSidebarPanel,
|
||||
onToggleSidebarPanel: toggleCanvasSidebarPanel,
|
||||
onToggleMinimap: toggleMinimap,
|
||||
onMinimapPointerDown: handleMinimapPointerDown,
|
||||
onSwitchTool: switchTool,
|
||||
|
||||
@@ -117,7 +117,7 @@ function renderComposer(
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationComposerView', () => {
|
||||
it('让快速编辑复用普通图片生成面板结构', () => {
|
||||
it('让快速编辑只保留提示词和模型选择', () => {
|
||||
renderComposer({
|
||||
mode: 'quick-edit',
|
||||
prompt: '',
|
||||
@@ -142,6 +142,20 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
);
|
||||
expect(within(panel).getByRole('textbox', { name: '快速编辑提示词' }))
|
||||
.toBeTruthy();
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: '添加参考图' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(panel).queryByRole('button', {
|
||||
name: /快速编辑图片尺寸/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '快速编辑图片模型 gpt-image-2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy();
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -1198,11 +1198,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{quickEditPanel &&
|
||||
(quickEditPanel.mode === 'redraw' ||
|
||||
quickEditPanel.status !== 'generating') &&
|
||||
quickEditSourceLayer &&
|
||||
quickEditPanelStyle ? (
|
||||
{quickEditPanel && quickEditSourceLayer && quickEditPanelStyle ? (
|
||||
<ImageCanvasQuickEditPanelView
|
||||
panel={quickEditPanel}
|
||||
sourceLayer={quickEditSourceLayer}
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
updateSpecFormDialogValue,
|
||||
} from './ImageCanvasGenerationDialogModel';
|
||||
import {
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
@@ -649,21 +649,21 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes legacy nanobanana model aliases in quick edit drafts', () => {
|
||||
it('locks quick edit drafts to gpt-image-2 despite legacy source models', () => {
|
||||
expect(
|
||||
createQuickEditPanelDraft(
|
||||
createLayer({
|
||||
model: 'nano-banana',
|
||||
}),
|
||||
).model,
|
||||
).toBe(IMAGE_MODEL_NANOBANANA2);
|
||||
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
|
||||
expect(
|
||||
createQuickEditPanelDraft(
|
||||
createLayer({
|
||||
model: 'nanobanana2',
|
||||
}),
|
||||
).model,
|
||||
).toBe(IMAGE_MODEL_NANOBANANA2);
|
||||
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
|
||||
});
|
||||
|
||||
it('creates character animation generation dialog drafts for character layers', () => {
|
||||
|
||||
@@ -1187,9 +1187,7 @@ export function createQuickEditPanelDraft(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const model = normalizeEditorImageModel(
|
||||
options.imageModel ?? sourceLayer.model,
|
||||
);
|
||||
const model = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
return {
|
||||
mode: 'quick-edit',
|
||||
sourceLayerId: sourceLayer.id,
|
||||
@@ -1211,7 +1209,6 @@ export function createQuickEditGenerationDialogDraft({
|
||||
prompt,
|
||||
status = 'idle',
|
||||
references = [],
|
||||
model,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
frame,
|
||||
@@ -1234,7 +1231,7 @@ export function createQuickEditGenerationDialogDraft({
|
||||
composerOpen: false,
|
||||
sourceLayerId: sourceLayer.id,
|
||||
generationReferences: references.slice(0, 8),
|
||||
imageModel: normalizeEditorImageModel(model ?? sourceLayer.model),
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
placeholder: {
|
||||
@@ -1259,6 +1256,7 @@ export function createRedrawPanelDraft(
|
||||
return {
|
||||
...createQuickEditPanelDraft(sourceLayer, options),
|
||||
mode: 'redraw',
|
||||
model: normalizeEditorImageModel(options.imageModel ?? sourceLayer.model),
|
||||
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getEditorImageModelDisplayName,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
normalizeEditorImageModel,
|
||||
QUICK_EDIT_MODEL_OPTIONS,
|
||||
resizeGenerationPlaceholderToImageSelection,
|
||||
resolveEditorImageSizeLabel,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
@@ -135,6 +136,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
||||
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const isQuickEdit = dialog.mode === 'quick-edit';
|
||||
const normalizedLockedModel = lockedModel
|
||||
? normalizeEditorImageModel(lockedModel)
|
||||
: null;
|
||||
@@ -143,7 +145,17 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
? { ...dialog, imageModel: normalizedLockedModel }
|
||||
: dialog,
|
||||
);
|
||||
const selectedModelLabel = getEditorImageModelDisplayName(selection.model);
|
||||
const modelOptions = isQuickEdit
|
||||
? [...QUICK_EDIT_MODEL_OPTIONS]
|
||||
: [...EDITOR_IMAGE_MODEL_OPTIONS];
|
||||
const selectedModel = modelOptions.some(
|
||||
(option) => option.value === selection.model,
|
||||
)
|
||||
? selection.model
|
||||
: (modelOptions[0]?.value ?? selection.model);
|
||||
const selectedModelLabel =
|
||||
modelOptions.find((option) => option.value === selectedModel)?.label ??
|
||||
getEditorImageModelDisplayName(selectedModel);
|
||||
const selectedSizeLabel = resolveEditorImageSizeLabel({
|
||||
aspectRatio: selection.aspectRatio,
|
||||
imageSize: selection.imageSize,
|
||||
@@ -340,8 +352,8 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
|
||||
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
|
||||
const selected = selection.model === option.value;
|
||||
{modelOptions.map((option) => {
|
||||
const selected = selectedModel === option.value;
|
||||
return (
|
||||
<OptionChoice
|
||||
key={option.value}
|
||||
|
||||
@@ -461,6 +461,27 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
priceMudPoints: 10,
|
||||
audioKind: 'sound-effect',
|
||||
durationSeconds: 75,
|
||||
resource: {
|
||||
resourceId: 'resource-audio-bff',
|
||||
projectId: 'editor-project-1',
|
||||
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
|
||||
assetObjectId: 'assetobj-audio-1',
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'sound-effect',
|
||||
},
|
||||
asset: {
|
||||
assetId: 'asset-audio-bff',
|
||||
folderId: 'user-1:asset-folder:project',
|
||||
label: '游戏音效 14',
|
||||
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'sound-effect',
|
||||
},
|
||||
},
|
||||
generatedIndex: 14,
|
||||
title: '游戏音效 14',
|
||||
@@ -482,7 +503,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
|
||||
expect(layer).toMatchObject({
|
||||
id: 'layer-audio-14',
|
||||
resourceId: 'local-resource-audio-14',
|
||||
resourceId: 'resource-audio-bff',
|
||||
title: '游戏音效 14',
|
||||
src: '/generated-character-drafts/editor-audios/sfx.mp3',
|
||||
mediaType: 'audio',
|
||||
@@ -500,7 +521,11 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
taskId: 'sound-task-1',
|
||||
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
|
||||
assetObjectId: 'assetobj-audio-1',
|
||||
sourceAssetId: 'asset-audio-bff',
|
||||
durationSeconds: 75,
|
||||
generatedAssetSnapshot: expect.objectContaining({
|
||||
assetId: 'asset-audio-bff',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -459,6 +459,8 @@ export function createAudioResultLayer({
|
||||
generationInputs,
|
||||
frame,
|
||||
}: AudioResultLayerOptions): CanvasLayer {
|
||||
const resource = generated.resource;
|
||||
const asset = generated.asset;
|
||||
const originalWidth = generated.width || 420;
|
||||
const originalHeight = generated.height || 120;
|
||||
const { width, height } = resolveLayerResolutionSize(
|
||||
@@ -478,9 +480,10 @@ export function createAudioResultLayer({
|
||||
|
||||
return {
|
||||
id: `layer-audio-${generatedIndex}`,
|
||||
resourceId: `local-resource-audio-${generatedIndex}`,
|
||||
resourceId:
|
||||
resource?.resourceId ?? `local-resource-audio-${generatedIndex}`,
|
||||
title,
|
||||
src: generated.audioSrc,
|
||||
src: resource?.imageSrc ?? generated.audioSrc,
|
||||
mediaType: 'audio',
|
||||
assetKind: generated.audioKind,
|
||||
x: frameX ?? worldCenter.x - width / 2,
|
||||
@@ -496,10 +499,12 @@ export function createAudioResultLayer({
|
||||
model: generated.model,
|
||||
provider: generated.provider,
|
||||
taskId: generated.taskId,
|
||||
objectKey: generated.objectKey,
|
||||
assetObjectId: generated.assetObjectId,
|
||||
objectKey: resource?.objectKey ?? generated.objectKey,
|
||||
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
|
||||
sourceAssetId: asset?.assetId,
|
||||
durationSeconds: generated.durationSeconds ?? undefined,
|
||||
generationInputs,
|
||||
generationInputs: resource?.generationInputs ?? generationInputs,
|
||||
generatedAssetSnapshot: asset ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user