画布图片生成支持真实参考图引用
新增参考图模型:候选为当前项目已登记图片,用户参考上限普通生成 5 张、带规范前置 4 张、图集为 0 生成浮层复用资源引用输入组件,提示词里的 @ 引用直接产出当前 manifest 资产 ID 作为参考 生成任务与队列新增 referenceAssetIds,派发时以 referenceAssetIds 交给原生侧解析账号绑定 图集入口不呈现参考选择器,超限在提交前给出原因并挡住提交,不做静默截断 补充参考模型、面板接线与队列派发的定向测试
This commit is contained in:
+125
-14
@@ -4,14 +4,30 @@ import { type FormEvent, useState } from 'react';
|
||||
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
|
||||
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
|
||||
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
|
||||
import type {
|
||||
GameCreationAppAssetManifestEntry,
|
||||
GameIterationVersion,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
import { ResourceReferenceInput } from '../project-workspace/ResourceReferenceInput';
|
||||
import type {
|
||||
ChatComposerDraft,
|
||||
ChatReference,
|
||||
} from '../project-workspace/resourceReferences';
|
||||
import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel';
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_ASPECT_RATIOS,
|
||||
RESOURCE_CANVAS_ASSET_IMAGE_SIZES,
|
||||
type ResourceCanvasAssetToolAction,
|
||||
} from './resourceCanvasBottomToolbarModel';
|
||||
import {
|
||||
resourceCanvasAssetGenerationAcceptsReferences,
|
||||
resourceCanvasAssetGenerationReferenceAssets,
|
||||
resourceCanvasAssetGenerationReferenceError,
|
||||
resourceCanvasAssetGenerationReferenceIds,
|
||||
resourceCanvasAssetGenerationUserReferenceLimit,
|
||||
} from './resourceCanvasAssetGenerationReferenceModel';
|
||||
import { ResourcePromptPolishSlot } from './ResourcePromptPolishSlot';
|
||||
|
||||
export type ResourceCanvasAssetGenerationSubmitInput = {
|
||||
@@ -20,6 +36,14 @@ export type ResourceCanvasAssetGenerationSubmitInput = {
|
||||
assetName: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
/**
|
||||
* 本次生成的参考图引用(面板草稿与重开草稿的同一种形状)。
|
||||
*
|
||||
* 宿主从这里取 `resourceId`(**当前项目 manifest 的资产 ID**,按选择顺序去重)交给原生侧;
|
||||
* 原生据此读本地正式文件并按当前账号重新建立远端绑定,不接受本地路径,也不复用 manifest 里
|
||||
* 历史账号的远端 ID。
|
||||
*/
|
||||
references: ChatReference[];
|
||||
};
|
||||
|
||||
/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */
|
||||
@@ -28,6 +52,8 @@ export type ResourceCanvasAssetGenerationPanelDraft = {
|
||||
assetName: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
/** 提示词里的 `@显示名` 引用节点;参考选择器的候选项与它们同源。 */
|
||||
references: ChatReference[];
|
||||
};
|
||||
|
||||
export type ResourceCanvasAssetGenerationPanelViewProps = {
|
||||
@@ -40,6 +66,12 @@ export type ResourceCanvasAssetGenerationPanelViewProps = {
|
||||
draft?: ResourceCanvasAssetGenerationPanelDraft;
|
||||
/** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */
|
||||
error?: string | null;
|
||||
/** 当前项目的 manifest 资产:参考选择的候选集由它收口到本项目的已登记图片。 */
|
||||
assets?: readonly GameCreationAppAssetManifestEntry[];
|
||||
/** `@` 引用选择器需要项目路径来登记资源预览,与快速编辑走同一条链路。 */
|
||||
projectPath?: string;
|
||||
versions?: GameIterationVersion[];
|
||||
activeVersionId?: string | null;
|
||||
/**
|
||||
* 提交回调:**同步返回**,面板不等它的结果。
|
||||
*
|
||||
@@ -73,10 +105,17 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
action,
|
||||
draft,
|
||||
error: initialError,
|
||||
assets,
|
||||
projectPath,
|
||||
versions,
|
||||
activeVersionId,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ResourceCanvasAssetGenerationPanelViewProps) {
|
||||
const [prompt, setPrompt] = useState(draft?.prompt ?? '');
|
||||
const [references, setReferences] = useState<ChatReference[]>(
|
||||
draft?.references ?? [],
|
||||
);
|
||||
const [assetName, setAssetName] = useState(
|
||||
draft?.assetName ?? action.assetName,
|
||||
);
|
||||
@@ -90,13 +129,50 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
// 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust
|
||||
// `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。
|
||||
const promptMaxLength = resourceEditPromptMaxLength('image-reference');
|
||||
const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0;
|
||||
/**
|
||||
* 参考选择只对有真实参考能力的入口呈现。
|
||||
*
|
||||
* 图集只接受单张规范引用、图标规范本身就是权威规范图产出方:这两类入口不给选择器,
|
||||
* 原生侧同样拒绝额外参考(不是静默丢弃)。
|
||||
*/
|
||||
const referenceEnabled = resourceCanvasAssetGenerationAcceptsReferences(action);
|
||||
const referenceLimit =
|
||||
resourceCanvasAssetGenerationUserReferenceLimit(action);
|
||||
const referenceAssets = resourceCanvasAssetGenerationReferenceAssets(
|
||||
assets ?? [],
|
||||
);
|
||||
const referenceAssetIds =
|
||||
resourceCanvasAssetGenerationReferenceIds(references);
|
||||
const referenceError = resourceCanvasAssetGenerationReferenceError({
|
||||
action,
|
||||
referenceCount: referenceAssetIds.length,
|
||||
});
|
||||
const promptTooLong = prompt.trim().length > promptMaxLength;
|
||||
const promptTooLongError = promptTooLong
|
||||
? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length} 个`
|
||||
: null;
|
||||
const canSubmit =
|
||||
prompt.trim().length > 0 &&
|
||||
assetName.trim().length > 0 &&
|
||||
!referenceError &&
|
||||
!promptTooLong;
|
||||
const shownError = error ?? promptTooLongError ?? referenceError;
|
||||
const applyDraft = (next: ChatComposerDraft) => {
|
||||
setPrompt(next.text);
|
||||
setReferences(next.references);
|
||||
};
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const normalizedPrompt = prompt.trim();
|
||||
const normalizedAssetName = assetName.trim();
|
||||
if (!normalizedPrompt || !normalizedAssetName) {
|
||||
// 超限与超长在提交入口再挡一次:按钮禁用只是表现,不能当唯一防线。
|
||||
if (
|
||||
!normalizedPrompt ||
|
||||
!normalizedAssetName ||
|
||||
referenceError ||
|
||||
promptTooLong
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -108,6 +184,7 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
assetName: normalizedAssetName,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
references,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
@@ -143,16 +220,40 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
</label>
|
||||
<label>
|
||||
<span>生成提示词</span>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
rows={6}
|
||||
autoFocus
|
||||
maxLength={promptMaxLength}
|
||||
placeholder={action.promptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
/>
|
||||
{referenceEnabled ? (
|
||||
/*
|
||||
与聊天输入区、资源快速编辑同一个 `@` 引用输入区:候选、`@显示名` 文本与引用模型都
|
||||
复用那一份,所以参考图带的是**稳定资源 ID**(进而出站到当前账号绑定下的远端资源),
|
||||
不是只有名字的纯提示词。候选集只放当前项目的已登记图片。
|
||||
*/
|
||||
<div className="resource-canvas-asset-generation-prompt-input">
|
||||
<ResourceReferenceInput
|
||||
ariaLabel="生成提示词"
|
||||
value={prompt}
|
||||
references={references}
|
||||
onChange={applyDraft}
|
||||
assets={referenceAssets}
|
||||
projectPath={projectPath ?? ''}
|
||||
versions={versions}
|
||||
activeVersionId={activeVersionId}
|
||||
multiline
|
||||
rows={6}
|
||||
placeholder={`${action.promptPlaceholder}(可用 @ 选择参考图)`}
|
||||
showPolishAction={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
rows={6}
|
||||
autoFocus
|
||||
maxLength={promptMaxLength}
|
||||
placeholder={action.promptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
{action.adjustableDimensions ? (
|
||||
<div className="resource-canvas-asset-generation-dimensions">
|
||||
@@ -197,9 +298,19 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
prompt={prompt}
|
||||
applyPrompt={setPrompt}
|
||||
/>
|
||||
{error ? (
|
||||
{referenceEnabled && referenceLimit > 0 ? (
|
||||
<p
|
||||
className="resource-canvas-asset-generation-reference-hint"
|
||||
data-resource-canvas-generation-reference-count={
|
||||
referenceAssetIds.length
|
||||
}
|
||||
>
|
||||
{`参考图 ${referenceAssetIds.length}/${referenceLimit}`}
|
||||
</p>
|
||||
) : null}
|
||||
{shownError ? (
|
||||
<p className="game-resource-generation-error" role="alert">
|
||||
{error}
|
||||
{shownError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="game-resource-generation-actions">
|
||||
|
||||
+1
@@ -152,6 +152,7 @@ export function createResourceCanvasAssetGenerationQueue(
|
||||
aspectRatio: task.aspectRatio,
|
||||
imageSize: task.imageSize,
|
||||
assetName: task.assetName,
|
||||
referenceAssetIds: task.referenceAssetIds,
|
||||
outputPath: task.outputPath,
|
||||
})) as LocalProjectAssetGenerationTaskRecord;
|
||||
let started: LocalProjectAssetGenerationTaskRecord;
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { ChatReference } from '../project-workspace/resourceReferences';
|
||||
import type { ResourceCanvasAssetToolAction } from './resourceCanvasBottomToolbarModel';
|
||||
|
||||
/**
|
||||
* 参考图上限:一次生成请求里 `referenceImageSrcs` 的总数,**含**规范图。
|
||||
*
|
||||
* 与 Rust `PLATFORM_ART_MAX_REFERENCE_IMAGES` 同口径:面板负责在提交前挡住超限,原生侧再挡一次,
|
||||
* 两侧都不做截断(截断就是静默丢弃用户的选择)。
|
||||
*/
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES = 5;
|
||||
|
||||
/** 没有规范图前置时的用户参考上限。 */
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES = 5;
|
||||
|
||||
/** 有规范图前置时:权威规范图自己占掉一张,用户参考最多四张。 */
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC = 4;
|
||||
|
||||
/**
|
||||
* 不接受用户参考的生成类型。
|
||||
*
|
||||
* 目前只有 `art-spritesheet`(图集)按合同只接受单张规范引用:这类入口不呈现用户参考选择器,
|
||||
* 原生提交也会显式拒绝额外参考(不是静默丢弃)。
|
||||
*
|
||||
* 其它入口一律支持用户参考,包括 `icon-spec`(图标规范):它虽然产出权威规范图,但生成时同样
|
||||
* 可以带参考图,上限与普通生成一致。
|
||||
*/
|
||||
const RESOURCE_CANVAS_ASSET_GENERATION_REFERENCE_FREE_KINDS: readonly string[] = [
|
||||
'art-spritesheet',
|
||||
];
|
||||
|
||||
export function resourceCanvasAssetGenerationAcceptsReferences(
|
||||
action: ResourceCanvasAssetToolAction,
|
||||
): boolean {
|
||||
return !RESOURCE_CANVAS_ASSET_GENERATION_REFERENCE_FREE_KINDS.includes(
|
||||
action.assetKind,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 该入口允许用户选几张参考。
|
||||
*
|
||||
* `0` 表示不呈现参考选择器;其余值与原生侧的用户参考上限一致。
|
||||
*/
|
||||
export function resourceCanvasAssetGenerationUserReferenceLimit(
|
||||
action: ResourceCanvasAssetToolAction,
|
||||
): number {
|
||||
if (!resourceCanvasAssetGenerationAcceptsReferences(action)) {
|
||||
return 0;
|
||||
}
|
||||
return action.requiresIconSpecReference
|
||||
? RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC
|
||||
: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参考选择的候选集:**当前项目**已登记的图片。
|
||||
*
|
||||
* 直接吃当前 manifest 的 `assets`,所以候选天然限定在同一项目内;再按媒体类型收口到图片,
|
||||
* 文档、音视频、字体、代码与未落盘的占位都不进候选。清单里没有 `localPath` 的记录(例如只存在于
|
||||
* 远端画布、本地还没有文件的条目)同样排除——原生侧要读本地正式文件才能按当前账号重新上传绑定。
|
||||
*/
|
||||
export function resourceCanvasAssetGenerationReferenceAssets(
|
||||
assets: readonly GameCreationAppAssetManifestEntry[],
|
||||
): GameCreationAppAssetManifestEntry[] {
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
asset.mediaType.startsWith('image/') &&
|
||||
asset.localPath.trim().length > 0 &&
|
||||
!asset.localPath.startsWith('.agent/'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从草稿里的引用列表取出本次生成的参考资源 ID。
|
||||
*
|
||||
* 只认资源引用(运行态区域引用不是素材);顺序即用户选择顺序,重复选择同一张按一次算。
|
||||
* 这些 ID 是**当前项目 manifest 的资产 ID**,不是本地路径,也不是历史远端 ID:原生侧据此读本地
|
||||
* 正式文件并按当前账号重新建立远端绑定。
|
||||
*/
|
||||
export function resourceCanvasAssetGenerationReferenceIds(
|
||||
references: readonly ChatReference[],
|
||||
): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const reference of references) {
|
||||
if (reference.type !== 'resource') {
|
||||
continue;
|
||||
}
|
||||
const resourceId = reference.resourceId.trim();
|
||||
if (!resourceId || ids.includes(resourceId)) {
|
||||
continue;
|
||||
}
|
||||
ids.push(resourceId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交前对参考数量的判据。
|
||||
*
|
||||
* 超限时给一条能照着做的原因,而不是把多出来的引用悄悄丢掉:面板据此禁用提交,
|
||||
* 原生侧仍按同一上限再校验一次。
|
||||
*/
|
||||
export function resourceCanvasAssetGenerationReferenceError({
|
||||
action,
|
||||
referenceCount,
|
||||
}: {
|
||||
action: ResourceCanvasAssetToolAction;
|
||||
referenceCount: number;
|
||||
}): string | null {
|
||||
if (!resourceCanvasAssetGenerationAcceptsReferences(action)) {
|
||||
return null;
|
||||
}
|
||||
const limit = resourceCanvasAssetGenerationUserReferenceLimit(action);
|
||||
if (referenceCount <= limit) {
|
||||
return null;
|
||||
}
|
||||
return action.requiresIconSpecReference
|
||||
? `已选 ${referenceCount} 张参考图;该入口会带上权威规范图,用户参考最多 ${limit} 张`
|
||||
: `已选 ${referenceCount} 张参考图;最多 ${limit} 张`;
|
||||
}
|
||||
+11
@@ -41,6 +41,14 @@ export type ResourceCanvasAssetGenerationTask = {
|
||||
prompt: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
/**
|
||||
* 本次生成携带的参考图(当前项目 manifest 的资产 ID,按选择顺序去重)。
|
||||
*
|
||||
* 派发时随 `referenceAssetIds` 交给原生侧:它读本地正式文件并按当前账号重新建立远端绑定,
|
||||
* 前端不传本地路径,也不复用 manifest 里历史账号的远端资源 ID。恢复出来的历史任务(账本里
|
||||
* 没有这份本地草稿)按空列表读——账本不承诺回放当时的参考选择。
|
||||
*/
|
||||
referenceAssetIds: string[];
|
||||
outputPath: string | null;
|
||||
projectId: string;
|
||||
/** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */
|
||||
@@ -148,6 +156,7 @@ export function createResourceCanvasAssetGenerationTask(input: {
|
||||
assetName: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
referenceAssetIds?: readonly string[];
|
||||
outputPath: string | null;
|
||||
projectId: string;
|
||||
nowMillis: number;
|
||||
@@ -161,6 +170,7 @@ export function createResourceCanvasAssetGenerationTask(input: {
|
||||
prompt: input.prompt,
|
||||
aspectRatio: input.aspectRatio,
|
||||
imageSize: input.imageSize,
|
||||
referenceAssetIds: [...(input.referenceAssetIds ?? [])],
|
||||
outputPath: input.outputPath,
|
||||
projectId: input.projectId,
|
||||
dispatched: false,
|
||||
@@ -191,6 +201,7 @@ export function restoreResourceCanvasAssetGenerationTask(
|
||||
prompt: '',
|
||||
aspectRatio: '',
|
||||
imageSize: '',
|
||||
referenceAssetIds: [],
|
||||
outputPath: null,
|
||||
projectId: record.projectId,
|
||||
dispatched: true,
|
||||
|
||||
@@ -120,6 +120,7 @@ import {
|
||||
ResourceCanvasAssetGenerationPanelView,
|
||||
type ResourceCanvasAssetGenerationSubmitInput,
|
||||
} from '../../features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
|
||||
import { resourceCanvasAssetGenerationReferenceIds } from '../../features/resource-canvas/resourceCanvasAssetGenerationReferenceModel';
|
||||
import {
|
||||
createResourceCanvasAssetGenerationQueue,
|
||||
mergeResourceCanvasAssetGenerationTasksWithRecords,
|
||||
@@ -7261,6 +7262,11 @@ export default function ProjectDevelopmentView({
|
||||
assetName: input.assetName,
|
||||
aspectRatio: input.aspectRatio,
|
||||
imageSize: input.imageSize,
|
||||
// 参考图只传**当前 manifest 的资产 ID**:原生侧据此读本地正式文件并按当前账号重新
|
||||
// 建立远端绑定,前端既不传本地路径,也不复用 manifest 里历史账号的远端资源 ID。
|
||||
referenceAssetIds: resourceCanvasAssetGenerationReferenceIds(
|
||||
input.references,
|
||||
),
|
||||
outputPath: resourceCanvasAssetGenerationOutputPath(
|
||||
action,
|
||||
context.hasIconSpecReference,
|
||||
@@ -7276,6 +7282,7 @@ export default function ProjectDevelopmentView({
|
||||
assetName: input.assetName,
|
||||
aspectRatio: input.aspectRatio,
|
||||
imageSize: input.imageSize,
|
||||
references: input.references,
|
||||
},
|
||||
dispatchedImmediately,
|
||||
};
|
||||
@@ -8849,6 +8856,11 @@ export default function ProjectDevelopmentView({
|
||||
: 'fresh'
|
||||
}`}
|
||||
action={resourceAssetGenerationAction}
|
||||
// 参考选择的候选集来自当前项目 manifest,收口到已登记图片;与快速编辑同一份 `@` 链路。
|
||||
assets={manifest.assets}
|
||||
projectPath={projectPath}
|
||||
versions={projectVersions}
|
||||
activeVersionId={activeVersionId}
|
||||
draft={
|
||||
resourceAssetGenerationPanelReopen?.actionId ===
|
||||
resourceAssetGenerationAction.id
|
||||
|
||||
@@ -79,6 +79,31 @@ describe('生成任务模型', () => {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE,
|
||||
);
|
||||
expect(task.restored).toBe(false);
|
||||
// 没有选择参考图时就是空列表:原生侧按「没有参考」处理,前端不编造引用。
|
||||
expect(task.referenceAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
test('参考图资产 ID 随任务保存,重开项目恢复出来的历史任务不带参考选择', () => {
|
||||
const task = createResourceCanvasAssetGenerationTask({
|
||||
taskId: 'task-ref',
|
||||
action: uiPrototypeAction,
|
||||
prompt: '主界面',
|
||||
assetName: 'UI',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
referenceAssetIds: ['asset-a', 'asset-b', 'asset-a'],
|
||||
outputPath: null,
|
||||
projectId: 'project-1',
|
||||
nowMillis: 10,
|
||||
});
|
||||
expect(task.referenceAssetIds).toEqual(['asset-a', 'asset-b', 'asset-a']);
|
||||
|
||||
const restored = applyLocalProjectAssetGenerationRecords(
|
||||
[],
|
||||
[record('task-ref', 'completed')],
|
||||
);
|
||||
expect(restored).toHaveLength(1);
|
||||
expect(restored[0]?.referenceAssetIds).toEqual([]);
|
||||
});
|
||||
|
||||
test('有在途任务时下一条不可派发,前一条终态后才轮到它', () => {
|
||||
@@ -348,6 +373,29 @@ describe('本地排队驱动器', () => {
|
||||
).toBe('asset-task-b');
|
||||
});
|
||||
|
||||
test('派发把参考图资产 ID 原样交给原生侧', async () => {
|
||||
const harness = createHarness((pollCount, api) => {
|
||||
if (pollCount === 1) {
|
||||
api.advance('task-ref', 'completed');
|
||||
}
|
||||
});
|
||||
const queue = createResourceCanvasAssetGenerationQueue(harness.deps);
|
||||
|
||||
await queue.submit({
|
||||
...localTask('task-ref', 10),
|
||||
referenceAssetIds: ['asset-a', 'asset-b'],
|
||||
});
|
||||
|
||||
const startCall = harness.invoke.mock.calls.find(
|
||||
([command]) => command === 'start_local_project_asset_generation',
|
||||
);
|
||||
expect(startCall?.[1]).toMatchObject({
|
||||
taskId: 'task-ref',
|
||||
// 只传当前 manifest 的资产 ID:不传本地路径,也不传历史远端 ID。
|
||||
referenceAssetIds: ['asset-a', 'asset-b'],
|
||||
});
|
||||
});
|
||||
|
||||
test('账本里找不到这条任务时不会无限轮询,收口为失败并放行后面的排队任务', async () => {
|
||||
const harness = createHarness((pollCount, api) => {
|
||||
if (pollCount === 1) {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
ResourceCanvasAssetGenerationPanelView,
|
||||
type ResourceCanvasAssetGenerationSubmitInput,
|
||||
} from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES,
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES,
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC,
|
||||
resourceCanvasAssetGenerationAcceptsReferences,
|
||||
resourceCanvasAssetGenerationReferenceAssets,
|
||||
resourceCanvasAssetGenerationReferenceError,
|
||||
resourceCanvasAssetGenerationReferenceIds,
|
||||
resourceCanvasAssetGenerationUserReferenceLimit,
|
||||
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel';
|
||||
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
|
||||
import {
|
||||
type ChatReference,
|
||||
resourceReferenceFromAsset,
|
||||
} from '../src/features/project-workspace/resourceReferences';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function action(
|
||||
overrides: Partial<ResourceCanvasAssetToolAction> &
|
||||
Pick<ResourceCanvasAssetToolAction, 'id' | 'label' | 'assetKind'>,
|
||||
): ResourceCanvasAssetToolAction {
|
||||
return {
|
||||
route: 'asset',
|
||||
audioKind: null,
|
||||
assetName: `素材 ${overrides.label}`,
|
||||
promptPlaceholder: '描述要生成什么',
|
||||
adjustableDimensions: true,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
requiresIconSpecReference: false,
|
||||
writesIconSpecReference: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const imageAction = action({
|
||||
id: 'generate-image',
|
||||
label: '生成图片',
|
||||
assetKind: 'image',
|
||||
});
|
||||
const iconSpecAction = action({
|
||||
id: 'generate-spec-icon',
|
||||
label: '图标规范',
|
||||
assetKind: 'icon-spec',
|
||||
adjustableDimensions: false,
|
||||
writesIconSpecReference: true,
|
||||
});
|
||||
const uiPrototypeAction = action({
|
||||
id: 'generate-ui-prototype',
|
||||
label: '生成 UI 设计图',
|
||||
assetKind: 'ui-prototype',
|
||||
requiresIconSpecReference: true,
|
||||
});
|
||||
const spritesheetAction = action({
|
||||
id: 'generate-icon-spritesheet',
|
||||
label: '生成图标素材',
|
||||
assetKind: 'art-spritesheet',
|
||||
requiresIconSpecReference: true,
|
||||
});
|
||||
|
||||
function asset(
|
||||
id: string,
|
||||
mediaType: string,
|
||||
localPath: string,
|
||||
kind = 'image',
|
||||
): GameCreationAppAssetManifestEntry {
|
||||
return { id, kind, mediaType, localPath, source: { kind: 'canvas' } };
|
||||
}
|
||||
|
||||
function resourceReference(
|
||||
resourceId: string,
|
||||
label: string,
|
||||
mediaType = 'image/png',
|
||||
): ChatReference {
|
||||
return {
|
||||
type: 'resource',
|
||||
resourceId,
|
||||
kind: 'image',
|
||||
mediaType,
|
||||
label,
|
||||
category: 'scene',
|
||||
tags: [],
|
||||
source: 'asset-picker',
|
||||
};
|
||||
}
|
||||
|
||||
describe('参考图模型', () => {
|
||||
test('只有图集不接受用户参考,图标规范与普通生成一致', () => {
|
||||
expect(resourceCanvasAssetGenerationAcceptsReferences(imageAction)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resourceCanvasAssetGenerationAcceptsReferences(iconSpecAction)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resourceCanvasAssetGenerationAcceptsReferences(uiPrototypeAction)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
resourceCanvasAssetGenerationAcceptsReferences(spritesheetAction),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('用户参考上限:普通生成五张,自带规范前置的入口四张,图集零张', () => {
|
||||
expect(resourceCanvasAssetGenerationUserReferenceLimit(imageAction)).toBe(
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES,
|
||||
);
|
||||
expect(resourceCanvasAssetGenerationUserReferenceLimit(iconSpecAction)).toBe(
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES,
|
||||
);
|
||||
expect(
|
||||
resourceCanvasAssetGenerationUserReferenceLimit(uiPrototypeAction),
|
||||
).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC);
|
||||
expect(
|
||||
resourceCanvasAssetGenerationUserReferenceLimit(spritesheetAction),
|
||||
).toBe(0);
|
||||
// 规范前置入口的四张用户参考 + 一张权威规范图,正好是总上限。
|
||||
expect(
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + 1,
|
||||
).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES);
|
||||
});
|
||||
|
||||
test('候选集只留当前项目已登记的图片', () => {
|
||||
const candidates = resourceCanvasAssetGenerationReferenceAssets([
|
||||
asset('asset-image', 'image/png', 'assets/a.png'),
|
||||
asset('asset-doc', 'text/markdown', 'assets/a.md', 'document'),
|
||||
asset('asset-audio', 'audio/mpeg', 'assets/a.mp3', 'sound-effect'),
|
||||
asset('asset-hidden', 'image/png', '.agent/runtime/a.png'),
|
||||
asset('asset-remote-only', 'image/png', ' '),
|
||||
]);
|
||||
expect(candidates.map((item) => item.id)).toEqual(['asset-image']);
|
||||
});
|
||||
|
||||
test('参考 ID 只取资源引用、按选择顺序去重', () => {
|
||||
expect(
|
||||
resourceCanvasAssetGenerationReferenceIds([
|
||||
resourceReference('asset-a', '素材 A'),
|
||||
{
|
||||
type: 'runtime-region',
|
||||
label: '运行区域',
|
||||
resourceIds: ['asset-x'],
|
||||
source: 'runtime-picker',
|
||||
},
|
||||
resourceReference('asset-b', '素材 B'),
|
||||
resourceReference('asset-a', '素材 A 重名'),
|
||||
resourceReference(' ', '空身份'),
|
||||
]),
|
||||
).toEqual(['asset-a', 'asset-b']);
|
||||
});
|
||||
|
||||
test('超限给出可执行原因而不是静默截断', () => {
|
||||
expect(
|
||||
resourceCanvasAssetGenerationReferenceError({
|
||||
action: imageAction,
|
||||
referenceCount: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resourceCanvasAssetGenerationReferenceError({
|
||||
action: imageAction,
|
||||
referenceCount: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES + 1,
|
||||
}),
|
||||
).toContain('最多 5 张');
|
||||
expect(
|
||||
resourceCanvasAssetGenerationReferenceError({
|
||||
action: uiPrototypeAction,
|
||||
referenceCount:
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + 1,
|
||||
}),
|
||||
).toContain('最多 4 张');
|
||||
// 图集没有参考选择器:不存在「超限」这条用户可见反馈。
|
||||
expect(
|
||||
resourceCanvasAssetGenerationReferenceError({
|
||||
action: spritesheetAction,
|
||||
referenceCount: 3,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('生成面板的参考接线', () => {
|
||||
test('图片入口呈现参考计数与引用输入区,提交带回引用', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>();
|
||||
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
|
||||
const references = [
|
||||
resourceReferenceFromAsset(imageAsset, 'asset-picker'),
|
||||
];
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationPanelView
|
||||
action={imageAction}
|
||||
assets={[imageAsset]}
|
||||
projectPath="/tmp/project"
|
||||
draft={{
|
||||
// 重开草稿的提示词与引用是同一次编辑的产物:`@显示名` 已经在正文里。
|
||||
prompt: '画一只猫 @素材-a',
|
||||
assetName: '猫',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
references,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
expect(panel.textContent).toContain('参考图 1/5');
|
||||
// 引用输入区与聊天 / 快速编辑同一份组件:可访问名就是「生成提示词」。
|
||||
expect(screen.getByLabelText('生成提示词')).not.toBeNull();
|
||||
|
||||
await user.click(
|
||||
within(panel).getByRole('button', { name: '生成图片' }),
|
||||
);
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
|
||||
kind: 'image',
|
||||
prompt: '画一只猫 @素材-a',
|
||||
references: [expect.objectContaining({ resourceId: 'asset-a' })],
|
||||
});
|
||||
});
|
||||
|
||||
test('图集入口不呈现参考选择,只提交纯提示词', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>();
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationPanelView
|
||||
action={spritesheetAction}
|
||||
assets={[asset('asset-a', 'image/png', 'assets/a.png')]}
|
||||
draft={{
|
||||
prompt: '一套敌人头像',
|
||||
assetName: '图标素材',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
references: [],
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(panel.textContent).not.toContain('参考图');
|
||||
const prompt = screen.getByLabelText('生成提示词');
|
||||
expect((prompt as HTMLTextAreaElement).tagName).toBe('TEXTAREA');
|
||||
|
||||
await user.click(
|
||||
within(panel).getByRole('button', { name: '生成图标素材' }),
|
||||
);
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
|
||||
kind: 'art-spritesheet',
|
||||
references: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('超限时提交被挡住并给出原因', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>();
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationPanelView
|
||||
action={uiPrototypeAction}
|
||||
assets={[asset('asset-a', 'image/png', 'assets/a.png')]}
|
||||
draft={{
|
||||
prompt: '主界面',
|
||||
assetName: 'UI',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
references: ['a', 'b', 'c', 'd', 'e'].map((id) =>
|
||||
resourceReference(`asset-${id}`, `素材 ${id}`),
|
||||
),
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' });
|
||||
expect(panel.textContent).toContain('参考图 5/4');
|
||||
const submit = within(panel).getByRole('button', {
|
||||
name: '生成 UI 设计图',
|
||||
});
|
||||
expect((submit as HTMLButtonElement).disabled).toBe(true);
|
||||
await user.click(submit);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user