修复画布生成参数复用问题
强化持久化生成输入的运行时结构校验 统一快速编辑请求尺寸、模型、参考图与生成快照 补齐视频联网搜索参数保存与恢复 隐藏缺少必需来源的复用参数入口 补充回归测试并同步编辑器方案文档
This commit is contained in:
@@ -113,7 +113,7 @@
|
||||
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
|
||||
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
|
||||
- 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
|
||||
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时拒绝改造。有效 V2 不得因引用缺失降级到 legacy。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。独立非生成型裁扩、手动去背景和手动图集拆分结果不继承 `generationInputs`,不显示该动作;原生成任务内自动后处理的同源产物可保留原生成输入。
|
||||
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的动作在 source 缺失时不显示改造入口,运行期间来源变化时仍拒绝改造。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑仍不得提交未展示的隐藏参考图。视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。独立非生成型裁扩、手动去背景和手动图集拆分结果不继承 `generationInputs`,不显示该动作;原生成任务内自动后处理的同源产物可保留原生成输入。
|
||||
- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。
|
||||
|
||||
## 画布保存
|
||||
@@ -190,7 +190,7 @@
|
||||
- 图片快速编辑底部左侧展示比例 / 尺寸组合选择,右侧展示模型选择和 `修改` 按钮;原图或红框序号标注图作为 `sourceImageSrc` 直接编辑,不展示额外参考图条。图标与图集素材不展示快速编辑入口,图标规范仍可快速编辑。
|
||||
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`。
|
||||
- 快速编辑提交前保留提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用,不再改写成 `图N`。
|
||||
- 快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`。
|
||||
- 普通快速编辑提交给后端时只把原图或已绘制红框和序号的标注图作为 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`;从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图除外。
|
||||
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
|
||||
- 快速编辑不创建生成中占位图;提交后当前面板显示修改中,异步结果只允许回填到源图。
|
||||
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
|
||||
|
||||
@@ -860,6 +860,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
{ id: 'resolution', title: '显示快照', value: '1080p' },
|
||||
{ id: 'durationSeconds', title: '显示快照', value: 8 },
|
||||
{ id: 'sound', title: '显示快照', value: 'off' },
|
||||
{ id: 'webSearchEnabled', title: '显示快照', value: false },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
@@ -888,6 +889,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
videoResolution: '1080p',
|
||||
videoDurationSeconds: 8,
|
||||
videoSound: 'off',
|
||||
videoWebSearchEnabled: false,
|
||||
generationReferences: [
|
||||
expect.objectContaining({
|
||||
id: 'canvas-layer-image-reference',
|
||||
@@ -1005,6 +1007,67 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores quick-edit references and the persisted image model', () => {
|
||||
const editSource = createLayer({
|
||||
id: 'layer-edit-source',
|
||||
resourceId: 'resource-edit-source',
|
||||
});
|
||||
const styleReference = createLayer({
|
||||
id: 'layer-style-reference',
|
||||
resourceId: 'resource-style-reference',
|
||||
title: '风格参考',
|
||||
});
|
||||
|
||||
expect(
|
||||
createLayerGenerationDialogDraft({
|
||||
sourceLayer: createLayer({
|
||||
id: 'layer-edit-result',
|
||||
resourceId: 'resource-edit-result',
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'image.edit',
|
||||
fields: [
|
||||
{ id: 'prompt', title: '提示词', value: '改成雨天' },
|
||||
{ id: 'model', title: '模型', value: IMAGE_MODEL_NANOBANANA2 },
|
||||
{ id: 'aspectRatio', title: '比例', value: '16:9' },
|
||||
{ id: 'imageSize', title: '尺寸', value: '2K' },
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '原图',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-edit-source',
|
||||
},
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考图 1',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-style-reference',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
availableLayers: [editSource, styleReference],
|
||||
canvasSize: { width: 960, height: 720 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'quick-edit',
|
||||
prompt: '改成雨天',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
generationReferences: [
|
||||
expect.objectContaining({
|
||||
id: 'canvas-layer-style-reference',
|
||||
label: '风格参考',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the V2 source reference ahead of a conflicting legacy animation source', () => {
|
||||
const v2Source = createLayer({
|
||||
id: 'layer-v2-character',
|
||||
|
||||
@@ -782,6 +782,15 @@ function getNormalizedNumber(
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function getNormalizedBoolean(
|
||||
sourceLayer: CanvasLayer,
|
||||
id: string,
|
||||
fallback: boolean,
|
||||
) {
|
||||
const value = getNormalizedFieldValue(sourceLayer, id);
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function findAvailableGenerationReferenceLayer(
|
||||
reference: CanvasGenerationInputReference,
|
||||
availableLayers: CanvasLayer[] | undefined,
|
||||
@@ -1028,6 +1037,11 @@ function createNormalizedGenerationDialogDraft({
|
||||
'sound',
|
||||
DEFAULT_VIDEO_SOUND,
|
||||
) as GenerateDialogState['videoSound'],
|
||||
videoWebSearchEnabled: getNormalizedBoolean(
|
||||
sourceLayer,
|
||||
'webSearchEnabled',
|
||||
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
),
|
||||
generationReferences: [
|
||||
...resolveNormalizedReferences(
|
||||
sourceLayer,
|
||||
@@ -1152,6 +1166,7 @@ function createNormalizedGenerationDialogDraft({
|
||||
...createQuickEditGenerationDialogDraft({
|
||||
sourceLayer: sourceLayerForEdit,
|
||||
prompt,
|
||||
references,
|
||||
model: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL),
|
||||
aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'),
|
||||
imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'),
|
||||
@@ -1749,6 +1764,7 @@ export function createQuickEditGenerationDialogDraft({
|
||||
assetLabel,
|
||||
status = 'idle',
|
||||
references = [],
|
||||
model = IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
frame,
|
||||
@@ -1763,6 +1779,11 @@ export function createQuickEditGenerationDialogDraft({
|
||||
imageSize?: string;
|
||||
frame?: { width: number; height: number };
|
||||
}): Omit<CanvasGenerationDialogState, 'id'> {
|
||||
const resolvedOptions = createQuickEditPanelDraft(sourceLayer, {
|
||||
imageModel: model,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
});
|
||||
const frameWidth = frame?.width ?? sourceLayer.width;
|
||||
const frameHeight = frame?.height ?? sourceLayer.height;
|
||||
return {
|
||||
@@ -1773,9 +1794,9 @@ export function createQuickEditGenerationDialogDraft({
|
||||
composerOpen: false,
|
||||
sourceLayerId: sourceLayer.id,
|
||||
generationReferences: references.slice(0, 8),
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
imageModel: resolvedOptions.model,
|
||||
aspectRatio: resolvedOptions.aspectRatio,
|
||||
imageSize: resolvedOptions.imageSize,
|
||||
placeholder: {
|
||||
x: sourceLayer.x + sourceLayer.width + 32,
|
||||
y: sourceLayer.y,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
buildSpecGenerationInputs,
|
||||
buildSpecPrompt,
|
||||
buildUiDesignAssetExtractionGenerationInputs,
|
||||
canOpenRedrawPanel,
|
||||
buildVideoGenerationInputs,
|
||||
calculateCharacterAnimationPrice,
|
||||
calculateEditorBackgroundMusicPrice,
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
calculateEditorSpecGenerationPrice,
|
||||
calculateEditorUiDesignPrice,
|
||||
calculateEditorVideoPrice,
|
||||
canOpenRedrawPanel,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
DEFAULT_IMAGE_MODEL,
|
||||
DEFAULT_SOUND_EFFECT_MODEL,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
getGenerationFrameLabel,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
isNormalizedCanvasGenerationInputs,
|
||||
isQuickEditUnsupportedAssetKind,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
@@ -493,6 +495,54 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed persisted generation metadata without throwing', () => {
|
||||
expect(
|
||||
isNormalizedCanvasGenerationInputs({
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: [{ id: 42, title: '生成提示词', value: '城堡' } as never],
|
||||
references: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isNormalizedCanvasGenerationInputs({
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: [{ id: 'prompt', title: '生成提示词', value: '城堡' }],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '原图',
|
||||
refType: 'project-resource',
|
||||
refId: 7,
|
||||
} as never,
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(() =>
|
||||
canOpenRedrawPanel({
|
||||
...buildSourceLayer(),
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
fields: [{ title: 42, value: '旧数据' } as never],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('stores the video web-search setting in reusable metadata', () => {
|
||||
expect(
|
||||
buildVideoGenerationInputs('镜头推进', [], {
|
||||
webSearchEnabled: false,
|
||||
}).fields,
|
||||
).toContainEqual({
|
||||
id: 'webSearchEnabled',
|
||||
title: '联网搜索',
|
||||
value: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps generated prompts and quick edit options stable', () => {
|
||||
const prompt = buildSpecPrompt('ui', {
|
||||
playSetting: '消除玩法',
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
isGeneratedLayer,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasGenerationAction,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasGenerationInputField,
|
||||
CanvasGenerationInputReference,
|
||||
CanvasGenerationInputValue,
|
||||
CanvasGenerationInputs,
|
||||
CanvasGenerationInputValue,
|
||||
CanvasLayer,
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
@@ -1085,9 +1085,28 @@ export function isNormalizedCanvasGenerationInputs(
|
||||
CANVAS_GENERATION_ACTIONS.has(value.action) &&
|
||||
Array.isArray(value.fields) &&
|
||||
Array.isArray(value.references) &&
|
||||
value.fields.every((field) => Boolean(field && field.id?.trim())) &&
|
||||
value.references.every((reference) =>
|
||||
Boolean(reference && reference.id?.trim()),
|
||||
value.fields.every(
|
||||
(field) =>
|
||||
Boolean(field) &&
|
||||
typeof field.id === 'string' &&
|
||||
Boolean(field.id.trim()) &&
|
||||
typeof field.title === 'string' &&
|
||||
(typeof field.value === 'string' ||
|
||||
(typeof field.value === 'number' && Number.isFinite(field.value)) ||
|
||||
typeof field.value === 'boolean'),
|
||||
) &&
|
||||
value.references.every(
|
||||
(reference) =>
|
||||
Boolean(reference) &&
|
||||
typeof reference.id === 'string' &&
|
||||
Boolean(reference.id.trim()) &&
|
||||
typeof reference.title === 'string' &&
|
||||
(reference.label === undefined ||
|
||||
typeof reference.label === 'string') &&
|
||||
(reference.refType === 'project-resource' ||
|
||||
reference.refType === 'asset') &&
|
||||
typeof reference.refId === 'string' &&
|
||||
Boolean(reference.refId.trim()),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1114,21 +1133,47 @@ const LEGACY_REUSABLE_GENERATION_INPUT_TITLES = new Set(
|
||||
].map((title) => title.toLowerCase()),
|
||||
);
|
||||
|
||||
export function canOpenRedrawPanel(layer: CanvasLayer) {
|
||||
const REQUIRED_SOURCE_GENERATION_ACTIONS = new Set<CanvasGenerationAction>([
|
||||
'image.edit',
|
||||
'character-animation.generate',
|
||||
'ui-design.extract-assets',
|
||||
]);
|
||||
|
||||
export function canOpenRedrawPanel(
|
||||
layer: CanvasLayer,
|
||||
availableLayers: CanvasLayer[] = [],
|
||||
) {
|
||||
if (layer.sourceType === 'uploaded') {
|
||||
return false;
|
||||
}
|
||||
if (isNormalizedCanvasGenerationInputs(layer.generationInputs)) {
|
||||
return true;
|
||||
if (
|
||||
!REQUIRED_SOURCE_GENERATION_ACTIONS.has(layer.generationInputs.action)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const sourceReference = layer.generationInputs.references.find(
|
||||
(reference) => reference.id === 'source',
|
||||
);
|
||||
if (!sourceReference) {
|
||||
return false;
|
||||
}
|
||||
return availableLayers.some((candidate) =>
|
||||
sourceReference.refType === 'project-resource'
|
||||
? candidate.resourceId === sourceReference.refId
|
||||
: candidate.sourceAssetId === sourceReference.refId,
|
||||
);
|
||||
}
|
||||
if (layer.generationInputs?.version === 2) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
layer.generationInputs?.fields.some((field) =>
|
||||
LEGACY_REUSABLE_GENERATION_INPUT_TITLES.has(
|
||||
field.title.trim().toLowerCase(),
|
||||
),
|
||||
layer.generationInputs?.fields.some(
|
||||
(field) =>
|
||||
typeof field?.title === 'string' &&
|
||||
LEGACY_REUSABLE_GENERATION_INPUT_TITLES.has(
|
||||
field.title.trim().toLowerCase(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1189,6 +1234,7 @@ export function buildVideoGenerationInputs(
|
||||
resolution?: string;
|
||||
durationSeconds?: number;
|
||||
sound?: string;
|
||||
webSearchEnabled?: boolean;
|
||||
} = {},
|
||||
): CanvasGenerationInputs {
|
||||
const mediaIndexes = {
|
||||
@@ -1205,6 +1251,11 @@ export function buildVideoGenerationInputs(
|
||||
...createGenerationInputField('清晰度', options.resolution ?? '480p', 'resolution'),
|
||||
...createGenerationInputField('时长', options.durationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS, 'durationSeconds'),
|
||||
...createGenerationInputField('声音', options.sound ?? DEFAULT_VIDEO_SOUND, 'sound'),
|
||||
...createGenerationInputField(
|
||||
'联网搜索',
|
||||
options.webSearchEnabled ?? DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
'webSearchEnabled',
|
||||
),
|
||||
],
|
||||
(references ?? []).flatMap((reference) => {
|
||||
const mediaType =
|
||||
|
||||
@@ -169,6 +169,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
id: 'style-reference',
|
||||
label: '风格参考',
|
||||
src: 'data:image/png;base64,style',
|
||||
resourceId: 'resource-style-reference',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -181,8 +182,11 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
normalizedPrompt: '把当前图改成雨天',
|
||||
sourceLayer,
|
||||
editInput: {
|
||||
size: '1537x1025',
|
||||
size: '1024x768',
|
||||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aspectRatio: '4:3',
|
||||
imageSize: '1K',
|
||||
referenceImageSrcs: ['data:image/png;base64,style'],
|
||||
},
|
||||
result: {
|
||||
title: '源图 快速编辑',
|
||||
@@ -205,6 +209,13 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-source',
|
||||
},
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考图 1',
|
||||
label: '风格参考',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-style-reference',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -842,6 +853,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
videoModel: 'seedance2.0-fast',
|
||||
videoResolution: '720p',
|
||||
videoDurationSeconds: 5,
|
||||
videoWebSearchEnabled: false,
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'image-ref',
|
||||
@@ -886,6 +898,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
kind: 'video',
|
||||
input: {
|
||||
model: 'seedance2.0-fast',
|
||||
webSearchEnabled: false,
|
||||
referenceImageSrcs: ['data:image/png;base64,image'],
|
||||
referenceVideoSrcs: [
|
||||
'generated-character-drafts/editor/seedance-references/video/video.mp4',
|
||||
@@ -898,6 +911,13 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
generationInputs: expect.objectContaining({
|
||||
version: 2,
|
||||
action: 'video.generate',
|
||||
fields: expect.arrayContaining([
|
||||
{
|
||||
id: 'webSearchEnabled',
|
||||
title: '联网搜索',
|
||||
value: false,
|
||||
},
|
||||
]),
|
||||
references: [
|
||||
{
|
||||
id: 'imageReference',
|
||||
|
||||
@@ -39,9 +39,14 @@ import {
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
DEFAULT_VIDEO_SOUND,
|
||||
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
normalizeEditorImageModel,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
SEEDANCE_VIDEO_REFERENCE_LIMITS,
|
||||
SPEC_GENERATION_ASPECT_RATIO,
|
||||
SPEC_GENERATION_IMAGE_SIZE,
|
||||
@@ -166,7 +171,14 @@ export type ImageGenerationSubmissionPlan =
|
||||
kind: 'quick-edit';
|
||||
normalizedPrompt: string;
|
||||
sourceLayer: CanvasLayer;
|
||||
editInput: Pick<EditorImageEditInput, 'size' | 'model'>;
|
||||
editInput: Pick<
|
||||
EditorImageEditInput,
|
||||
| 'size'
|
||||
| 'model'
|
||||
| 'aspectRatio'
|
||||
| 'imageSize'
|
||||
| 'referenceImageSrcs'
|
||||
>;
|
||||
result: {
|
||||
title: string;
|
||||
assetKind?: CanvasLayer['assetKind'];
|
||||
@@ -269,14 +281,64 @@ export function buildImageGenerationSubmissionPlan({
|
||||
}
|
||||
const basePrompt = dialog.prompt.trim() || '快速编辑图片';
|
||||
const normalizedQuickEditPrompt = basePrompt;
|
||||
const imageModel = IMAGE_MODEL_GPT_IMAGE_2;
|
||||
const requestedImageModel = normalizeEditorImageModel(
|
||||
dialog.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2,
|
||||
);
|
||||
const imageModel = EDITOR_IMAGE_MODEL_OPTIONS.some(
|
||||
(option) => option.value === requestedImageModel,
|
||||
)
|
||||
? requestedImageModel
|
||||
: IMAGE_MODEL_GPT_IMAGE_2;
|
||||
const dimensionOptions =
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_GPT_IMAGE_2];
|
||||
const supportedAspectRatios =
|
||||
dimensionOptions.aspectRatios as readonly string[];
|
||||
const supportedImageSizes =
|
||||
dimensionOptions.imageSizes as readonly string[];
|
||||
const inferredAspectRatio = inferEditorImageAspectRatio(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const inferredImageSize = inferEditorImageSizeLabel(
|
||||
sourceLayer.originalWidth,
|
||||
sourceLayer.originalHeight,
|
||||
);
|
||||
const aspectRatio = supportedAspectRatios.includes(dialog.aspectRatio ?? '')
|
||||
? dialog.aspectRatio!
|
||||
: supportedAspectRatios.includes(inferredAspectRatio)
|
||||
? inferredAspectRatio
|
||||
: (dimensionOptions.aspectRatios[0] ?? '1:1');
|
||||
const imageSize = supportedImageSizes.includes(dialog.imageSize ?? '')
|
||||
? dialog.imageSize!
|
||||
: supportedImageSizes.includes(inferredImageSize)
|
||||
? inferredImageSize
|
||||
: (dimensionOptions.imageSizes.find((size) => size === '1K') ??
|
||||
dimensionOptions.imageSizes[0] ??
|
||||
'1K');
|
||||
const outputSize = resolveEditorImageGenerationPixelSize({
|
||||
model: imageModel,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
});
|
||||
const references = dialog.generationReferences ?? [];
|
||||
return {
|
||||
kind: 'quick-edit',
|
||||
normalizedPrompt: normalizedQuickEditPrompt,
|
||||
sourceLayer,
|
||||
editInput: {
|
||||
size: `${sourceLayer.originalWidth}x${sourceLayer.originalHeight}`,
|
||||
size: `${outputSize.width}x${outputSize.height}`,
|
||||
model: imageModel,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
...(references.length
|
||||
? {
|
||||
referenceImageSrcs: references.map(
|
||||
resolveImageReferenceSubmissionSource,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
result: {
|
||||
title: resolveGenerationAssetLabel(
|
||||
@@ -288,11 +350,11 @@ export function buildImageGenerationSubmissionPlan({
|
||||
'快速编辑提示词',
|
||||
normalizedQuickEditPrompt,
|
||||
sourceLayer,
|
||||
[],
|
||||
references,
|
||||
{
|
||||
model: imageModel,
|
||||
aspectRatio: dialog.aspectRatio,
|
||||
imageSize: dialog.imageSize,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -531,6 +593,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
resolution,
|
||||
durationSeconds,
|
||||
sound,
|
||||
webSearchEnabled,
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
@@ -41,8 +41,10 @@ function renderSelectedToolbar(
|
||||
Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0]
|
||||
> = {},
|
||||
) {
|
||||
const selectedLayer = createLayer();
|
||||
const props: Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0] = {
|
||||
selectedLayer: createLayer(),
|
||||
selectedLayer,
|
||||
availableLayers: [selectedLayer],
|
||||
selectedToolbarStyle: { left: 12, top: 24 },
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
@@ -334,7 +336,41 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
within(actionToolbar)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['快速编辑', '改造', '下载按钮']);
|
||||
).toEqual(['快速编辑', '下载按钮']);
|
||||
});
|
||||
|
||||
it('shows redraw only when a required V2 source is available', () => {
|
||||
const sourceLayer = createLayer({
|
||||
id: 'source-layer',
|
||||
resourceId: 'source-resource',
|
||||
});
|
||||
const generatedLayer = createLayer({
|
||||
id: 'animation-layer',
|
||||
resourceId: 'animation-resource',
|
||||
mediaType: 'image-sequence',
|
||||
assetKind: 'character-animation',
|
||||
generationInputs: {
|
||||
version: 2,
|
||||
action: 'character-animation.generate',
|
||||
fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }],
|
||||
references: [
|
||||
{
|
||||
id: 'source',
|
||||
title: '原角色图',
|
||||
label: '角色',
|
||||
refType: 'project-resource',
|
||||
refId: 'source-resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
renderSelectedToolbar({
|
||||
selectedLayer: generatedLayer,
|
||||
availableLayers: [generatedLayer, sourceLayer],
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders character animation only for character layers', () => {
|
||||
@@ -388,6 +424,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
const { rerender } = render(
|
||||
<ImageCanvasSelectedLayerToolbarView
|
||||
selectedLayer={null}
|
||||
availableLayers={[]}
|
||||
selectedToolbarStyle={{ left: 0, top: 0 }}
|
||||
onOpenQuickEditPanel={vi.fn()}
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
@@ -407,6 +444,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
rerender(
|
||||
<ImageCanvasSelectedLayerToolbarView
|
||||
selectedLayer={createLayer()}
|
||||
availableLayers={[]}
|
||||
selectedToolbarStyle={null}
|
||||
onOpenQuickEditPanel={vi.fn()}
|
||||
onOpenRedrawPanel={vi.fn()}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
selectedLayer: CanvasLayer | null;
|
||||
availableLayers: CanvasLayer[];
|
||||
selectedToolbarStyle: CSSProperties | null;
|
||||
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
|
||||
onOpenRedrawPanel: (layer: CanvasLayer) => void;
|
||||
@@ -35,6 +36,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
|
||||
export function ImageCanvasSelectedLayerToolbarView({
|
||||
selectedLayer,
|
||||
availableLayers,
|
||||
selectedToolbarStyle,
|
||||
onOpenQuickEditPanel,
|
||||
onOpenRedrawPanel,
|
||||
@@ -50,7 +52,10 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
if (!selectedLayer || !selectedToolbarStyle) {
|
||||
return null;
|
||||
}
|
||||
const canRedraw = canOpenRedrawPanel(selectedLayer);
|
||||
const canRedraw = canOpenRedrawPanel(
|
||||
selectedLayer,
|
||||
availableLayers,
|
||||
);
|
||||
|
||||
if (selectedLayer.mediaType === 'audio') {
|
||||
return (
|
||||
|
||||
@@ -389,6 +389,7 @@ export function ImageCanvasStageView({
|
||||
/>
|
||||
|
||||
<ImageCanvasSelectedLayerToolbarView
|
||||
availableLayers={layers}
|
||||
selectedLayer={
|
||||
uiAssetExtractionState || quickEditSelectionState
|
||||
? null
|
||||
|
||||
Reference in New Issue
Block a user