新增后台Dashboard并修正画板规范图选择
新增后台 Dashboard 默认入口、日周月筛选和运营汇总页签 补充后台 Dashboard 数据契约、接口统计和文档 修正画板角色与图标规范图选择、缓存和提示状态 修复创作页桌面导航类型收窄错误 更新 Codex 环境初始化脚本和相关回归测试记录
This commit is contained in:
@@ -540,20 +540,35 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
characterReferences: [],
|
||||
};
|
||||
const sourceLayer = createLayer({ title: '参考图' });
|
||||
const characterSpecLayer = createLayer({
|
||||
title: '角色规范',
|
||||
assetKind: 'spec',
|
||||
});
|
||||
|
||||
expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toEqual(
|
||||
expect(
|
||||
assignCharacterSpecReference(characterDialog, characterSpecLayer),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
composerOpen: true,
|
||||
characterSpecReference: expect.objectContaining({
|
||||
id: 'canvas-layer-source',
|
||||
label: '参考图',
|
||||
label: '角色规范',
|
||||
src: 'data:image/png;base64,source',
|
||||
resourceId: 'resource-source',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toBe(
|
||||
characterDialog,
|
||||
);
|
||||
expect(
|
||||
assignCharacterSpecReference(
|
||||
characterDialog,
|
||||
createLayer({ title: '角色形象', assetKind: 'character' }),
|
||||
),
|
||||
).toBe(characterDialog);
|
||||
expect(
|
||||
appendCharacterReference(characterDialog, sourceLayer),
|
||||
).toMatchObject({
|
||||
|
||||
@@ -108,7 +108,7 @@ function getReferenceMediaType(layer: CanvasLayer) {
|
||||
: 'image';
|
||||
}
|
||||
|
||||
function isIconSpecReferenceLayer(layer: CanvasLayer) {
|
||||
export function isIconSpecReferenceLayer(layer: CanvasLayer) {
|
||||
if (layer.assetKind === 'icon-spec') {
|
||||
return true;
|
||||
}
|
||||
@@ -126,6 +126,13 @@ function isIconSpecReferenceLayer(layer: CanvasLayer) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isCharacterSpecReferenceLayer(layer: CanvasLayer) {
|
||||
if (getReferenceMediaType(layer) !== 'image' || layer.assetKind !== 'spec') {
|
||||
return false;
|
||||
}
|
||||
return !isIconSpecReferenceLayer(layer);
|
||||
}
|
||||
|
||||
function appendLimitedSeedanceCanvasReference(
|
||||
references: NonNullable<GenerateDialogState['generationReferences']>,
|
||||
layer: CanvasLayer,
|
||||
@@ -731,7 +738,7 @@ export function assignCharacterSpecReference(
|
||||
dialog: GenerateDialogState | null,
|
||||
layer: CanvasLayer,
|
||||
): GenerateDialogState | null {
|
||||
return dialog?.mode === 'character' && getReferenceMediaType(layer) === 'image'
|
||||
return dialog?.mode === 'character' && isCharacterSpecReferenceLayer(layer)
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
characterSpecReference: createCanvasLayerReference(layer),
|
||||
|
||||
@@ -373,6 +373,36 @@ describe('ImageCanvasGenerationLayerModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the spritesheet layer when icon results are missing', () => {
|
||||
const layers = createIconSpritesheetResultLayers({
|
||||
generated: {
|
||||
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
||||
spritesheetWidth: 512,
|
||||
spritesheetHeight: 512,
|
||||
iconImageSrcs: [],
|
||||
prompt: '图标 prompt',
|
||||
actualPrompt: '图标 actual prompt',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'task-icons',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
iconResults: undefined,
|
||||
startIndex: 11,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
generationInputs: createGenerationInputs(),
|
||||
});
|
||||
|
||||
expect(layers).toHaveLength(1);
|
||||
expect(layers[0]).toMatchObject({
|
||||
id: 'layer-icon-spritesheet-11',
|
||||
title: '图标素材图集 11',
|
||||
assetKind: 'icon-spritesheet',
|
||||
src: 'data:image/png;base64,sheet',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates audio result layers centered on the active placeholder', () => {
|
||||
const layer = createAudioResultLayer({
|
||||
generated: {
|
||||
|
||||
@@ -43,7 +43,7 @@ type QuickEditResultLayerOptions = {
|
||||
|
||||
type IconSpritesheetResultLayerOptions = {
|
||||
generated: EditorIconSpritesheetGenerationResult;
|
||||
iconResults: EditorIconSpritesheetIconResult[];
|
||||
iconResults?: EditorIconSpritesheetIconResult[] | null;
|
||||
startIndex: number;
|
||||
canvasSize: CanvasSize;
|
||||
viewport: CanvasViewport;
|
||||
@@ -299,7 +299,8 @@ export function createIconSpritesheetResultLayers({
|
||||
let cursorY = startY;
|
||||
let rowHeight = 0;
|
||||
|
||||
const iconLayers = iconResults.map((icon, index) => {
|
||||
const normalizedIconResults = Array.isArray(iconResults) ? iconResults : [];
|
||||
const iconLayers = normalizedIconResults.map((icon, index) => {
|
||||
const resource = icon.resource;
|
||||
const asset = icon.asset;
|
||||
const originalWidth = icon.width || 128;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
@@ -147,6 +147,57 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(within(layerButton).queryByAltText('画布图片:角色主图')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks an already decoded cached image as loaded', async () => {
|
||||
const completeDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLImageElement.prototype,
|
||||
'complete',
|
||||
);
|
||||
const naturalWidthDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLImageElement.prototype,
|
||||
'naturalWidth',
|
||||
);
|
||||
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
|
||||
configurable: true,
|
||||
get: () => 128,
|
||||
});
|
||||
|
||||
try {
|
||||
renderWorldView();
|
||||
|
||||
const image = screen.getByAltText('画布图片:角色主图');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(image.className).toContain(
|
||||
'image-canvas-editor__layer-image--loaded',
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
if (completeDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLImageElement.prototype,
|
||||
'complete',
|
||||
completeDescriptor,
|
||||
);
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLImageElement.prototype, 'complete');
|
||||
}
|
||||
if (naturalWidthDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLImageElement.prototype,
|
||||
'naturalWidth',
|
||||
naturalWidthDescriptor,
|
||||
);
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLImageElement.prototype, 'naturalWidth');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves image layers from objectKey before falling back to src', () => {
|
||||
useResolvedAssetReadUrlMock.mockImplementation(
|
||||
(_source: string, options?: { objectKey?: string | null }) => ({
|
||||
|
||||
@@ -339,6 +339,10 @@ function stopMediaControlKeyPropagation(
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function isImageAlreadyDecoded(image: HTMLImageElement | null) {
|
||||
return Boolean(image?.complete && image.naturalWidth > 0);
|
||||
}
|
||||
|
||||
function getLayerMetricLabel(layer: CanvasLayer) {
|
||||
return `${Math.round(layer.originalWidth)} x ${Math.round(
|
||||
layer.originalHeight,
|
||||
@@ -636,10 +640,11 @@ function ImageCanvasImageLayer({
|
||||
refreshKey: layer.taskId ?? layer.resourceId,
|
||||
},
|
||||
);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false);
|
||||
setIsLoaded(isImageAlreadyDecoded(imageRef.current));
|
||||
}, [resolvedUrl]);
|
||||
|
||||
const isLoading =
|
||||
@@ -657,6 +662,7 @@ function ImageCanvasImageLayer({
|
||||
) : null}
|
||||
{resolvedUrl ? (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={resolvedUrl}
|
||||
alt={`画布图片:${layer.title}`}
|
||||
decoding="async"
|
||||
@@ -708,6 +714,7 @@ function ImageCanvasImageSequenceLayer({
|
||||
currentFrame?.frameIndex ?? frameIndex + 1
|
||||
}`,
|
||||
});
|
||||
const frameImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [isFrameLoaded, setIsFrameLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -716,7 +723,7 @@ function ImageCanvasImageSequenceLayer({
|
||||
}, [layer.taskId, layer.resourceId, frames.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFrameLoaded(false);
|
||||
setIsFrameLoaded(isImageAlreadyDecoded(frameImageRef.current));
|
||||
}, [resolvedUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -747,6 +754,7 @@ function ImageCanvasImageSequenceLayer({
|
||||
) : null}
|
||||
{resolvedUrl ? (
|
||||
<img
|
||||
ref={frameImageRef}
|
||||
src={resolvedUrl}
|
||||
alt={`画布序列帧:${layer.title}`}
|
||||
decoding="async"
|
||||
|
||||
@@ -1583,6 +1583,44 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('keeps the extracted spritesheet when extracted icon results are missing', async () => {
|
||||
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
||||
spritesheetWidth: 512,
|
||||
spritesheetHeight: 512,
|
||||
prompt: '仅提取被红色框框选的素材并整理成spritesheet',
|
||||
actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'task-ui-assets',
|
||||
});
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
assetKind: 'ui-design',
|
||||
objectKey: 'generated/ui-design.png',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||||
'layer-icon-2:',
|
||||
);
|
||||
expect(screen.getByTestId('selected').textContent).toBe(
|
||||
'layer-icon-spritesheet-1',
|
||||
);
|
||||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
|
||||
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
||||
|
||||
@@ -759,7 +759,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
}
|
||||
const nextLayers = addIconSpritesheetResultLayers(
|
||||
generated,
|
||||
generated.iconImageSrcs,
|
||||
generated.iconImageSrcs ?? [],
|
||||
generationInputs,
|
||||
extractionFrame,
|
||||
undefined,
|
||||
@@ -868,7 +868,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
}
|
||||
addIconSpritesheetResultLayers(
|
||||
generated,
|
||||
generated.iconImageSrcs,
|
||||
generated.iconImageSrcs ?? [],
|
||||
submissionPlan.generationInputs,
|
||||
canvasCompletionPlaceholder,
|
||||
canvasDialog.id,
|
||||
|
||||
@@ -184,6 +184,20 @@ function GenerationSurfaceHarness() {
|
||||
>
|
||||
切换宣发素材
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
surface.pickCharacterSpecFromLayer(
|
||||
createLayer({
|
||||
id: 'layer-character-image',
|
||||
title: '角色形象 2',
|
||||
assetKind: 'character',
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
选择非规范角色图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => surface.switchGenerationTool('select')}
|
||||
@@ -235,6 +249,17 @@ describe('useImageCanvasGenerationSurface', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a warning toast when a non-spec layer is picked as character spec', () => {
|
||||
render(<GenerationSurfaceHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换角色' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' }));
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe(
|
||||
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
|
||||
);
|
||||
});
|
||||
|
||||
it('anchors the music menu to the music tool and closes after hover leaves', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
@@ -338,6 +339,17 @@ export function useImageCanvasGenerationSurface({
|
||||
|
||||
const generationComposerNode = (
|
||||
<>
|
||||
{generationWorkflow.referencePickWarning ? (
|
||||
<PlatformRuntimeStatusToast
|
||||
tone="warning"
|
||||
surface="solid"
|
||||
size="sm"
|
||||
className="image-canvas-editor__reference-pick-toast"
|
||||
role="alert"
|
||||
>
|
||||
{generationWorkflow.referencePickWarning}
|
||||
</PlatformRuntimeStatusToast>
|
||||
) : null}
|
||||
<ImageCanvasGenerationComposerView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
publicationReferenceButtonRef={publicationReferenceButtonRef}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasTool,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
SidebarPanel,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
@@ -22,9 +23,18 @@ const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorImageReference', () => ({
|
||||
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
|
||||
}));
|
||||
vi.mock('../../services/image-editor/editorImageReference', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/image-editor/editorImageReference')
|
||||
>('../../services/image-editor/editorImageReference');
|
||||
return {
|
||||
...actual,
|
||||
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
|
||||
resolveEditorImageReferenceDataUrlForGeneration: vi.fn(
|
||||
async (src: string) => src,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -107,6 +117,7 @@ function GenerationWorkflowHarness({
|
||||
const fitLayersMockRef = useRef(vi.fn());
|
||||
const layerCounterRef = useRef(0);
|
||||
const dialogs = useCanvasGenerationDialogs();
|
||||
const activeDialogRef = useRef<GenerateDialogState | null>(null);
|
||||
const workflow = useImageCanvasGenerationWorkflow({
|
||||
layers,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
@@ -137,6 +148,7 @@ function GenerationWorkflowHarness({
|
||||
});
|
||||
|
||||
const activeDialog = dialogs.generateDialog;
|
||||
activeDialogRef.current = activeDialog;
|
||||
const activeCanvasDialog =
|
||||
activeDialog && 'id' in activeDialog
|
||||
? (activeDialog as CanvasGenerationDialogState)
|
||||
@@ -191,6 +203,9 @@ function GenerationWorkflowHarness({
|
||||
<span data-testid="ui-design-spec-reference">
|
||||
{activeDialog?.uiDesignSpecReference?.label ?? '-'}
|
||||
</span>
|
||||
<span data-testid="reference-pick-warning">
|
||||
{workflow.referencePickWarning ?? '-'}
|
||||
</span>
|
||||
<span data-testid="quick-edit">
|
||||
{workflow.quickEditPanel
|
||||
? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}`
|
||||
@@ -388,8 +403,9 @@ function GenerationWorkflowHarness({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (activeDialog) {
|
||||
void workflow.submitImageGeneration(activeDialog);
|
||||
const latestDialog = activeDialogRef.current;
|
||||
if (latestDialog) {
|
||||
void workflow.submitImageGeneration(latestDialog);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -421,6 +437,22 @@ function GenerationWorkflowHarness({
|
||||
>
|
||||
选择角色规范
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.pickCharacterSpecFromLayer(
|
||||
createLayer({
|
||||
id: 'layer-character-image',
|
||||
resourceId: 'resource-character-image',
|
||||
title: '角色形象 2',
|
||||
src: 'data:image/png;base64,character-image',
|
||||
assetKind: 'character',
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
选择非规范角色图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -437,6 +469,22 @@ function GenerationWorkflowHarness({
|
||||
>
|
||||
选择图标规范
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
workflow.pickIconSpecFromLayer(
|
||||
createLayer({
|
||||
id: 'layer-icon-image',
|
||||
resourceId: 'resource-icon-image',
|
||||
title: '图标素材 2',
|
||||
src: 'data:image/png;base64,icon-image',
|
||||
assetKind: 'icon',
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
选择非图标规范图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -768,11 +816,21 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith({
|
||||
prompt: '金币跳出音',
|
||||
model: 'audio1.0',
|
||||
duration: 8,
|
||||
});
|
||||
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '金币跳出音',
|
||||
model: 'audio1.0',
|
||||
duration: 8,
|
||||
generationInputs: {
|
||||
fields: [
|
||||
{ title: 'prompt', value: '金币跳出音' },
|
||||
{ title: 'model', value: 'audio1.0' },
|
||||
{ title: 'duration', value: '8秒' },
|
||||
],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
@@ -797,24 +855,22 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
|
||||
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '一张生成图',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
projectId: undefined,
|
||||
assetFolderId: undefined,
|
||||
assetKind: undefined,
|
||||
assetLabel: '生成图片 1',
|
||||
generationInputs: {
|
||||
fields: [{ title: '生成提示词', value: '一张生成图' }],
|
||||
references: [],
|
||||
},
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '一张生成图',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
projectId: undefined,
|
||||
assetFolderId: undefined,
|
||||
assetKind: undefined,
|
||||
assetLabel: '生成图片 1',
|
||||
generationInputs: {
|
||||
fields: [{ title: '生成提示词', value: '一张生成图' }],
|
||||
references: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:generating:closed:-:placeholder',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-generated-1:生成图片 1',
|
||||
@@ -894,6 +950,26 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and keeps character spec empty when picked layer is not a spec image', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' }));
|
||||
|
||||
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
|
||||
'-',
|
||||
);
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'选择的图片不是角色规范图,请选择生成规范里的角色规范。',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
|
||||
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
|
||||
'上一张角色规范',
|
||||
);
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('restores the last picked character spec from local cache after remounting the editor', () => {
|
||||
const firstRender = render(<GenerationWorkflowHarness />);
|
||||
|
||||
@@ -967,6 +1043,24 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and keeps icon spec empty when picked layer is not an icon spec image', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择非图标规范图' }));
|
||||
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'选择的图片不是图标规范图,请选择生成规范里的图标规范。',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
|
||||
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
|
||||
'上一张图标规范',
|
||||
);
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('restores the last picked icon spec from local cache for icon and UI design dialogs', () => {
|
||||
const firstRender = render(<GenerationWorkflowHarness />);
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
createUiDesignGenerationDialogDraft,
|
||||
createVideoGenerationDialogDraft,
|
||||
hideGeneratedLayerComposerAfterBlur,
|
||||
isCharacterSpecReferenceLayer,
|
||||
isIconSpecReferenceLayer,
|
||||
updateCharacterAnimationDurationPanel,
|
||||
updateIconDescriptionsTextInDialog,
|
||||
updateSpecFormDialogValue,
|
||||
@@ -111,6 +113,11 @@ const LAST_CHARACTER_SPEC_REFERENCE_CACHE_KEY =
|
||||
'genarrative.imageCanvas.lastCharacterSpecReference';
|
||||
const LAST_ICON_SPEC_REFERENCE_CACHE_KEY =
|
||||
'genarrative.imageCanvas.lastIconSpecReference';
|
||||
const REFERENCE_PICK_WARNING_DISMISS_MS = 2600;
|
||||
const INVALID_CHARACTER_SPEC_WARNING =
|
||||
'选择的图片不是角色规范图,请选择生成规范里的角色规范。';
|
||||
const INVALID_ICON_SPEC_WARNING =
|
||||
'选择的图片不是图标规范图,请选择生成规范里的图标规范。';
|
||||
|
||||
function appendQuickEditSelectionPrompt(prompt: string, markNumber: number) {
|
||||
const nextLine = `对${markNumber}号红色圈选框里的内容做以下修改:`;
|
||||
@@ -391,6 +398,9 @@ export function useImageCanvasGenerationWorkflow({
|
||||
] = useState(false);
|
||||
const [quickEditPanel, setQuickEditPanel] =
|
||||
useState<QuickEditPanelState | null>(null);
|
||||
const [referencePickWarning, setReferencePickWarning] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [cropExpandPanel, setCropExpandPanel] =
|
||||
useState<CropExpandPanelState | null>(null);
|
||||
const [uiAssetExtractionState, setUiAssetExtractionState] =
|
||||
@@ -482,6 +492,16 @@ export function useImageCanvasGenerationWorkflow({
|
||||
}
|
||||
}, [generateDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!referencePickWarning) {
|
||||
return undefined;
|
||||
}
|
||||
const warningTimer = window.setTimeout(() => {
|
||||
setReferencePickWarning(null);
|
||||
}, REFERENCE_PICK_WARNING_DISMISS_MS);
|
||||
return () => window.clearTimeout(warningTimer);
|
||||
}, [referencePickWarning]);
|
||||
|
||||
const quickEditSourceLayer = quickEditPanel
|
||||
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
|
||||
null)
|
||||
@@ -1093,6 +1113,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
|
||||
const pickCharacterSpecFromLayer = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (!isCharacterSpecReferenceLayer(layer)) {
|
||||
setReferencePickWarning(INVALID_CHARACTER_SPEC_WARNING);
|
||||
return;
|
||||
}
|
||||
setReferencePickWarning(null);
|
||||
setGenerateDialog((currentDialog) => {
|
||||
const nextDialog = assignCharacterSpecReference(currentDialog, layer);
|
||||
if (
|
||||
@@ -1147,6 +1172,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
|
||||
const pickIconSpecFromLayer = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (!isIconSpecReferenceLayer(layer)) {
|
||||
setReferencePickWarning(INVALID_ICON_SPEC_WARNING);
|
||||
return;
|
||||
}
|
||||
setReferencePickWarning(null);
|
||||
const nextDialog = assignIconSpecReference(generateDialog, layer);
|
||||
if (nextDialog?.mode === 'icon' && nextDialog.iconSpecReference) {
|
||||
lastIconSpecReferenceRef.current = nextDialog.iconSpecReference;
|
||||
@@ -1164,6 +1194,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
|
||||
const pickUiDesignSpecFromLayer = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (!isIconSpecReferenceLayer(layer)) {
|
||||
setReferencePickWarning(INVALID_ICON_SPEC_WARNING);
|
||||
return;
|
||||
}
|
||||
setReferencePickWarning(null);
|
||||
const nextDialog = assignUiDesignSpecReference(generateDialog, layer);
|
||||
if (
|
||||
nextDialog?.mode === 'ui-design' &&
|
||||
@@ -1674,6 +1709,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setIsPublicationReferenceMenuOpen,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
setIsPickingPublicationReferenceFromCanvas,
|
||||
referencePickWarning,
|
||||
clearReferencePickWarning: () => setReferencePickWarning(null),
|
||||
openGenerateDialog,
|
||||
openSpecDialog,
|
||||
openCharacterAnimationPanel,
|
||||
@@ -1752,6 +1789,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
isPublicationReferenceMenuOpen,
|
||||
isSpecMenuOpen,
|
||||
isPickingPublicationReferenceFromCanvas,
|
||||
referencePickWarning,
|
||||
openBackgroundMusicGenerationDialog,
|
||||
openCharacterAnimationPanel,
|
||||
openCharacterGenerationDialog,
|
||||
|
||||
@@ -327,6 +327,13 @@ const PLATFORM_DESKTOP_NAV_TABS: PlatformNavTab[] = [
|
||||
'projects',
|
||||
'profile',
|
||||
];
|
||||
const GUEST_DESKTOP_NAV_TABS: PlatformNavTab[] = [
|
||||
'home',
|
||||
'category',
|
||||
'create',
|
||||
'projects',
|
||||
'profile',
|
||||
];
|
||||
const HIDDEN_DESKTOP_CREATION_NAV_TABS = new Set<PlatformNavTab>([
|
||||
'home',
|
||||
'category',
|
||||
@@ -2903,7 +2910,7 @@ export function RpgEntryHomeView({
|
||||
() =>
|
||||
(isAuthenticated
|
||||
? PLATFORM_DESKTOP_NAV_TABS
|
||||
: ['home', 'category', 'create', 'projects', 'profile']
|
||||
: GUEST_DESKTOP_NAV_TABS
|
||||
).filter((tab) => !HIDDEN_DESKTOP_CREATION_NAV_TABS.has(tab)),
|
||||
[isAuthenticated],
|
||||
);
|
||||
|
||||
+32
-11
@@ -3202,18 +3202,29 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.62),
|
||||
transparent
|
||||
),
|
||||
rgba(226, 232, 240, 0.72);
|
||||
animation: image-canvas-generation-scan 1.45s linear infinite;
|
||||
background: rgba(226, 232, 240, 0.72);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.project-gallery__canvas-cover-loading::before,
|
||||
.image-canvas-editor__layer-image-loading::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -64%;
|
||||
width: 64%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.62),
|
||||
transparent
|
||||
);
|
||||
content: '';
|
||||
transform: translateX(-100%);
|
||||
animation: image-canvas-generation-scan 1.45s linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.project-gallery__canvas-cover-image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -5771,10 +5782,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
@keyframes image-canvas-generation-scan {
|
||||
from {
|
||||
transform: translateX(-65%);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(65%);
|
||||
transform: translateX(260%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8379,6 +8390,16 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-pick-toast {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
z-index: 72;
|
||||
max-width: min(24rem, calc(100% - 2rem));
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__generate-submit {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,31 @@ function getCssBlock(source: string, selector: string) {
|
||||
}
|
||||
|
||||
describe('index stylesheet unread dots', () => {
|
||||
it('keeps image canvas loading shimmer clipped inside media frames', () => {
|
||||
const css = readIndexCss();
|
||||
const loadingBlock = getCssBlock(
|
||||
css,
|
||||
'.project-gallery__canvas-cover-loading,\n.image-canvas-editor__layer-image-loading',
|
||||
);
|
||||
const shimmerBlock = getCssBlock(
|
||||
css,
|
||||
'.project-gallery__canvas-cover-loading::before,\n.image-canvas-editor__layer-image-loading::before',
|
||||
);
|
||||
const keyframesBlock = getCssBlock(css, '@keyframes image-canvas-generation-scan');
|
||||
|
||||
expect(loadingBlock).toContain('overflow: hidden;');
|
||||
expect(loadingBlock).toContain('background: rgba(226, 232, 240, 0.72);');
|
||||
expect(loadingBlock).not.toContain('animation: image-canvas-generation-scan');
|
||||
|
||||
expect(shimmerBlock).toContain('left: -64%;');
|
||||
expect(shimmerBlock).toContain('width: 64%;');
|
||||
expect(shimmerBlock).toContain('animation: image-canvas-generation-scan');
|
||||
expect(shimmerBlock).toContain('will-change: transform;');
|
||||
|
||||
expect(keyframesBlock).toContain('translateX(-100%)');
|
||||
expect(keyframesBlock).toContain('translateX(260%)');
|
||||
});
|
||||
|
||||
it('keeps creation home primary rail styling separate from active tab styling', () => {
|
||||
const css = readIndexCss();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user