修复画布生成配方恢复边界
兼容生成配方未来字段与历史完美像素快照 补齐参数回退、旧版恢复告警和本地参考图上传 收紧生成任务幂等比较与裁扩来源归属 同步画布编辑器设计文档和回归测试
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -216,6 +216,10 @@ fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -
|
||||
generation_inputs.remove("references").is_some()
|
||||
}
|
||||
|
||||
fn generation_input_references(value: &Value) -> Option<&Value> {
|
||||
value.pointer("/generationInputs/references")
|
||||
}
|
||||
|
||||
fn job_kind_migrated_away_from_client_generation_references(job_kind: &str) -> bool {
|
||||
matches!(
|
||||
job_kind,
|
||||
@@ -248,6 +252,11 @@ fn editor_generation_request_payloads_match(
|
||||
if !job_kind_migrated_away_from_client_generation_references(job_kind) {
|
||||
return false;
|
||||
}
|
||||
if generation_input_references(&existing).is_some()
|
||||
== generation_input_references(&requested).is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
strip_untrusted_generation_input_references_from_payload(&mut existing);
|
||||
strip_untrusted_generation_input_references_from_payload(&mut requested);
|
||||
existing == requested
|
||||
@@ -643,6 +652,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_migrated_jobs_with_different_safe_slots_conflict() {
|
||||
let mut job = queue_job_fixture("queued", None);
|
||||
job.owner_user_id = "user-1".to_string();
|
||||
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
|
||||
job.request_payload_json = serde_json::to_string(&json!({
|
||||
"prompt": "same",
|
||||
"referenceImageSrcs": ["asset-1"],
|
||||
"generationInputs": {
|
||||
"version": 2,
|
||||
"action": "image.generate",
|
||||
"fields": [],
|
||||
"references": [{"id": "source"}]
|
||||
}
|
||||
}))
|
||||
.expect("existing payload should serialize");
|
||||
let requested = serde_json::to_string(&json!({
|
||||
"prompt": "same",
|
||||
"referenceImageSrcs": ["asset-1"],
|
||||
"generationInputs": {
|
||||
"version": 2,
|
||||
"action": "image.generate",
|
||||
"fields": [],
|
||||
"references": [{"id": "specReference"}]
|
||||
}
|
||||
}))
|
||||
.expect("requested payload should serialize");
|
||||
|
||||
let error = ensure_editor_generation_job_matches_request(
|
||||
job,
|
||||
"user-1",
|
||||
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||||
requested.as_str(),
|
||||
"幂等冲突",
|
||||
)
|
||||
.expect_err("different migrated safe slots must conflict");
|
||||
assert_eq!(error.status_code(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_jobs_with_references_on_both_sides_compare_them_strictly() {
|
||||
let mut job = queue_job_fixture("queued", None);
|
||||
|
||||
@@ -2598,6 +2598,27 @@ fn build_editor_crop_expand_generation_inputs(
|
||||
build_editor_deterministic_generation_inputs("image.crop-expand", reference)
|
||||
}
|
||||
|
||||
fn require_editor_crop_expand_source<'a>(
|
||||
resources: &'a [EditorProjectResourceRecord],
|
||||
owner_user_id: &str,
|
||||
project_id: &str,
|
||||
source_resource_id: &str,
|
||||
) -> Result<&'a EditorProjectResourceRecord, AppError> {
|
||||
resources
|
||||
.iter()
|
||||
.find(|resource| {
|
||||
resource.resource_id == source_resource_id
|
||||
&& resource.project_id == project_id
|
||||
&& resource.owner_user_id == owner_user_id
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-project-resource",
|
||||
"message": "裁切扩图来源资源不存在或不属于当前项目",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_editor_project_resource_generation_inputs(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -2626,12 +2647,13 @@ async fn resolve_editor_project_resource_generation_inputs(
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
let source = project.resources.iter().find(|resource| {
|
||||
resource.resource_id == source_resource_id
|
||||
&& resource.project_id == project_id
|
||||
&& resource.owner_user_id == owner_user_id
|
||||
});
|
||||
Ok(build_editor_crop_expand_generation_inputs(source))
|
||||
let source = require_editor_crop_expand_source(
|
||||
project.resources.as_slice(),
|
||||
owner_user_id,
|
||||
project_id,
|
||||
source_resource_id,
|
||||
)?;
|
||||
Ok(build_editor_crop_expand_generation_inputs(Some(source)))
|
||||
}
|
||||
|
||||
fn editor_generated_image_storage_profile(
|
||||
@@ -13013,6 +13035,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_expand_source_must_match_owner_project_and_resource() {
|
||||
let resource = test_editor_project_resource_record(
|
||||
"resource-crop-source",
|
||||
"project-1",
|
||||
"generated-character-drafts/editor/crop-source.png",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let resources = vec![resource];
|
||||
|
||||
assert!(
|
||||
require_editor_crop_expand_source(
|
||||
resources.as_slice(),
|
||||
"user-1",
|
||||
"project-1",
|
||||
"resource-crop-source",
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
for (owner_user_id, project_id, source_resource_id) in [
|
||||
("user-2", "project-1", "resource-crop-source"),
|
||||
("user-1", "project-2", "resource-crop-source"),
|
||||
("user-1", "project-1", "resource-missing"),
|
||||
] {
|
||||
let error = require_editor_crop_expand_source(
|
||||
resources.as_slice(),
|
||||
owner_user_id,
|
||||
project_id,
|
||||
source_resource_id,
|
||||
)
|
||||
.expect_err("mismatched crop-expand source must fail closed");
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_reference_provenance_comes_from_matching_owner_records() {
|
||||
let resource = test_editor_project_resource_record(
|
||||
|
||||
@@ -119,6 +119,47 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unknown nested V2 properties during non-strict resource hydration', () => {
|
||||
expect(
|
||||
generationInputsOrNull({
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
futureTopLevelProperty: true,
|
||||
fields: [
|
||||
{
|
||||
id: 'prompt',
|
||||
title: '生成提示词',
|
||||
value: '未来配方',
|
||||
futureFieldProperty: 'ignored',
|
||||
},
|
||||
],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考图',
|
||||
label: '未来素材',
|
||||
refType: 'asset',
|
||||
refId: 'asset-future',
|
||||
futureReferenceProperty: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
version: 2,
|
||||
action: 'image.generate',
|
||||
fields: [{ id: 'prompt', title: '生成提示词', value: '未来配方' }],
|
||||
references: [
|
||||
{
|
||||
id: 'reference',
|
||||
title: '参考图',
|
||||
label: '未来素材',
|
||||
refType: 'asset',
|
||||
refId: 'asset-future',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails malformed V2 closed while preserving legacy hydration', () => {
|
||||
expect(
|
||||
generationInputsOrNull({
|
||||
@@ -1491,6 +1532,34 @@ describe('ImageCanvasEditorModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps legacy perfect-pixel snapshots with empty string metadata recoverable', () => {
|
||||
const dialogId = 'dialog-perfect-pixel-empty-legacy-metadata';
|
||||
const operation = buildPerfectPixelOperation(dialogId);
|
||||
operation.request.generationInputs = {
|
||||
fields: [{ title: '', value: '' }],
|
||||
references: [
|
||||
{
|
||||
title: '',
|
||||
label: '',
|
||||
refType: 'asset',
|
||||
refId: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const hydrated = hydrateCanvasGenerationDialog({
|
||||
id: dialogId,
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status: 'pending-confirmation',
|
||||
composerOpen: false,
|
||||
perfectPixelOperation: operation,
|
||||
});
|
||||
|
||||
expect(hydrated?.perfectPixelOperation).toEqual(operation);
|
||||
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
|
||||
});
|
||||
|
||||
it('keeps a settled perfect-pixel placeholder valid without any local ledger', () => {
|
||||
// 中文注释:服务端完成 completion 后只做字段级改写,perfectPixelOperationId 会永久留在
|
||||
// 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状,
|
||||
|
||||
@@ -171,6 +171,29 @@ describe('ImageCanvasExportModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the reference fallback label when export metadata is blank', () => {
|
||||
const metadata = buildLayerExportMetadata(
|
||||
buildLayer({
|
||||
generationInputs: {
|
||||
fields: [],
|
||||
references: [
|
||||
{
|
||||
title: '参考图',
|
||||
label: ' ',
|
||||
refType: 'asset',
|
||||
refId: 'asset-reference',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
'images/001-layer.png',
|
||||
);
|
||||
|
||||
expect(metadata.visible.generationInputs?.references[0]?.label).toBe(
|
||||
'参考素材',
|
||||
);
|
||||
});
|
||||
|
||||
it('filters built-in prompts from exported visible generation inputs', () => {
|
||||
const metadata = buildLayerExportMetadata(
|
||||
buildLayer({
|
||||
|
||||
@@ -546,7 +546,7 @@ function buildVisibleGenerationInputs(layer: CanvasLayer) {
|
||||
const references =
|
||||
layer.generationInputs?.references.map((reference) => ({
|
||||
title: reference.title,
|
||||
label: reference.label ?? '参考素材',
|
||||
label: reference.label?.trim() || '参考素材',
|
||||
refType: reference.refType,
|
||||
refId: reference.refId,
|
||||
})) ?? [];
|
||||
|
||||
@@ -24,21 +24,20 @@ export const CANVAS_GENERATION_ACTIONS = [
|
||||
'image.crop-expand',
|
||||
] as const satisfies readonly CanvasGenerationAction[];
|
||||
|
||||
export const REMIXABLE_CANVAS_GENERATION_ACTIONS =
|
||||
new Set<CanvasGenerationAction>([
|
||||
'image.generate',
|
||||
'spec.generate',
|
||||
'character.generate',
|
||||
'icon.generate',
|
||||
'ui-design.generate',
|
||||
'publication.generate',
|
||||
'video.generate',
|
||||
'audio.sound-effect.generate',
|
||||
'audio.background-music.generate',
|
||||
'character-animation.generate',
|
||||
'image.edit',
|
||||
'ui-design.extract-assets',
|
||||
]);
|
||||
const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set<CanvasGenerationAction>([
|
||||
'image.generate',
|
||||
'spec.generate',
|
||||
'character.generate',
|
||||
'icon.generate',
|
||||
'ui-design.generate',
|
||||
'publication.generate',
|
||||
'video.generate',
|
||||
'audio.sound-effect.generate',
|
||||
'audio.background-music.generate',
|
||||
'character-animation.generate',
|
||||
'image.edit',
|
||||
'ui-design.extract-assets',
|
||||
]);
|
||||
|
||||
const CANVAS_GENERATION_ACTION_SET = new Set<string>(CANVAS_GENERATION_ACTIONS);
|
||||
const V2_GENERATION_INPUT_KEYS = new Set([
|
||||
@@ -95,8 +94,9 @@ export function isCanvasGenerationAction(
|
||||
return typeof value === 'string' && CANVAS_GENERATION_ACTION_SET.has(value);
|
||||
}
|
||||
|
||||
export function isNormalizedCanvasGenerationInputsStructure(
|
||||
function hasNormalizedCanvasGenerationInputsStructure(
|
||||
value: unknown,
|
||||
strictWhitelist: boolean,
|
||||
): value is CanvasGenerationInputs & {
|
||||
version: 2;
|
||||
action: CanvasGenerationAction;
|
||||
@@ -114,7 +114,7 @@ export function isNormalizedCanvasGenerationInputsStructure(
|
||||
value.fields.every(
|
||||
(field) =>
|
||||
isRecord(field) &&
|
||||
hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS) &&
|
||||
(!strictWhitelist || hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS)) &&
|
||||
isPresentString(field.id) &&
|
||||
typeof field.title === 'string' &&
|
||||
isGenerationInputValue(field.value),
|
||||
@@ -122,7 +122,8 @@ export function isNormalizedCanvasGenerationInputsStructure(
|
||||
value.references.every(
|
||||
(reference) =>
|
||||
isRecord(reference) &&
|
||||
hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS) &&
|
||||
(!strictWhitelist ||
|
||||
hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS)) &&
|
||||
isPresentString(reference.id) &&
|
||||
typeof reference.title === 'string' &&
|
||||
(reference.label === undefined ||
|
||||
@@ -134,21 +135,46 @@ export function isNormalizedCanvasGenerationInputsStructure(
|
||||
);
|
||||
}
|
||||
|
||||
export function isNormalizedCanvasGenerationInputsStructure(
|
||||
value: unknown,
|
||||
): value is CanvasGenerationInputs & {
|
||||
version: 2;
|
||||
action: CanvasGenerationAction;
|
||||
} {
|
||||
return hasNormalizedCanvasGenerationInputsStructure(value, true);
|
||||
}
|
||||
|
||||
export function isRemixableCanvasGenerationAction(
|
||||
action: CanvasGenerationAction,
|
||||
) {
|
||||
return REMIXABLE_CANVAS_GENERATION_ACTIONS.has(action);
|
||||
}
|
||||
|
||||
function cloneV2GenerationInputs(
|
||||
value: Record<string, unknown>,
|
||||
strictWhitelist: boolean,
|
||||
): CanvasGenerationInputs | null {
|
||||
if (
|
||||
(strictWhitelist && !hasOnlyKeys(value, V2_GENERATION_INPUT_KEYS)) ||
|
||||
!isNormalizedCanvasGenerationInputsStructure(value)
|
||||
!hasNormalizedCanvasGenerationInputsStructure(value, strictWhitelist)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
action: value.action,
|
||||
fields: value.fields.map((field) => ({ ...field })),
|
||||
references: value.references.map((reference) => ({ ...reference })),
|
||||
fields: value.fields.map((field) => ({
|
||||
id: field.id,
|
||||
title: field.title,
|
||||
value: field.value,
|
||||
})),
|
||||
references: value.references.map((reference) => ({
|
||||
id: reference.id,
|
||||
title: reference.title,
|
||||
...(reference.label === undefined ? {} : { label: reference.label }),
|
||||
refType: reference.refType,
|
||||
refId: reference.refId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,32 +196,42 @@ function hydrateLegacyGenerationInputs(
|
||||
if (
|
||||
!isRecord(field) ||
|
||||
(strictWhitelist && !hasOnlyKeys(field, LEGACY_GENERATION_FIELD_KEYS)) ||
|
||||
!isPresentString(field.title) ||
|
||||
!isPresentString(field.value)
|
||||
(strictWhitelist
|
||||
? typeof field.title !== 'string'
|
||||
: !isPresentString(field.title)) ||
|
||||
(strictWhitelist
|
||||
? typeof field.value !== 'string'
|
||||
: !isPresentString(field.value))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [{ title: field.title, value: field.value }];
|
||||
return [{ title: field.title as string, value: field.value as string }];
|
||||
});
|
||||
const references = rawReferences.flatMap((reference) => {
|
||||
if (
|
||||
!isRecord(reference) ||
|
||||
(strictWhitelist &&
|
||||
!hasOnlyKeys(reference, LEGACY_GENERATION_REFERENCE_KEYS)) ||
|
||||
!isPresentString(reference.title) ||
|
||||
!isPresentString(reference.label) ||
|
||||
(strictWhitelist
|
||||
? typeof reference.title !== 'string'
|
||||
: !isPresentString(reference.title)) ||
|
||||
(strictWhitelist
|
||||
? typeof reference.label !== 'string'
|
||||
: !isPresentString(reference.label)) ||
|
||||
(reference.refType !== 'project-resource' &&
|
||||
reference.refType !== 'asset') ||
|
||||
!isPresentString(reference.refId)
|
||||
(strictWhitelist
|
||||
? typeof reference.refId !== 'string'
|
||||
: !isPresentString(reference.refId))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: reference.title,
|
||||
label: reference.label,
|
||||
title: reference.title as string,
|
||||
label: reference.label as string,
|
||||
refType: reference.refType,
|
||||
refId: reference.refId,
|
||||
refId: reference.refId as string,
|
||||
} satisfies CanvasGenerationInputReference,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -591,7 +591,10 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
],
|
||||
references: [],
|
||||
});
|
||||
expect(alias).toMatchObject({ ok: true, warnings: [] });
|
||||
expect(alias).toMatchObject({
|
||||
ok: true,
|
||||
warnings: [expect.objectContaining({ fieldIds: ['style'] })],
|
||||
});
|
||||
expect(
|
||||
alias.ok
|
||||
? alias.inputs.fields.find((field) => field.id === 'model')?.value
|
||||
@@ -706,6 +709,50 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('materializes and warns about missing action parameters', () => {
|
||||
const decoded = decodeCanvasGenerationInputs({
|
||||
version: 2,
|
||||
action: 'video.generate',
|
||||
fields: [{ id: 'prompt', title: '视频描述', value: '追逐镜头' }],
|
||||
references: [],
|
||||
});
|
||||
|
||||
expect(decoded).toMatchObject({
|
||||
ok: true,
|
||||
inputs: {
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'model', value: DEFAULT_VIDEO_MODEL }),
|
||||
expect.objectContaining({ id: 'durationSeconds', value: 4 }),
|
||||
]),
|
||||
},
|
||||
warnings: expect.arrayContaining([
|
||||
expect.objectContaining({ fieldIds: ['model'] }),
|
||||
expect.objectContaining({ fieldIds: ['durationSeconds'] }),
|
||||
]),
|
||||
});
|
||||
|
||||
const animation = decodeCanvasGenerationInputs({
|
||||
version: 2,
|
||||
action: 'character-animation.generate',
|
||||
fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }],
|
||||
references: [],
|
||||
});
|
||||
expect(animation).toMatchObject({
|
||||
ok: true,
|
||||
inputs: {
|
||||
fields: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'frameCount', value: 32 }),
|
||||
expect.objectContaining({ id: 'durationSeconds', value: 4 }),
|
||||
]),
|
||||
},
|
||||
warnings: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
fieldIds: ['frameCount', 'durationSeconds'],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('falls invalid sound and character animation parameters back to complete defaults', () => {
|
||||
const sound = decodeCanvasGenerationInputs({
|
||||
version: 2,
|
||||
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
isNormalizedCanvasGenerationInputsStructure,
|
||||
REMIXABLE_CANVAS_GENERATION_ACTIONS,
|
||||
isRemixableCanvasGenerationAction,
|
||||
} from './ImageCanvasGenerationInputsModel';
|
||||
import {
|
||||
getPublicationMaterialsWorkflow,
|
||||
@@ -1259,9 +1259,14 @@ export function decodeCanvasGenerationInputs(
|
||||
title: string,
|
||||
fallback: T,
|
||||
resolve: (fieldValue: CanvasGenerationInputValue) => T | undefined,
|
||||
materializeMissing = true,
|
||||
): T => {
|
||||
const index = findFieldIndex(id);
|
||||
if (index < 0) {
|
||||
if (materializeMissing) {
|
||||
setFieldValue(id, title, fallback);
|
||||
addFallbackWarning([id]);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
const resolved = resolve(fields[index]!.value);
|
||||
@@ -1274,8 +1279,12 @@ export function decodeCanvasGenerationInputs(
|
||||
return resolved;
|
||||
};
|
||||
const normalizeStringField = (id: string, title: string, fallback = '') =>
|
||||
normalizeExistingField(id, title, fallback, (fieldValue) =>
|
||||
typeof fieldValue === 'string' ? fieldValue : undefined,
|
||||
normalizeExistingField(
|
||||
id,
|
||||
title,
|
||||
fallback,
|
||||
(fieldValue) => (typeof fieldValue === 'string' ? fieldValue : undefined),
|
||||
false,
|
||||
);
|
||||
const normalizeStringOption = <T extends string>(
|
||||
id: string,
|
||||
@@ -1492,6 +1501,11 @@ export function decodeCanvasGenerationInputs(
|
||||
);
|
||||
addFallbackWarning(['frameCount', 'durationSeconds']);
|
||||
}
|
||||
} else {
|
||||
const defaultDuration = CHARACTER_ANIMATION_DURATION_OPTIONS[0];
|
||||
setFieldValue('frameCount', '帧数', defaultDuration.frameCount);
|
||||
setFieldValue('durationSeconds', '时长', defaultDuration.durationSeconds);
|
||||
addFallbackWarning(['frameCount', 'durationSeconds']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1553,7 +1567,7 @@ export function canOpenRedrawPanel(
|
||||
}
|
||||
const decodedInputs = decodeCanvasGenerationInputs(layer.generationInputs);
|
||||
if (decodedInputs.ok) {
|
||||
if (!REMIXABLE_CANVAS_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) {
|
||||
if (!isRemixableCanvasGenerationAction(decodedInputs.inputs.action)) {
|
||||
return false;
|
||||
}
|
||||
if (!REQUIRED_SOURCE_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) {
|
||||
|
||||
@@ -1065,6 +1065,46 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('uploads local quick-edit references before submitting the edit request', async () => {
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '参考局部素材修图' }),
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialDialog={{
|
||||
id: 'generation-dialog-quick-edit-local-reference',
|
||||
mode: 'quick-edit',
|
||||
prompt: '参考局部素材修图',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
sourceLayerId: 'layer-source',
|
||||
imageModel: 'gpt-image-2',
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'local-reference',
|
||||
label: '本地参考图',
|
||||
src: 'data:image/png;base64,bG9jYWwtcmVmZXJlbmNl',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceImageSrcs: [
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refreshes the wallet balance after inline image generation succeeds', async () => {
|
||||
const refreshWalletBalance = vi.fn();
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
|
||||
@@ -2062,6 +2062,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
projectId,
|
||||
);
|
||||
}
|
||||
const normalizedReferenceImageSrcs = await Promise.all(
|
||||
(submissionPlan.editInput.referenceImageSrcs ?? []).map(
|
||||
(referenceImageSrc, index) =>
|
||||
resolveEditorGenerationMediaReference(
|
||||
dialog.generationReferences?.[index]
|
||||
? {
|
||||
...dialog.generationReferences[index],
|
||||
src: referenceImageSrc,
|
||||
}
|
||||
: { src: referenceImageSrc },
|
||||
'image',
|
||||
projectId,
|
||||
),
|
||||
),
|
||||
);
|
||||
const quickEditPlaceholderSize =
|
||||
getCanvasCompletionPlaceholderSizeFromPlan({
|
||||
sourceLayer: submissionPlan.sourceLayer,
|
||||
@@ -2072,6 +2087,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
prompt: submissionPlan.normalizedPrompt,
|
||||
sourceImageSrc: referenceImageSrc,
|
||||
...submissionPlan.editInput,
|
||||
...(normalizedReferenceImageSrcs.length
|
||||
? { referenceImageSrcs: normalizedReferenceImageSrcs }
|
||||
: {}),
|
||||
projectId,
|
||||
assetKind: submissionPlan.result.assetKind,
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
|
||||
@@ -4938,7 +4938,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
expect(screen.getByTestId('generation-references').textContent).toBe('');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。',
|
||||
`${CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING} 部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5001,6 +5001,29 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('warns when a legacy recipe is restored successfully', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
sourceType: 'generated',
|
||||
generationInputs: {
|
||||
fields: [{ title: '生成提示词', value: '旧版森林场景' }],
|
||||
references: [],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
|
||||
|
||||
expect(screen.getByTestId('dialog').textContent).not.toBe('-');
|
||||
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
|
||||
'已按旧版数据恢复,部分参数可能使用当前默认值。',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not legacy-fallback when a V2 required source is unavailable', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
|
||||
@@ -158,7 +158,7 @@ function getCanvasGenerationRedrawWarning({
|
||||
if (hasParameterFallbacks) {
|
||||
return CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING;
|
||||
}
|
||||
if (!isNormalizedGenerationInputs && hasUnavailableReferences) {
|
||||
if (!isNormalizedGenerationInputs) {
|
||||
return '已按旧版数据恢复,部分参数可能使用当前默认值。';
|
||||
}
|
||||
if (hasUnavailableReferences) {
|
||||
|
||||
Reference in New Issue
Block a user