From cbea6508358fe243e03f6a5776212abd61636360 Mon Sep 17 00:00:00 2001 From: shi <1611699255@qq.com> Date: Wed, 26 Aug 2026 17:06:25 +0800 Subject: [PATCH 1/6] feat(scene): add reference image service --- .../__snapshots__/dts-snapshot.test.ts.snap | 66 +++ src/api/scene/reference-image-schema.ts | 52 ++ src/api/scene/reference-image.ts | 80 +++ src/api/scene/scene.ts | 3 + src/core/scene/common/index.ts | 1 + src/core/scene/common/message.ts | 2 + src/core/scene/common/reference-image.ts | 108 ++++ src/core/scene/main-process/index.ts | 4 + .../proxy/reference-image-proxy.ts | 33 ++ .../main-process/reference-image-files.ts | 32 + src/core/scene/main-process/rpc.ts | 2 + src/core/scene/scene-configs.ts | 9 +- .../scene-process/engine-bootstrap.test.ts | 3 + .../scene/scene-process/engine-bootstrap.ts | 5 + src/core/scene/scene-process/service/index.ts | 4 + .../scene/scene-process/service/interfaces.ts | 4 + .../scene-process/service/reference-image.ts | 545 ++++++++++++++++++ src/core/scene/test/process-rpc.test.ts | 25 +- src/core/scene/test/process-rpc/rpc-worker.js | 12 +- .../test/reference-image-service.test.ts | 117 ++++ src/core/scene/test/scene-configs.test.ts | 9 + workflow/build-scene-bundle.js | 4 +- 22 files changed, 1114 insertions(+), 6 deletions(-) create mode 100644 src/api/scene/reference-image-schema.ts create mode 100644 src/api/scene/reference-image.ts create mode 100644 src/core/scene/common/reference-image.ts create mode 100644 src/core/scene/main-process/proxy/reference-image-proxy.ts create mode 100644 src/core/scene/main-process/reference-image-files.ts create mode 100644 src/core/scene/scene-process/service/reference-image.ts create mode 100644 src/core/scene/test/reference-image-service.test.ts diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 7b9cd8e0f..495b91316 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -6612,6 +6612,70 @@ export declare interface IRedoService { redo(options?: IUndoOperationOptions): Promise; canRedo(options?: IUndoOperationOptions): boolean; } +export declare interface IReferenceImageCancelOptions { + interactionId: number; +} +export declare interface IReferenceImageCommitOptions { + interactionId?: number; + patch: IReferenceImageParameters; +} +export declare interface IReferenceImageConfigItem { + path: string; + x: number; + y: number; + scaleX: number; + scaleY: number; + opacity: number; +} +export declare interface IReferenceImageError { + stage: 'config' | 'file' | 'decode'; + message: string; +} +export declare interface IReferenceImageItem extends IReferenceImageConfigItem { + missing: boolean; +} +export declare interface IReferenceImageParameters { + x?: number; + y?: number; + scaleX?: number; + scaleY?: number; + opacity?: number; +} +export declare interface IReferenceImagePathOptions { + path: string; +} +export declare interface IReferenceImagePreviewOptions { + interactionId: number; + patch: IReferenceImageParameters; +} +export declare interface IReferenceImageService extends IServiceEvents { + getState(): Promise; + addAndSelect(options: IReferenceImagePathOptions): Promise; + remove(options: IReferenceImagePathOptions): Promise; + select(options: IReferenceImagePathOptions): Promise; + setVisible(options: IReferenceImageVisibilityOptions): Promise; + refresh(): Promise; + previewParameters(options: IReferenceImagePreviewOptions): Promise; + commitParameters(options: IReferenceImageCommitOptions): Promise; + cancelPreview(options: IReferenceImageCancelOptions): Promise; +} +export declare interface IReferenceImageState { + images: IReferenceImageItem[]; + current: { + sceneUuid: string | null; + imagePath: string | null; + image: IReferenceImageItem | null; + }; + desiredVisible: boolean; + effectiveVisible: boolean; + visibilityReason: ReferenceImageVisibilityReason; + is2D: boolean; + hasOpenEditor: boolean; + error: IReferenceImageError | null; +} +export declare interface IReferenceImageVisibilityOptions { + desiredVisible: boolean; +} export declare interface IReloadOptions { urlOrUUID?: string; preserveUndoHistory?: boolean; @@ -6756,6 +6820,7 @@ export declare interface IServiceManager { SceneView: ISceneViewService; Preview: IPreviewService; UI: IUIService; + ReferenceImage: IReferenceImageService; } export declare interface ISetParentParams { paths: string[]; @@ -7090,6 +7155,7 @@ export declare enum PrefabState { PrefabInstance = 2, PrefabLostAsset = 3 } +export declare type ReferenceImageVisibilityReason = 'visible' | 'disabled' | 'no-editor' | 'not-2d' | 'unbound' | 'missing' | 'load-error'; export declare enum ReloadResult { SUCCESS = 0, FAILED = 1, diff --git a/src/api/scene/reference-image-schema.ts b/src/api/scene/reference-image-schema.ts new file mode 100644 index 000000000..20cfec1f0 --- /dev/null +++ b/src/api/scene/reference-image-schema.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +export const SchemaReferenceImageParameters = z.object({ + x: z.number().finite().optional().describe('Horizontal offset in 2D scene world units'), + y: z.number().finite().optional().describe('Vertical offset in 2D scene world units'), + scaleX: z.number().finite().optional().describe('Horizontal scale factor'), + scaleY: z.number().finite().optional().describe('Vertical scale factor'), + opacity: z.number().min(0).max(100).optional().describe('Opacity percentage from 0 to 100'), +}).refine((value) => Object.keys(value).length > 0, { + message: 'At least one reference image parameter is required.', +}).describe('Reference image parameters'); + +export const SchemaReferenceImagePath = z.object({ + path: z.string().min(1).describe('Absolute local PNG, JPG, or JPEG file path'), +}).describe('Reference image file'); + +export const SchemaReferenceImageVisibility = z.object({ + desiredVisible: z.boolean().describe('Whether the user wants reference images visible in supported editors'), +}).describe('Reference image visibility preference'); + +const SchemaReferenceImageItem = z.object({ + path: z.string(), + x: z.number(), + y: z.number(), + scaleX: z.number(), + scaleY: z.number(), + opacity: z.number().min(0).max(100), + missing: z.boolean(), +}); + +export const SchemaReferenceImageState = z.object({ + images: z.array(SchemaReferenceImageItem), + current: z.object({ + sceneUuid: z.string().nullable(), + imagePath: z.string().nullable(), + image: SchemaReferenceImageItem.nullable(), + }), + desiredVisible: z.boolean(), + effectiveVisible: z.boolean(), + visibilityReason: z.enum(['visible', 'disabled', 'no-editor', 'not-2d', 'unbound', 'missing', 'load-error']), + is2D: z.boolean(), + hasOpenEditor: z.boolean(), + error: z.object({ + stage: z.enum(['config', 'file', 'decode']), + message: z.string(), + }).nullable(), +}).describe('Current editor reference-image state'); + +export type TReferenceImageParameters = z.infer; +export type TReferenceImagePath = z.infer; +export type TReferenceImageVisibility = z.infer; +export type TReferenceImageState = z.infer; diff --git a/src/api/scene/reference-image.ts b/src/api/scene/reference-image.ts new file mode 100644 index 000000000..3ae889336 --- /dev/null +++ b/src/api/scene/reference-image.ts @@ -0,0 +1,80 @@ +import { COMMON_STATUS, CommonResultType } from '../base/schema-base'; +import { description, param, result, title, tool } from '../decorator/decorator'; +import { Scene } from '../../core/scene'; +import { + SchemaReferenceImageParameters, + SchemaReferenceImagePath, + SchemaReferenceImageState, + SchemaReferenceImageVisibility, + TReferenceImageParameters, + TReferenceImagePath, + TReferenceImageState, + TReferenceImageVisibility, +} from './reference-image-schema'; + +/** Formal, semantic MCP operations. Ephemeral preview APIs remain scene-Webview only. */ +export class ReferenceImageApi { + @tool('reference-image-query') + @title('Query reference image state') + @description('Get the current reference-image library, current binding, parameters and effective visibility.') + @result(SchemaReferenceImageState) + async query(): Promise> { + return this.execute(() => Scene.ReferenceImage.getState()); + } + + @tool('reference-image-add') + @title('Add and select reference image') + @description('Validate a local PNG, JPG, or JPEG, add it to the local library, and bind it to the current scene or prefab.') + @result(SchemaReferenceImageState) + async add(@param(SchemaReferenceImagePath) options: TReferenceImagePath): Promise> { + return this.execute(() => Scene.ReferenceImage.addAndSelect(options)); + } + + @tool('reference-image-delete') + @title('Delete reference image') + @description('Remove a reference image record and all scene bindings. The original local file is not deleted.') + @result(SchemaReferenceImageState) + async delete(@param(SchemaReferenceImagePath) options: TReferenceImagePath): Promise> { + return this.execute(() => Scene.ReferenceImage.remove(options)); + } + + @tool('reference-image-select') + @title('Select reference image') + @description('Bind an existing reference image to the current scene or prefab.') + @result(SchemaReferenceImageState) + async select(@param(SchemaReferenceImagePath) options: TReferenceImagePath): Promise> { + return this.execute(() => Scene.ReferenceImage.select(options)); + } + + @tool('reference-image-set-visible') + @title('Set reference image visibility') + @description('Set the persisted desired visibility. Reference images remain hidden while the editor is not in 2D mode.') + @result(SchemaReferenceImageState) + async setVisible(@param(SchemaReferenceImageVisibility) options: TReferenceImageVisibility): Promise> { + return this.execute(() => Scene.ReferenceImage.setVisible(options)); + } + + @tool('reference-image-refresh') + @title('Refresh current reference image') + @description('Reload the current reference image from its original local path.') + @result(SchemaReferenceImageState) + async refresh(): Promise> { + return this.execute(() => Scene.ReferenceImage.refresh()); + } + + @tool('reference-image-set-parameters') + @title('Set reference image parameters') + @description('Persist finite position or scale values and opacity from 0 to 100 for the image bound to the current scene or prefab.') + @result(SchemaReferenceImageState) + async setParameters(@param(SchemaReferenceImageParameters) patch: TReferenceImageParameters): Promise> { + return this.execute(() => Scene.ReferenceImage.commitParameters({ patch })); + } + + private async execute(operation: () => Promise): Promise> { + try { + return { code: COMMON_STATUS.SUCCESS, data: await operation() }; + } catch (error) { + return { code: COMMON_STATUS.FAIL, reason: error instanceof Error ? error.message : String(error) }; + } + } +} diff --git a/src/api/scene/scene.ts b/src/api/scene/scene.ts index 9ee39f740..94538dabb 100644 --- a/src/api/scene/scene.ts +++ b/src/api/scene/scene.ts @@ -22,17 +22,20 @@ import { Scene, TSceneTemplateType } from '../../core/scene'; import { ComponentApi } from './component'; import { NodeApi } from './node'; import { PrefabApi } from './prefab'; +import { ReferenceImageApi } from './reference-image'; import { options } from '../../core/builder/platforms/android/i18n/en'; export class SceneApi { public component: ComponentApi; public node: NodeApi; public prefab: PrefabApi; + public referenceImage: ReferenceImageApi; constructor() { this.component = new ComponentApi(); this.node = new NodeApi(); this.prefab = new PrefabApi(); + this.referenceImage = new ReferenceImageApi(); } @tool('scene-query-current') diff --git a/src/core/scene/common/index.ts b/src/core/scene/common/index.ts index c37aabece..04185174f 100644 --- a/src/core/scene/common/index.ts +++ b/src/core/scene/common/index.ts @@ -17,3 +17,4 @@ export * from './scene-view'; export * from './preview'; export * from './ui'; export * from './message'; +export * from './reference-image'; diff --git a/src/core/scene/common/message.ts b/src/core/scene/common/message.ts index 3c6ad6de7..778554f89 100644 --- a/src/core/scene/common/message.ts +++ b/src/core/scene/common/message.ts @@ -9,6 +9,7 @@ import type { ICameraEvents } from './camera'; import type { ISceneViewEvents } from './scene-view'; import type { IUndoEvents } from './undo'; import type { IAnimationEvents } from './animation'; +import type { IReferenceImageEvents } from './reference-image'; /** * messageManager 不在已有接口中的补充事件 @@ -35,4 +36,5 @@ export interface IMessageManagerEvents extends ISceneViewEvents, IUndoEvents, IAnimationEvents, + IReferenceImageEvents, ISceneEvents {} diff --git a/src/core/scene/common/reference-image.ts b/src/core/scene/common/reference-image.ts new file mode 100644 index 000000000..8479c0fdd --- /dev/null +++ b/src/core/scene/common/reference-image.ts @@ -0,0 +1,108 @@ +import type { IServiceEvents } from '../scene-process/service/core'; + +/** Persisted locally; external files themselves are never imported into AssetDB. */ +export interface IReferenceImageConfigItem { + path: string; + x: number; + y: number; + scaleX: number; + scaleY: number; + /** Opacity is a percentage in the inclusive 0–100 range. */ + opacity: number; +} + +export interface IReferenceImageConfig { + images: IReferenceImageConfigItem[]; + sceneBindings: Record; + desiredVisible: boolean; +} + +export interface IReferenceImageItem extends IReferenceImageConfigItem { + missing: boolean; +} + +export type ReferenceImageVisibilityReason = + | 'visible' + | 'disabled' + | 'no-editor' + | 'not-2d' + | 'unbound' + | 'missing' + | 'load-error'; + +export interface IReferenceImageError { + stage: 'config' | 'file' | 'decode'; + message: string; +} + +/** A read-only snapshot; `current.image` is derived from library + binding. */ +export interface IReferenceImageState { + images: IReferenceImageItem[]; + current: { + sceneUuid: string | null; + imagePath: string | null; + image: IReferenceImageItem | null; + }; + desiredVisible: boolean; + effectiveVisible: boolean; + visibilityReason: ReferenceImageVisibilityReason; + is2D: boolean; + hasOpenEditor: boolean; + error: IReferenceImageError | null; +} + +export interface IReferenceImageParameters { + x?: number; + y?: number; + scaleX?: number; + scaleY?: number; + opacity?: number; +} + +export interface IReferenceImagePreviewOptions { + interactionId: number; + patch: IReferenceImageParameters; +} + +export interface IReferenceImageCommitOptions { + interactionId?: number; + patch: IReferenceImageParameters; +} + +export interface IReferenceImageCancelOptions { + interactionId: number; +} + +export interface IReferenceImagePathOptions { + path: string; +} + +export interface IReferenceImageVisibilityOptions { + desiredVisible: boolean; +} + +export interface IReferenceImageEvents { + 'reference-image:state-changed': [state: IReferenceImageState]; +} + +/** Scene-local service; preview APIs are intentionally restricted to Webviews. */ +export interface IReferenceImageService extends IServiceEvents { + getState(): Promise; + addAndSelect(options: IReferenceImagePathOptions): Promise; + remove(options: IReferenceImagePathOptions): Promise; + select(options: IReferenceImagePathOptions): Promise; + setVisible(options: IReferenceImageVisibilityOptions): Promise; + refresh(): Promise; + previewParameters(options: IReferenceImagePreviewOptions): Promise; + commitParameters(options: IReferenceImageCommitOptions): Promise; + cancelPreview(options: IReferenceImageCancelOptions): Promise; +} + +/** Node/MCP facade excludes ephemeral preview state and interaction generations. */ +export type IPublicReferenceImageService = Pick; + +export interface IReferenceImageFileService { + readDataUrl(path: string): Promise; +} diff --git a/src/core/scene/main-process/index.ts b/src/core/scene/main-process/index.ts index c6d14b7dd..ee2e07e3d 100644 --- a/src/core/scene/main-process/index.ts +++ b/src/core/scene/main-process/index.ts @@ -6,17 +6,20 @@ import { ComponentProxy } from './proxy/component-proxy'; import { AssetProxy } from './proxy/asset-proxy'; import { EngineProxy } from './proxy/engine-proxy'; import { PrefabProxy } from './proxy/prefab-proxy'; +import { ReferenceImageProxy } from './proxy/reference-image-proxy'; import { assetManager } from '../../assets'; import scriptManager from '../../scripting'; import { sceneConfigInstance } from '../scene-configs'; import i18n from '../../base/i18n'; +import { referenceImageFiles } from './reference-image-files'; export interface IMainModule { 'assetManager': typeof assetManager; 'programming': typeof scriptManager; 'sceneConfigInstance': typeof sceneConfigInstance; 'i18n': typeof i18n; + 'referenceImageFiles': typeof referenceImageFiles; } export const Scene = { @@ -25,6 +28,7 @@ export const Scene = { ...AssetProxy, ...EngineProxy, ...PrefabProxy, + ReferenceImage: ReferenceImageProxy, // 节点相关的接口 Node: NodeProxy, // 组件相关的接口 diff --git a/src/core/scene/main-process/proxy/reference-image-proxy.ts b/src/core/scene/main-process/proxy/reference-image-proxy.ts new file mode 100644 index 000000000..140653fbb --- /dev/null +++ b/src/core/scene/main-process/proxy/reference-image-proxy.ts @@ -0,0 +1,33 @@ +import { + IPublicReferenceImageService, + IReferenceImageCommitOptions, + IReferenceImagePathOptions, + IReferenceImageState, + IReferenceImageVisibilityOptions, +} from '../../common'; +import { Rpc } from '../rpc'; + +/** Node facade for formal reference-image operations; preview remains scene-local. */ +export const ReferenceImageProxy: IPublicReferenceImageService = { + getState(): Promise { + return Rpc.getInstance().request('ReferenceImage', 'getState'); + }, + addAndSelect(options: IReferenceImagePathOptions): Promise { + return Rpc.getInstance().request('ReferenceImage', 'addAndSelect', [options]); + }, + remove(options: IReferenceImagePathOptions): Promise { + return Rpc.getInstance().request('ReferenceImage', 'remove', [options]); + }, + select(options: IReferenceImagePathOptions): Promise { + return Rpc.getInstance().request('ReferenceImage', 'select', [options]); + }, + setVisible(options: IReferenceImageVisibilityOptions): Promise { + return Rpc.getInstance().request('ReferenceImage', 'setVisible', [options]); + }, + refresh(): Promise { + return Rpc.getInstance().request('ReferenceImage', 'refresh'); + }, + commitParameters(options: IReferenceImageCommitOptions): Promise { + return Rpc.getInstance().request('ReferenceImage', 'commitParameters', [options]); + }, +}; diff --git a/src/core/scene/main-process/reference-image-files.ts b/src/core/scene/main-process/reference-image-files.ts new file mode 100644 index 000000000..cd55ab769 --- /dev/null +++ b/src/core/scene/main-process/reference-image-files.ts @@ -0,0 +1,32 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import type { IReferenceImageFileService } from '../common/reference-image'; + +const MIME_BY_EXTENSION: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', +}; + +/** Node-only file boundary; it returns a JSON-safe data URL, never a Buffer. */ +export class ReferenceImageFileService implements IReferenceImageFileService { + async readDataUrl(filePath: string): Promise { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) { + throw new Error('Reference image path must be absolute.'); + } + const mime = MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()]; + if (!mime) { + throw new Error('Reference image format must be PNG, JPG, or JPEG.'); + } + let data: Buffer; + try { + data = await fs.readFile(filePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to read reference image: ${message}`); + } + return `data:${mime};base64,${data.toString('base64')}`; + } +} + +export const referenceImageFiles = new ReferenceImageFileService(); diff --git a/src/core/scene/main-process/rpc.ts b/src/core/scene/main-process/rpc.ts index a76bd0c0f..181e09b73 100644 --- a/src/core/scene/main-process/rpc.ts +++ b/src/core/scene/main-process/rpc.ts @@ -4,6 +4,7 @@ import { assetManager } from '../../assets'; import scriptManager from '../../scripting'; import { sceneConfigInstance } from '../scene-configs'; import i18n from '../../base/i18n'; +import { referenceImageFiles } from './reference-image-files'; import type { IPublicServiceManager } from '../scene-process'; @@ -35,6 +36,7 @@ export class RpcProxy { programming: scriptManager, sceneConfigInstance: sceneConfigInstance, i18n: i18n, + referenceImageFiles, }); console.log(`[Node] Scene Process RPC ready ${prc ? '(Attached)' : '(Detached - Web Mode)'}`); } diff --git a/src/core/scene/scene-configs.ts b/src/core/scene/scene-configs.ts index 0b23f7101..d688f9476 100644 --- a/src/core/scene/scene-configs.ts +++ b/src/core/scene/scene-configs.ts @@ -1,5 +1,6 @@ import { configurationRegistry, ConfigurationScope, IBaseConfiguration } from '../configuration'; import { createSceneMetadataNodes } from './metadata'; +import type { IReferenceImageConfig } from './common/reference-image'; export interface IOriginAxesConfig { x: boolean; @@ -82,6 +83,7 @@ export interface ISceneConfig { * 记录过相机视角信息的节点 uuid 列表,运行期由 Camera 服务写入。 */ 'camera-uuids'?: string[]; + referenceImage?: IReferenceImageConfig; } class SceneConfig { @@ -131,12 +133,17 @@ class SceneConfig { // 运行期由 Camera 服务写入;提供空默认值,避免首次 get 时配置层抛错并被 RPC 中间件记为错误日志 'camera-infos': {}, 'camera-uuids': [], + referenceImage: { + images: [], + sceneBindings: {}, + desiredVisible: true, + }, }; private configInstance!: IBaseConfiguration; // 个人/本机键:存 local(profiles/),不进版本库 - private static readonly PERSONAL_KEYS = ['camera', 'gizmo', 'sceneView', 'camera-infos', 'camera-uuids']; + private static readonly PERSONAL_KEYS = ['camera', 'gizmo', 'sceneView', 'camera-infos', 'camera-uuids', 'referenceImage']; async init() { this.configInstance = await configurationRegistry.register('scene', { diff --git a/src/core/scene/scene-process/engine-bootstrap.test.ts b/src/core/scene/scene-process/engine-bootstrap.test.ts index 74d3dcc5b..6818ffd0a 100644 --- a/src/core/scene/scene-process/engine-bootstrap.test.ts +++ b/src/core/scene/scene-process/engine-bootstrap.test.ts @@ -44,6 +44,9 @@ jest.mock('./i18n', () => ({ })); jest.mock('./service', () => ({})); +jest.mock('./service/reference-image', () => ({ + ReferenceImageService: class ReferenceImageService {}, +})); jest.mock('cc/polyfill/engine', () => ({}), { virtual: true }); jest.mock('cc/overwrite', () => ({ default: (...args: any[]) => mockOverwrite(...args) }), { virtual: true }); jest.mock('../../engine/editor-extends/utils/serialize', () => ({ diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 99c8c8656..43185ec4a 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -2,6 +2,7 @@ import * as EditorExtends from '../../engine/editor-extends'; import { Rpc } from './rpc'; import { serviceManager } from './service/service-manager'; import { Service as DecoratorService } from './service/core/decorator'; +import { ReferenceImageService } from './service/reference-image'; import { messageManager } from './service/message'; import { initLocalI18n } from './i18n'; import { CUSTOM_PIPELINE_MODULE } from '../../engine/graphics-config'; @@ -20,6 +21,10 @@ if (EditorExtends.UuidUtils) { export { serviceManager, EditorExtends }; export const Service = DecoratorService; +// This value is intentionally exported through the preview bridge. Its module +// registers the service with @register(), and this live export prevents the +// web bundle from pruning that registration side effect. +export { ReferenceImageService }; declare const cc: any; diff --git a/src/core/scene/scene-process/service/index.ts b/src/core/scene/scene-process/service/index.ts index 5aa4fe94a..90fb5273a 100644 --- a/src/core/scene/scene-process/service/index.ts +++ b/src/core/scene/scene-process/service/index.ts @@ -18,4 +18,8 @@ export * from './scene-view'; export * from './particle'; export * from './preview'; export * from './ui'; +// Keep a runtime export so the web Scene bundle follows this decorator +// registration module instead of replacing its CommonJS side-effect import +// with an empty tree-shaken namespace. +export { ReferenceImageService } from './reference-image'; export * from './core/global-events'; diff --git a/src/core/scene/scene-process/service/interfaces.ts b/src/core/scene/scene-process/service/interfaces.ts index 30ddcf09f..6dd7cc40f 100644 --- a/src/core/scene/scene-process/service/interfaces.ts +++ b/src/core/scene/scene-process/service/interfaces.ts @@ -32,6 +32,8 @@ import { IPublicUIService, IUIService, IAnimationService, + IPublicReferenceImageService, + IReferenceImageService, } from '../../common'; /** @@ -54,6 +56,7 @@ export interface IPublicServiceManager { SceneView: IPublicSceneViewService, Preview: IPublicPreviewService, UI: IPublicUIService, + ReferenceImage: IPublicReferenceImageService, } export interface IServiceManager { @@ -74,4 +77,5 @@ export interface IServiceManager { SceneView: ISceneViewService, Preview: IPreviewService, UI: IUIService, + ReferenceImage: IReferenceImageService, } diff --git a/src/core/scene/scene-process/service/reference-image.ts b/src/core/scene/scene-process/service/reference-image.ts new file mode 100644 index 000000000..3db1dbaac --- /dev/null +++ b/src/core/scene/scene-process/service/reference-image.ts @@ -0,0 +1,545 @@ +import { Canvas, CCObject, Color, Layers, Node, Sprite, SpriteFrame, UITransform } from 'cc'; +import { + IReferenceImageCancelOptions, + IReferenceImageCommitOptions, + IReferenceImageConfig, + IReferenceImageConfigItem, + IReferenceImageError, + IReferenceImageEvents, + IReferenceImageItem, + IReferenceImageParameters, + IReferenceImagePathOptions, + IReferenceImagePreviewOptions, + IReferenceImageService, + IReferenceImageState, + IReferenceImageVisibilityOptions, + ReferenceImageVisibilityReason, +} from '../../common'; +import { Rpc } from '../rpc'; +import { BaseService, register, Service, ServiceEvents } from './core'; +import { messageManager } from './message'; + +const DEFAULT_CONFIG: IReferenceImageConfig = { + images: [], + sceneBindings: {}, + desiredVisible: true, +}; + +const DEFAULT_IMAGE_PARAMETERS: Omit = { + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + opacity: 100, +}; + +type EditorSession = { uuid: string | null; generation: number }; + +/** + * Editor-only reference image overlay. It owns no scene data: all runtime nodes + * live under Gizmo.backgroundNode and are explicitly DontSave/hidden. + */ +@register('ReferenceImage') +export class ReferenceImageService extends BaseService implements IReferenceImageService { + private config: IReferenceImageConfig = DEFAULT_CONFIG; + private currentSceneUuid: string | null = null; + private canvasNode: Node | null = null; + private imageNode: Node | null = null; + private sprite: Sprite | null = null; + private spriteFrame: SpriteFrame | null = null; + private loadedPath: string | null = null; + private missingPaths = new Set(); + private error: IReferenceImageError | null = null; + private loadGeneration = 0; + private activeInteractionId: number | null = null; + private interactionWatermark = 0; + private previewPatch: IReferenceImageParameters | null = null; + + async init(): Promise { + await this.loadConfig(); + ServiceEvents.on('scene:dimension-changed', this.onDimensionChanged); + await this.reconcileCurrentEditor(false); + } + + async getState(): Promise { + return this.createState(); + } + + async addAndSelect(options: IReferenceImagePathOptions): Promise { + const path = this.validatePath(options?.path); + const sceneUuid = this.requireCurrentScene(); + const frame = await this.createSpriteFrameForPath(path); + const existing = this.config.images.find((image) => image.path === path); + if (!existing) { + this.config.images.push({ path, ...DEFAULT_IMAGE_PARAMETERS }); + } + this.config.sceneBindings[sceneUuid] = path; + this.invalidatePreview(); + this.error = null; + this.replaceSpriteFrame(frame, path); + this.applyCurrentParameters(); + await this.persistAndPublish(); + return this.createState(); + } + + async remove(options: IReferenceImagePathOptions): Promise { + const path = this.validatePath(options?.path); + const index = this.config.images.findIndex((image) => image.path === path); + if (index === -1) { + return this.createState(); + } + + this.config.images.splice(index, 1); + const currentWasBound = this.currentSceneUuid !== null && this.config.sceneBindings[this.currentSceneUuid] === path; + for (const [sceneUuid, boundPath] of Object.entries(this.config.sceneBindings)) { + if (boundPath === path) { + delete this.config.sceneBindings[sceneUuid]; + } + } + if (currentWasBound && this.currentSceneUuid) { + const next = this.config.images[index] ?? this.config.images[index - 1]; + if (next) { + this.config.sceneBindings[this.currentSceneUuid] = next.path; + } + } + this.missingPaths.delete(path); + this.invalidatePreview(); + this.error = null; + await this.loadBoundImage(); + await this.persistAndPublish(); + return this.createState(); + } + + async select(options: IReferenceImagePathOptions): Promise { + const path = this.validatePath(options?.path); + const sceneUuid = this.requireCurrentScene(); + if (!this.config.images.some((image) => image.path === path)) { + throw new Error('Reference image is not in the local image library.'); + } + const frame = await this.createSpriteFrameForPath(path); + this.config.sceneBindings[sceneUuid] = path; + this.invalidatePreview(); + this.error = null; + this.replaceSpriteFrame(frame, path); + this.applyCurrentParameters(); + await this.persistAndPublish(); + return this.createState(); + } + + async setVisible(options: IReferenceImageVisibilityOptions): Promise { + if (typeof options?.desiredVisible !== 'boolean') { + throw new Error('desiredVisible must be a boolean.'); + } + if (this.config.desiredVisible === options.desiredVisible) { + return this.createState(); + } + this.config.desiredVisible = options.desiredVisible; + this.error = null; + if (options.desiredVisible) { + await this.loadBoundImage(); + } + this.applyVisibility(); + await this.persistAndPublish(); + return this.createState(); + } + + async refresh(): Promise { + this.invalidatePreview(); + this.error = null; + await this.loadBoundImage(true); + this.publishState(); + return this.createState(); + } + + async previewParameters(options: IReferenceImagePreviewOptions): Promise { + const interactionId = this.validateInteractionId(options?.interactionId); + if (interactionId <= this.interactionWatermark + || (this.activeInteractionId !== null && interactionId < this.activeInteractionId)) { + return this.createState(); + } + const patch = this.validateParameters(options?.patch); + this.requireCurrentImage(); + if (this.activeInteractionId !== interactionId) { + this.activeInteractionId = interactionId; + this.previewPatch = null; + } + this.previewPatch = { ...this.previewPatch, ...patch }; + this.applyCurrentParameters(); + return this.createState(); + } + + async commitParameters(options: IReferenceImageCommitOptions): Promise { + const interactionId = options?.interactionId === undefined ? undefined : this.validateInteractionId(options.interactionId); + if (interactionId !== undefined && interactionId <= this.interactionWatermark) { + return this.createState(); + } + const patch = this.validateParameters(options?.patch); + const current = this.requireCurrentImage(); + const next = { ...current, ...patch }; + const changed = !this.parametersEqual(current, next); + this.closeInteraction(interactionId); + this.error = null; + if (!changed) { + this.applyCurrentParameters(); + return this.createState(); + } + Object.assign(current, patch); + this.applyCurrentParameters(); + await this.persistAndPublish(); + return this.createState(); + } + + async cancelPreview(options: IReferenceImageCancelOptions): Promise { + const interactionId = this.validateInteractionId(options?.interactionId); + if (interactionId <= this.interactionWatermark) { + return this.createState(); + } + this.closeInteraction(interactionId); + this.applyCurrentParameters(); + return this.createState(); + } + + onEditorOpened(): void { + void this.reconcileCurrentEditor(true); + } + + onEditorClosed(): void { + this.currentSceneUuid = null; + this.invalidatePreview(); + this.clearRuntime(true); + this.error = null; + this.publishState(); + } + + private onDimensionChanged = (): void => { + void this.handleDimensionChanged(); + }; + + private async handleDimensionChanged(): Promise { + if (this.is2D()) { + await this.loadBoundImage(); + } + this.applyVisibility(); + this.publishState(); + } + + private async loadConfig(): Promise { + try { + const stored = await Rpc.getInstance().request('sceneConfigInstance', 'get', ['referenceImage', 'local']); + this.config = this.normalizeConfig(stored); + } catch (error) { + this.config = { ...DEFAULT_CONFIG, images: [], sceneBindings: {} }; + this.error = { stage: 'config', message: error instanceof Error ? error.message : String(error) }; + } + } + + private normalizeConfig(value: unknown): IReferenceImageConfig { + const raw = value && typeof value === 'object' ? value as Partial : {}; + const seen = new Set(); + const images = Array.isArray(raw.images) ? raw.images.flatMap((item) => { + if (!item || typeof item.path !== 'string' || !item.path || seen.has(item.path)) return []; + seen.add(item.path); + try { + return [{ + path: item.path, + x: this.finiteOrDefault(item.x, 0), + y: this.finiteOrDefault(item.y, 0), + scaleX: this.finiteOrDefault(item.scaleX, 1), + scaleY: this.finiteOrDefault(item.scaleY, 1), + opacity: this.opacityOrDefault(item.opacity), + }]; + } catch { + return []; + } + }) : []; + const paths = new Set(images.map((image) => image.path)); + const bindings: Record = {}; + if (raw.sceneBindings && typeof raw.sceneBindings === 'object') { + for (const [sceneUuid, imagePath] of Object.entries(raw.sceneBindings)) { + if (typeof imagePath === 'string' && paths.has(imagePath)) bindings[sceneUuid] = imagePath; + } + } + return { images, sceneBindings: bindings, desiredVisible: raw.desiredVisible !== false }; + } + + private async reconcileCurrentEditor(publish: boolean): Promise { + const nextSceneUuid = this.getEditorSession().uuid; + if (nextSceneUuid !== this.currentSceneUuid) { + this.currentSceneUuid = nextSceneUuid; + this.invalidatePreview(); + this.clearRuntime(true); + this.error = null; + } + await this.loadBoundImage(); + if (publish) this.publishState(); + } + + private async loadBoundImage(force = false): Promise { + const path = this.getCurrentPath(); + if (!path || !this.currentSceneUuid || !this.is2D() || !this.config.desiredVisible) { + this.clearRuntime(false); + this.applyVisibility(); + return; + } + if (!force && this.loadedPath === path && this.spriteFrame) { + this.applyCurrentParameters(); + return; + } + const session = this.getEditorSession(); + const generation = ++this.loadGeneration; + let dataUrl: string; + try { + dataUrl = await this.readDataUrl(path); + } catch (error) { + if (generation !== this.loadGeneration || !this.isSessionCurrent(session)) return; + this.missingPaths.add(path); + this.error = { stage: 'file', message: error instanceof Error ? error.message : String(error) }; + this.clearRuntime(false); + this.applyVisibility(); + return; + } + try { + const frame = await this.createSpriteFrame(dataUrl); + if (generation !== this.loadGeneration || !this.isSessionCurrent(session) || path !== this.getCurrentPath()) { + frame.destroy(); + return; + } + this.error = null; + this.missingPaths.delete(path); + this.replaceSpriteFrame(frame, path); + this.applyCurrentParameters(); + } catch (error) { + if (generation !== this.loadGeneration || !this.isSessionCurrent(session)) return; + this.missingPaths.delete(path); + this.error = { stage: 'decode', message: error instanceof Error ? error.message : String(error) }; + this.clearRuntime(false); + this.applyVisibility(); + } + } + + private async createSpriteFrameForPath(path: string): Promise { + return this.createSpriteFrame(await this.readDataUrl(path)); + } + + private async readDataUrl(path: string): Promise { + return Rpc.getInstance().request('referenceImageFiles', 'readDataUrl', [path]); + } + + private createSpriteFrame(dataUrl: string): Promise { + const ImageCtor = (globalThis as any).ccwindow?.Image ?? (globalThis as any).Image; + if (!ImageCtor) { + return Promise.reject(new Error('Image decoding is unavailable in the scene editor.')); + } + return new Promise((resolve, reject) => { + const image = new ImageCtor(); + image.onload = () => { + try { + resolve(SpriteFrame.createWithImage(image)); + } catch (error) { + reject(error); + } + }; + image.onerror = () => reject(new Error('Reference image decoding failed.')); + image.src = dataUrl; + }); + } + + private ensureNodes(): void { + if (this.sprite && this.imageNode && this.canvasNode) return; + const background = Service.Gizmo.backgroundNode; + if (!background) throw new Error('Editor gizmo background is unavailable.'); + const flags = CCObject.Flags.DontSave | CCObject.Flags.HideInHierarchy; + const layer = Layers.Enum.GIZMOS | Layers.Enum.UI_2D | Layers.Enum.IGNORE_RAYCAST; + this.canvasNode = new Node('Reference Image Canvas'); + this.canvasNode.objFlags |= flags; + this.canvasNode.layer = layer; + this.canvasNode.parent = background; + this.canvasNode.addComponent(Canvas); + + this.imageNode = new Node('Reference Image'); + this.imageNode.objFlags |= flags; + this.imageNode.layer = layer; + this.imageNode.parent = this.canvasNode; + this.imageNode.addComponent(UITransform); + this.sprite = this.imageNode.addComponent(Sprite); + } + + private replaceSpriteFrame(frame: SpriteFrame, path: string): void { + this.ensureNodes(); + const previous = this.spriteFrame; + this.spriteFrame = frame; + this.loadedPath = path; + this.sprite!.spriteFrame = frame; + if (previous && previous !== frame) previous.destroy(); + } + + private clearRuntime(destroyNodes: boolean): void { + this.loadGeneration++; + if (this.sprite) this.sprite.spriteFrame = null; + if (this.spriteFrame) this.spriteFrame.destroy(); + this.spriteFrame = null; + this.loadedPath = null; + if (destroyNodes && this.canvasNode) { + this.canvasNode.destroy(); + this.canvasNode = null; + this.imageNode = null; + this.sprite = null; + } + } + + private applyCurrentParameters(): void { + const parameters = this.getCurrentParameters(); + if (!parameters || !this.imageNode || !this.sprite) { + this.applyVisibility(); + return; + } + this.imageNode.setPosition(parameters.x, parameters.y, 0); + this.imageNode.setScale(parameters.scaleX, parameters.scaleY, 1); + const color = this.sprite.color.clone(); + color.a = Math.round(parameters.opacity / 100 * 255); + this.sprite.color = color; + this.applyVisibility(); + void Service.Engine.repaintInEditMode(); + } + + private applyVisibility(): void { + if (this.imageNode) this.imageNode.active = this.computeVisibility().effectiveVisible; + void Service.Engine.repaintInEditMode(); + } + + private computeVisibility(): { effectiveVisible: boolean; reason: ReferenceImageVisibilityReason } { + if (!this.currentSceneUuid) return { effectiveVisible: false, reason: 'no-editor' }; + if (!this.config.desiredVisible) return { effectiveVisible: false, reason: 'disabled' }; + if (!this.is2D()) return { effectiveVisible: false, reason: 'not-2d' }; + const path = this.getCurrentPath(); + if (!path) return { effectiveVisible: false, reason: 'unbound' }; + if (this.missingPaths.has(path)) return { effectiveVisible: false, reason: 'missing' }; + if (this.error) return { effectiveVisible: false, reason: 'load-error' }; + if (!this.spriteFrame || this.loadedPath !== path) return { effectiveVisible: false, reason: 'load-error' }; + return { effectiveVisible: true, reason: 'visible' }; + } + + private createState(): IReferenceImageState { + const visibility = this.computeVisibility(); + const currentPath = this.getCurrentPath(); + const images = this.config.images.map((image) => ({ ...image, missing: this.missingPaths.has(image.path) })); + const image = currentPath ? images.find((candidate) => candidate.path === currentPath) ?? null : null; + return { + images, + current: { sceneUuid: this.currentSceneUuid, imagePath: currentPath, image }, + desiredVisible: this.config.desiredVisible, + effectiveVisible: visibility.effectiveVisible, + visibilityReason: visibility.reason, + is2D: this.is2D(), + hasOpenEditor: this.currentSceneUuid !== null, + error: this.error, + }; + } + + private getCurrentPath(): string | null { + return this.currentSceneUuid ? this.config.sceneBindings[this.currentSceneUuid] ?? null : null; + } + + private requireCurrentScene(): string { + if (!this.currentSceneUuid) throw new Error('No scene or prefab is currently open.'); + return this.currentSceneUuid; + } + + private requireCurrentImage(): IReferenceImageConfigItem { + const path = this.getCurrentPath(); + const image = path ? this.config.images.find((candidate) => candidate.path === path) : undefined; + if (!image) throw new Error('The current scene or prefab has no reference image binding.'); + return image; + } + + private getCurrentParameters(): IReferenceImageConfigItem | null { + const image = this.getCurrentPath() + ? this.config.images.find((candidate) => candidate.path === this.getCurrentPath()) + : undefined; + return image ? { ...image, ...this.previewPatch } : null; + } + + private async persistAndPublish(): Promise { + await Rpc.getInstance().request('sceneConfigInstance', 'set', ['referenceImage', this.config, 'local']); + this.publishState(); + } + + private publishState(): void { + const state = this.createState(); + this.broadcast('reference-image:state-changed', state); + messageManager.broadcast('reference-image:state-changed', state); + } + + private invalidatePreview(): void { + this.interactionWatermark = Math.max(this.interactionWatermark, (this.activeInteractionId ?? 0) + 1); + this.activeInteractionId = null; + this.previewPatch = null; + } + + private closeInteraction(interactionId?: number): void { + this.interactionWatermark = Math.max(this.interactionWatermark, interactionId ?? this.activeInteractionId ?? 0); + this.activeInteractionId = null; + this.previewPatch = null; + } + + private validatePath(path: unknown): string { + if (typeof path !== 'string' || !path) throw new Error('Reference image path is required.'); + return path; + } + + private validateInteractionId(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new Error('interactionId must be a positive safe integer.'); + } + return value as number; + } + + private validateParameters(patch: unknown): IReferenceImageParameters { + if (!patch || typeof patch !== 'object') throw new Error('Reference image parameters are required.'); + const result: IReferenceImageParameters = {}; + for (const key of ['x', 'y', 'scaleX', 'scaleY', 'opacity'] as const) { + const value = (patch as Record)[key]; + if (value === undefined) continue; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${key} must be a finite number.`); + } + if (key === 'opacity' && (value < 0 || value > 100)) { + throw new Error('opacity must be between 0 and 100.'); + } + result[key] = value; + } + if (Object.keys(result).length === 0) throw new Error('At least one reference image parameter is required.'); + return result; + } + + private parametersEqual(a: IReferenceImageConfigItem, b: IReferenceImageConfigItem): boolean { + return a.x === b.x && a.y === b.y && a.scaleX === b.scaleX && a.scaleY === b.scaleY && a.opacity === b.opacity; + } + + private finiteOrDefault(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; + } + + private opacityOrDefault(value: unknown): number { + const opacity = this.finiteOrDefault(value, 100); + return opacity >= 0 && opacity <= 100 ? opacity : 100; + } + + private is2D(): boolean { + try { + return Boolean(Service.Camera.is2D); + } catch { + return false; + } + } + + private getEditorSession(): EditorSession { + const editor = Service.Editor as unknown as { getEditorSession?: () => EditorSession }; + return editor.getEditorSession?.() ?? { uuid: null, generation: 0 }; + } + + private isSessionCurrent(session: EditorSession): boolean { + const editor = Service.Editor as unknown as { isCurrentEditorSession?: (value: EditorSession) => boolean }; + return editor.isCurrentEditorSession?.(session) ?? session.uuid === this.getEditorSession().uuid; + } +} diff --git a/src/core/scene/test/process-rpc.test.ts b/src/core/scene/test/process-rpc.test.ts index 17d663c20..9b8c3fa2a 100644 --- a/src/core/scene/test/process-rpc.test.ts +++ b/src/core/scene/test/process-rpc.test.ts @@ -9,6 +9,11 @@ interface INodeService { interface ISceneService { loadScene(id: string): Promise; + addReferenceImage(path: string): Promise<{ path: string; dataUrl: string }>; +} + +interface IReferenceImageFiles { + readDataUrl(path: string): Promise; } // 测试用子进程文件路径 @@ -16,7 +21,7 @@ const workerPath = path.resolve(__dirname, './process-rpc/rpc-worker.js'); describe('ProcessRPC 双向调用测试', () => { let child: ReturnType; - let rpc: ProcessRPC<{ node: INodeService; scene: ISceneService }>; + let rpc: ProcessRPC<{ node: INodeService; scene: ISceneService; referenceImageFiles: IReferenceImageFiles }>; beforeAll(() => { child = fork(workerPath, [], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }); @@ -27,7 +32,7 @@ describe('ProcessRPC 双向调用测试', () => { child.stderr?.on('data', (chunk) => { console.log(chunk.toString()); }); - rpc = new ProcessRPC<{ node: INodeService; scene: ISceneService }>(); + rpc = new ProcessRPC<{ node: INodeService; scene: ISceneService; referenceImageFiles: IReferenceImageFiles }>(); rpc.attach(child); }); @@ -54,6 +59,22 @@ describe('ProcessRPC 双向调用测试', () => { expect(result).toBe(true); }); + test('Node → Scene → Node nested reference-image RPC completes', async () => { + rpc.register({ + referenceImageFiles: { + async readDataUrl(filePath: string) { + return `data:image/png;base64,${Buffer.from(filePath).toString('base64')}`; + }, + }, + }); + + await expect(rpc.request('scene', 'addReferenceImage', ['C:\\reference.png'], { timeout: 1000 })) + .resolves.toEqual({ + path: 'C:\\reference.png', + dataUrl: `data:image/png;base64,${Buffer.from('C:\\reference.png').toString('base64')}`, + }); + }); + test('超时处理', async () => { await expect( rpc.request('node', 'longTask', [], { timeout: 100 }) diff --git a/src/core/scene/test/process-rpc/rpc-worker.js b/src/core/scene/test/process-rpc/rpc-worker.js index ac263da41..a0509f977 100644 --- a/src/core/scene/test/process-rpc/rpc-worker.js +++ b/src/core/scene/test/process-rpc/rpc-worker.js @@ -13,6 +13,11 @@ class NodeService { async ping() { return 'pong'; } + + async addReferenceImage(path) { + const dataUrl = await rpc.request('referenceImageFiles', 'readDataUrl', [path]); + return { path, dataUrl }; + } } const rpc = new ProcessRPC(); @@ -22,12 +27,17 @@ rpc.attach({ process, }); +const nodeService = new NodeService(); + // 注册对象实例 rpc.register({ - node: new NodeService(), + node: nodeService, scene: { async loadScene(id) { return id === 'Level01'; }, + async addReferenceImage(path) { + return nodeService.addReferenceImage(path); + }, } }); diff --git a/src/core/scene/test/reference-image-service.test.ts b/src/core/scene/test/reference-image-service.test.ts new file mode 100644 index 000000000..52bdff0d6 --- /dev/null +++ b/src/core/scene/test/reference-image-service.test.ts @@ -0,0 +1,117 @@ +const request = jest.fn(); +const broadcast = jest.fn(); + +jest.mock('../scene-process/rpc', () => ({ + Rpc: { getInstance: () => ({ request }) }, +})); + +jest.mock('../scene-process/service/core', () => { + class BaseService { + broadcast = broadcast; + } + return { + BaseService, + register: () => (target: unknown) => target, + Service: { + Camera: { is2D: true }, + Engine: { repaintInEditMode: jest.fn() }, + Editor: { getEditorSession: () => ({ uuid: 'scene-a', generation: 1 }), isCurrentEditorSession: () => true }, + Gizmo: { backgroundNode: {} }, + }, + ServiceEvents: { on: jest.fn() }, + }; +}); + +jest.mock('cc', () => ({ + Canvas: class {}, + CCObject: { Flags: { DontSave: 1, HideInHierarchy: 2 } }, + Color: class {}, + Layers: { Enum: { GIZMOS: 1, UI_2D: 2, IGNORE_RAYCAST: 4 } }, + Node: class {}, + Sprite: class {}, + SpriteFrame: class {}, + UITransform: class {}, +})); + +import { ReferenceImageService } from '../scene-process/service/reference-image'; + +describe('ReferenceImageService state and preview boundary', () => { + let service: ReferenceImageService; + + beforeEach(() => { + request.mockReset(); + broadcast.mockReset(); + request.mockResolvedValue(undefined); + service = new ReferenceImageService(); + Object.assign(service as any, { + config: { + desiredVisible: true, + images: [{ path: 'C:\\design.png', x: 2, y: 3, scaleX: 1, scaleY: 1, opacity: 75 }], + sceneBindings: { 'scene-a': 'C:\\design.png' }, + }, + currentSceneUuid: 'scene-a', + spriteFrame: {}, + loadedPath: 'C:\\design.png', + }); + }); + + it('derives current image from the library and scene binding', async () => { + const state = await service.getState(); + + expect(state.current).toEqual({ + sceneUuid: 'scene-a', + imagePath: 'C:\\design.png', + image: expect.objectContaining({ path: 'C:\\design.png', opacity: 75, missing: false }), + }); + expect(state.visibilityReason).toBe('visible'); + }); + + it('keeps preview ephemeral and rejects a late preview after commit', async () => { + await service.previewParameters({ interactionId: 4, patch: { opacity: 40 } }); + + expect((service as any).config.images[0].opacity).toBe(75); + expect(request).not.toHaveBeenCalledWith('sceneConfigInstance', 'set', expect.anything()); + expect(broadcast).not.toHaveBeenCalled(); + + await service.commitParameters({ interactionId: 4, patch: { opacity: 40 } }); + + expect((service as any).config.images[0].opacity).toBe(40); + expect(request).toHaveBeenCalledWith('sceneConfigInstance', 'set', ['referenceImage', expect.any(Object), 'local']); + expect(broadcast).toHaveBeenCalledTimes(1); + + await service.previewParameters({ interactionId: 4, patch: { opacity: 10 } }); + expect((service as any).previewPatch).toBeNull(); + expect((service as any).config.images[0].opacity).toBe(40); + }); + + it('validates opacity as a percentage before changing runtime state', async () => { + await expect(service.previewParameters({ interactionId: 1, patch: { opacity: 101 } })) + .rejects.toThrow('opacity must be between 0 and 100'); + expect((service as any).previewPatch).toBeNull(); + }); + + it('reports unreadable files as missing file errors', async () => { + Object.assign(service as any, { spriteFrame: null, loadedPath: null }); + request.mockRejectedValueOnce(new Error('ENOENT: no such file')); + + await (service as any).loadBoundImage(true); + + const state = await service.getState(); + expect(state.current.image?.missing).toBe(true); + expect(state.error).toEqual({ stage: 'file', message: 'ENOENT: no such file' }); + expect(state.visibilityReason).toBe('missing'); + }); + + it('reports data URL decode failures without marking the file missing', async () => { + Object.assign(service as any, { spriteFrame: null, loadedPath: null }); + request.mockResolvedValueOnce('data:image/png;base64,invalid'); + jest.spyOn(service as any, 'createSpriteFrame').mockRejectedValueOnce(new Error('Reference image decoding failed.')); + + await (service as any).loadBoundImage(true); + + const state = await service.getState(); + expect(state.current.image?.missing).toBe(false); + expect(state.error).toEqual({ stage: 'decode', message: 'Reference image decoding failed.' }); + expect(state.visibilityReason).toBe('load-error'); + }); +}); diff --git a/src/core/scene/test/scene-configs.test.ts b/src/core/scene/test/scene-configs.test.ts index 9bf488de3..6847e005c 100644 --- a/src/core/scene/test/scene-configs.test.ts +++ b/src/core/scene/test/scene-configs.test.ts @@ -28,6 +28,7 @@ describe('SceneConfig', () => { expect(config.camera).toBeDefined(); expect(config.gizmo).toBeDefined(); expect(config.sceneView).toBeDefined(); + expect(config.referenceImage).toEqual({ images: [], sceneBindings: {}, desiredVisible: true }); }); }); @@ -126,6 +127,14 @@ describe('SceneConfig', () => { await expect(sceneConfigInstance.get('camera.fov', 'project')).rejects.toThrow(); }); + it('stores reference images only in the local scope', async () => { + const value = { images: [], sceneBindings: {}, desiredVisible: false }; + await sceneConfigInstance.set('referenceImage', value); + + expect(saveSpy).toHaveBeenLastCalledWith('local'); + expect(await sceneConfigInstance.get('referenceImage', 'local')).toEqual(value); + }); + it('should write to default scope and read back', async () => { await sceneConfigInstance.set('tick', true, 'default'); expect(await sceneConfigInstance.get('tick', 'default')).toBe(true); diff --git a/workflow/build-scene-bundle.js b/workflow/build-scene-bundle.js index 265918263..3586accc6 100644 --- a/workflow/build-scene-bundle.js +++ b/workflow/build-scene-bundle.js @@ -23,8 +23,8 @@ async function buildSceneBundle() { virtual({ entry: ` import * as Bridge from '${bridgeFile}'; - const { startup, serviceManager, EditorExtends, Service } = Bridge; - export { startup, serviceManager, EditorExtends, Service }; + const { startup, serviceManager, EditorExtends, Service, ReferenceImageService } = Bridge; + export { startup, serviceManager, EditorExtends, Service, ReferenceImageService }; ` }), { From aed83742d25d51474d89a5c439114c5fa8cb15ea Mon Sep 17 00:00:00 2001 From: shi <1611699255@qq.com> Date: Thu, 27 Aug 2026 12:04:56 +0800 Subject: [PATCH 2/6] feat(scene): add reference image clear binding --- .../__snapshots__/dts-snapshot.test.ts.snap | 1 + src/api/scene/reference-image.ts | 8 +++ src/core/scene/common/reference-image.ts | 3 +- .../proxy/reference-image-proxy.ts | 3 + .../scene-process/service/reference-image.ts | 15 +++++ .../test/reference-image-service.test.ts | 57 ++++++++++++++++++- 6 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 495b91316..f5225d8cb 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -6653,6 +6653,7 @@ export declare interface IReferenceImageService extends IServiceEvents { addAndSelect(options: IReferenceImagePathOptions): Promise; remove(options: IReferenceImagePathOptions): Promise; select(options: IReferenceImagePathOptions): Promise; + clearBinding(): Promise; setVisible(options: IReferenceImageVisibilityOptions): Promise; refresh(): Promise; previewParameters(options: IReferenceImagePreviewOptions): Promise; diff --git a/src/api/scene/reference-image.ts b/src/api/scene/reference-image.ts index 3ae889336..8fae0726d 100644 --- a/src/api/scene/reference-image.ts +++ b/src/api/scene/reference-image.ts @@ -46,6 +46,14 @@ export class ReferenceImageApi { return this.execute(() => Scene.ReferenceImage.select(options)); } + @tool('reference-image-clear-binding') + @title('Clear current reference image binding') + @description('Unbind the reference image from the current scene or prefab while preserving the local library and other bindings.') + @result(SchemaReferenceImageState) + async clearBinding(): Promise> { + return this.execute(() => Scene.ReferenceImage.clearBinding()); + } + @tool('reference-image-set-visible') @title('Set reference image visibility') @description('Set the persisted desired visibility. Reference images remain hidden while the editor is not in 2D mode.') diff --git a/src/core/scene/common/reference-image.ts b/src/core/scene/common/reference-image.ts index 8479c0fdd..c9d4ef353 100644 --- a/src/core/scene/common/reference-image.ts +++ b/src/core/scene/common/reference-image.ts @@ -91,6 +91,7 @@ export interface IReferenceImageService extends IServiceEvents { addAndSelect(options: IReferenceImagePathOptions): Promise; remove(options: IReferenceImagePathOptions): Promise; select(options: IReferenceImagePathOptions): Promise; + clearBinding(): Promise; setVisible(options: IReferenceImageVisibilityOptions): Promise; refresh(): Promise; previewParameters(options: IReferenceImagePreviewOptions): Promise; @@ -100,7 +101,7 @@ export interface IReferenceImageService extends IServiceEvents { /** Node/MCP facade excludes ephemeral preview state and interaction generations. */ export type IPublicReferenceImageService = Pick; export interface IReferenceImageFileService { diff --git a/src/core/scene/main-process/proxy/reference-image-proxy.ts b/src/core/scene/main-process/proxy/reference-image-proxy.ts index 140653fbb..4c7dbe20a 100644 --- a/src/core/scene/main-process/proxy/reference-image-proxy.ts +++ b/src/core/scene/main-process/proxy/reference-image-proxy.ts @@ -21,6 +21,9 @@ export const ReferenceImageProxy: IPublicReferenceImageService = { select(options: IReferenceImagePathOptions): Promise { return Rpc.getInstance().request('ReferenceImage', 'select', [options]); }, + clearBinding(): Promise { + return Rpc.getInstance().request('ReferenceImage', 'clearBinding'); + }, setVisible(options: IReferenceImageVisibilityOptions): Promise { return Rpc.getInstance().request('ReferenceImage', 'setVisible', [options]); }, diff --git a/src/core/scene/scene-process/service/reference-image.ts b/src/core/scene/scene-process/service/reference-image.ts index 3db1dbaac..b0e52c453 100644 --- a/src/core/scene/scene-process/service/reference-image.ts +++ b/src/core/scene/scene-process/service/reference-image.ts @@ -126,6 +126,21 @@ export class ReferenceImageService extends BaseService im return this.createState(); } + async clearBinding(): Promise { + const sceneUuid = this.requireCurrentScene(); + const hasBinding = Object.prototype.hasOwnProperty.call(this.config.sceneBindings, sceneUuid); + this.invalidatePreview(); + if (!hasBinding) { + return this.createState(); + } + + delete this.config.sceneBindings[sceneUuid]; + this.error = null; + await this.loadBoundImage(); + await this.persistAndPublish(); + return this.createState(); + } + async setVisible(options: IReferenceImageVisibilityOptions): Promise { if (typeof options?.desiredVisible !== 'boolean') { throw new Error('desiredVisible must be a boolean.'); diff --git a/src/core/scene/test/reference-image-service.test.ts b/src/core/scene/test/reference-image-service.test.ts index 52bdff0d6..18c91ff60 100644 --- a/src/core/scene/test/reference-image-service.test.ts +++ b/src/core/scene/test/reference-image-service.test.ts @@ -50,7 +50,7 @@ describe('ReferenceImageService state and preview boundary', () => { sceneBindings: { 'scene-a': 'C:\\design.png' }, }, currentSceneUuid: 'scene-a', - spriteFrame: {}, + spriteFrame: { destroy: jest.fn() }, loadedPath: 'C:\\design.png', }); }); @@ -84,6 +84,61 @@ describe('ReferenceImageService state and preview boundary', () => { expect((service as any).config.images[0].opacity).toBe(40); }); + it('clears only the current binding while preserving the image library and other scene bindings', async () => { + (service as any).config.sceneBindings['scene-b'] = 'C:\\design.png'; + const frame = (service as any).spriteFrame; + + const state = await service.clearBinding(); + + expect(state.current).toEqual({ sceneUuid: 'scene-a', imagePath: null, image: null }); + expect(state.visibilityReason).toBe('unbound'); + expect(frame.destroy).toHaveBeenCalledTimes(1); + expect((service as any).spriteFrame).toBeNull(); + expect((service as any).config.images).toEqual([ + { path: 'C:\\design.png', x: 2, y: 3, scaleX: 1, scaleY: 1, opacity: 75 }, + ]); + expect((service as any).config.sceneBindings).toEqual({ 'scene-b': 'C:\\design.png' }); + expect(request).toHaveBeenCalledWith('sceneConfigInstance', 'set', ['referenceImage', expect.any(Object), 'local']); + expect(broadcast).toHaveBeenCalledTimes(1); + }); + + it('keeps a cleared binding unbound after reinitialization and ignores its late preview', async () => { + let persisted: any; + request.mockImplementation((serviceName: string, method: string, args: unknown[]) => { + if (serviceName === 'sceneConfigInstance' && method === 'set') { + persisted = args[1]; + return Promise.resolve(); + } + if (serviceName === 'sceneConfigInstance' && method === 'get') { + return Promise.resolve(persisted); + } + return Promise.resolve(); + }); + + await service.previewParameters({ interactionId: 7, patch: { opacity: 40 } }); + await service.clearBinding(); + await service.previewParameters({ interactionId: 7, patch: { opacity: 10 } }); + + expect((await service.getState()).current.image).toBeNull(); + expect((service as any).previewPatch).toBeNull(); + + const reinitialized = new ReferenceImageService(); + await reinitialized.init(); + expect((await reinitialized.getState()).current).toEqual({ sceneUuid: 'scene-a', imagePath: null, image: null }); + }); + + it('does not persist or broadcast when the current scene is already unbound', async () => { + await service.clearBinding(); + request.mockClear(); + broadcast.mockClear(); + + const state = await service.clearBinding(); + + expect(state.visibilityReason).toBe('unbound'); + expect(request).not.toHaveBeenCalled(); + expect(broadcast).not.toHaveBeenCalled(); + }); + it('validates opacity as a percentage before changing runtime state', async () => { await expect(service.previewParameters({ interactionId: 1, patch: { opacity: 101 } })) .rejects.toThrow('opacity must be between 0 and 100'); From 5317918520ba4b73f94ffeed3bf45d9fa0e8fad4 Mon Sep 17 00:00:00 2001 From: shi <1611699255@qq.com> Date: Thu, 27 Aug 2026 14:45:37 +0800 Subject: [PATCH 3/6] fix(scene): centralize reference image library state --- src/core/scene/common/reference-image.ts | 82 +++++++ src/core/scene/main-process/index.ts | 2 + .../main-process/reference-image-store.ts | 171 +++++++++++++ src/core/scene/main-process/rpc.ts | 2 + .../scene/scene-process/engine-bootstrap.ts | 12 +- .../scene-process/service/reference-image.ts | 224 ++++++------------ .../test/reference-image-service.test.ts | 92 +++++-- .../scene/test/reference-image-store.test.ts | 94 ++++++++ 8 files changed, 512 insertions(+), 167 deletions(-) create mode 100644 src/core/scene/main-process/reference-image-store.ts create mode 100644 src/core/scene/test/reference-image-store.test.ts diff --git a/src/core/scene/common/reference-image.ts b/src/core/scene/common/reference-image.ts index c9d4ef353..c36b14ba3 100644 --- a/src/core/scene/common/reference-image.ts +++ b/src/core/scene/common/reference-image.ts @@ -17,6 +17,28 @@ export interface IReferenceImageConfig { desiredVisible: boolean; } +/** Runtime-only authority envelope; revision is never persisted in the profile. */ +export interface IReferenceImageAuthoritySnapshot { + revision: number; + config: IReferenceImageConfig; + /** Whether the requested formal mutation changed persisted configuration. */ + changed: boolean; +} + +export type IReferenceImageAuthorityMutation = + | { type: 'add-and-select'; path: string; sceneUuid: string } + | { type: 'remove'; path: string } + | { type: 'select'; path: string; sceneUuid: string } + | { type: 'clear-binding'; sceneUuid: string } + | { type: 'set-visible'; desiredVisible: boolean } + | { type: 'commit-parameters'; sceneUuid: string; patch: IReferenceImageParameters }; + +/** Main-process-only persistence boundary used by scene Webviews. */ +export interface IReferenceImageAuthorityStore { + getSnapshot(): Promise; + mutate(options: IReferenceImageAuthorityMutation): Promise; +} + export interface IReferenceImageItem extends IReferenceImageConfigItem { missing: boolean; } @@ -59,6 +81,66 @@ export interface IReferenceImageParameters { opacity?: number; } +const DEFAULT_IMAGE_PARAMETERS: Omit = { + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + opacity: 100, +}; + +export function normalizeReferenceImageConfig(value: unknown): IReferenceImageConfig { + const raw = value && typeof value === 'object' ? value as Partial : {}; + const seen = new Set(); + const images = Array.isArray(raw.images) ? raw.images.flatMap((item) => { + if (!item || typeof item.path !== 'string' || !item.path || seen.has(item.path)) return []; + seen.add(item.path); + return [{ + path: item.path, + x: finiteOrDefault(item.x, DEFAULT_IMAGE_PARAMETERS.x), + y: finiteOrDefault(item.y, DEFAULT_IMAGE_PARAMETERS.y), + scaleX: finiteOrDefault(item.scaleX, DEFAULT_IMAGE_PARAMETERS.scaleX), + scaleY: finiteOrDefault(item.scaleY, DEFAULT_IMAGE_PARAMETERS.scaleY), + opacity: opacityOrDefault(item.opacity), + }]; + }) : []; + const paths = new Set(images.map((image) => image.path)); + const sceneBindings: Record = {}; + if (raw.sceneBindings && typeof raw.sceneBindings === 'object') { + for (const [sceneUuid, imagePath] of Object.entries(raw.sceneBindings)) { + if (typeof imagePath === 'string' && paths.has(imagePath)) sceneBindings[sceneUuid] = imagePath; + } + } + return { images, sceneBindings, desiredVisible: raw.desiredVisible !== false }; +} + +export function validateReferenceImageParameters(patch: unknown): IReferenceImageParameters { + if (!patch || typeof patch !== 'object') throw new Error('Reference image parameters are required.'); + const result: IReferenceImageParameters = {}; + for (const key of ['x', 'y', 'scaleX', 'scaleY', 'opacity'] as const) { + const value = (patch as Record)[key]; + if (value === undefined) continue; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`${key} must be a finite number.`); + } + if (key === 'opacity' && (value < 0 || value > 100)) { + throw new Error('opacity must be between 0 and 100.'); + } + result[key] = value; + } + if (Object.keys(result).length === 0) throw new Error('At least one reference image parameter is required.'); + return result; +} + +function finiteOrDefault(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function opacityOrDefault(value: unknown): number { + const opacity = finiteOrDefault(value, DEFAULT_IMAGE_PARAMETERS.opacity); + return opacity >= 0 && opacity <= 100 ? opacity : DEFAULT_IMAGE_PARAMETERS.opacity; +} + export interface IReferenceImagePreviewOptions { interactionId: number; patch: IReferenceImageParameters; diff --git a/src/core/scene/main-process/index.ts b/src/core/scene/main-process/index.ts index ee2e07e3d..a69ebfc95 100644 --- a/src/core/scene/main-process/index.ts +++ b/src/core/scene/main-process/index.ts @@ -13,6 +13,7 @@ import scriptManager from '../../scripting'; import { sceneConfigInstance } from '../scene-configs'; import i18n from '../../base/i18n'; import { referenceImageFiles } from './reference-image-files'; +import { referenceImageStore } from './reference-image-store'; export interface IMainModule { 'assetManager': typeof assetManager; @@ -20,6 +21,7 @@ export interface IMainModule { 'sceneConfigInstance': typeof sceneConfigInstance; 'i18n': typeof i18n; 'referenceImageFiles': typeof referenceImageFiles; + 'referenceImageStore': typeof referenceImageStore; } export const Scene = { diff --git a/src/core/scene/main-process/reference-image-store.ts b/src/core/scene/main-process/reference-image-store.ts new file mode 100644 index 000000000..b2da6461f --- /dev/null +++ b/src/core/scene/main-process/reference-image-store.ts @@ -0,0 +1,171 @@ +import { + IReferenceImageAuthorityMutation, + IReferenceImageAuthoritySnapshot, + IReferenceImageAuthorityStore, + IReferenceImageConfig, + IReferenceImageConfigItem, + normalizeReferenceImageConfig, + validateReferenceImageParameters, +} from '../common/reference-image'; +import { socketService } from '../../../server/socket'; +import { sceneConfigInstance } from '../scene-configs'; + +const DEFAULT_IMAGE_PARAMETERS: Omit = { + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + opacity: 100, +}; + +/** + * The single writer for the project-local reference-image library. Scene + * Webviews only submit intents so their independent rendering snapshots can + * never overwrite another Webview's changes. + */ +export class ReferenceImageStore implements IReferenceImageAuthorityStore { + private revision = 0; + private mutationQueue: Promise = Promise.resolve(); + + async getSnapshot(): Promise { + // Do not pair a new in-memory config value with the previous revision + // while a queued write is awaiting disk persistence. + await this.mutationQueue.catch(() => undefined); + const config = normalizeReferenceImageConfig( + await sceneConfigInstance.get('referenceImage', 'local') + ); + return this.createSnapshot(config, false); + } + + async mutate(options: IReferenceImageAuthorityMutation): Promise { + let resolveTask!: (snapshot: IReferenceImageAuthoritySnapshot) => void; + let rejectTask!: (reason: unknown) => void; + const result = new Promise((resolve, reject) => { + resolveTask = resolve; + rejectTask = reject; + }); + this.mutationQueue = this.mutationQueue + .catch(() => undefined) + .then(async () => { + try { + resolveTask(await this.mutateLatest(options)); + } catch (error) { + rejectTask(error); + } + }); + return result; + } + + private async mutateLatest(options: IReferenceImageAuthorityMutation): Promise { + const current = normalizeReferenceImageConfig( + await sceneConfigInstance.get('referenceImage', 'local') + ); + const next = this.applyMutation(current, options); + const changed = !configsEqual(current, next); + if (changed) { + await sceneConfigInstance.set('referenceImage', next, 'local'); + this.revision++; + socketService.io?.emit('scene:invoke', { + module: 'ReferenceImage', + method: 'syncFromAuthority', + args: [], + }); + } + return this.createSnapshot(next, changed); + } + + private applyMutation(config: IReferenceImageConfig, options: IReferenceImageAuthorityMutation): IReferenceImageConfig { + const next = cloneConfig(config); + switch (options?.type) { + case 'add-and-select': { + const path = validatePath(options.path); + const sceneUuid = validateSceneUuid(options.sceneUuid); + if (!next.images.some((image) => image.path === path)) { + next.images.push({ path, ...DEFAULT_IMAGE_PARAMETERS }); + } + next.sceneBindings[sceneUuid] = path; + return next; + } + case 'remove': { + const path = validatePath(options.path); + const index = next.images.findIndex((image) => image.path === path); + if (index === -1) return next; + next.images.splice(index, 1); + for (const [sceneUuid, imagePath] of Object.entries(next.sceneBindings)) { + if (imagePath === path) delete next.sceneBindings[sceneUuid]; + } + return next; + } + case 'select': { + const path = validatePath(options.path); + const sceneUuid = validateSceneUuid(options.sceneUuid); + if (!next.images.some((image) => image.path === path)) { + throw new Error('Reference image is not in the local image library.'); + } + next.sceneBindings[sceneUuid] = path; + return next; + } + case 'clear-binding': { + delete next.sceneBindings[validateSceneUuid(options.sceneUuid)]; + return next; + } + case 'set-visible': { + if (typeof options.desiredVisible !== 'boolean') { + throw new Error('desiredVisible must be a boolean.'); + } + next.desiredVisible = options.desiredVisible; + return next; + } + case 'commit-parameters': { + const sceneUuid = validateSceneUuid(options.sceneUuid); + const path = next.sceneBindings[sceneUuid]; + const image = path ? next.images.find((candidate) => candidate.path === path) : undefined; + if (!image) throw new Error('The current scene or prefab has no reference image binding.'); + Object.assign(image, validateReferenceImageParameters(options.patch)); + return next; + } + default: + throw new Error('Unknown reference image mutation.'); + } + } + + private createSnapshot(config: IReferenceImageConfig, changed: boolean): IReferenceImageAuthoritySnapshot { + return { revision: this.revision, config: cloneConfig(config), changed }; + } +} + +function validatePath(path: unknown): string { + if (typeof path !== 'string' || !path) throw new Error('Reference image path is required.'); + return path; +} + +function validateSceneUuid(sceneUuid: unknown): string { + if (typeof sceneUuid !== 'string' || !sceneUuid) throw new Error('No scene or prefab is currently open.'); + return sceneUuid; +} + +function cloneConfig(config: IReferenceImageConfig): IReferenceImageConfig { + return { + images: config.images.map((image) => ({ ...image })), + sceneBindings: { ...config.sceneBindings }, + desiredVisible: config.desiredVisible, + }; +} + +function configsEqual(left: IReferenceImageConfig, right: IReferenceImageConfig): boolean { + return left.desiredVisible === right.desiredVisible + && left.images.length === right.images.length + && left.images.every((image, index) => { + const candidate = right.images[index]; + return candidate?.path === image.path + && candidate.x === image.x + && candidate.y === image.y + && candidate.scaleX === image.scaleX + && candidate.scaleY === image.scaleY + && candidate.opacity === image.opacity; + }) + && Object.keys(left.sceneBindings).length === Object.keys(right.sceneBindings).length + && Object.entries(left.sceneBindings).every(([sceneUuid, path]) => right.sceneBindings[sceneUuid] === path); +} + +export const referenceImageStore = new ReferenceImageStore(); diff --git a/src/core/scene/main-process/rpc.ts b/src/core/scene/main-process/rpc.ts index 181e09b73..da7fe38b9 100644 --- a/src/core/scene/main-process/rpc.ts +++ b/src/core/scene/main-process/rpc.ts @@ -5,6 +5,7 @@ import scriptManager from '../../scripting'; import { sceneConfigInstance } from '../scene-configs'; import i18n from '../../base/i18n'; import { referenceImageFiles } from './reference-image-files'; +import { referenceImageStore } from './reference-image-store'; import type { IPublicServiceManager } from '../scene-process'; @@ -37,6 +38,7 @@ export class RpcProxy { sceneConfigInstance: sceneConfigInstance, i18n: i18n, referenceImageFiles, + referenceImageStore, }); console.log(`[Node] Scene Process RPC ready ${prc ? '(Attached)' : '(Detached - Web Mode)'}`); } diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 43185ec4a..3c423b07d 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -231,7 +231,12 @@ async function setupBrowserInvokeChannel(serverURL: string) { try { const svc = (DecoratorService as any)[module]; if (svc && typeof svc[method] === 'function') { - svc[method](...(args || [])); + const result = svc[method](...(args || [])); + if (result && typeof result.catch === 'function') { + void result.catch((error: unknown) => { + console.warn(`[scene:invoke] ${module}.${method} rejected:`, error); + }); + } } } catch (e) { console.warn('[scene:invoke] failed:', e); @@ -243,7 +248,10 @@ async function setupBrowserInvokeChannel(serverURL: string) { } }); // 连接建立时同步一次设计分辨率(首次进入 / 断线重连时补齐错过的变更) - socket.on('connect', () => invoke('Engine', 'syncDesignResolution', [])); + socket.on('connect', () => { + invoke('Engine', 'syncDesignResolution', []); + invoke('ReferenceImage', 'syncFromAuthority', []); + }); } catch (e) { console.warn('[engine-bootstrap] setup browser-invoke channel failed:', e); } diff --git a/src/core/scene/scene-process/service/reference-image.ts b/src/core/scene/scene-process/service/reference-image.ts index b0e52c453..8ad4c52b4 100644 --- a/src/core/scene/scene-process/service/reference-image.ts +++ b/src/core/scene/scene-process/service/reference-image.ts @@ -1,6 +1,8 @@ import { Canvas, CCObject, Color, Layers, Node, Sprite, SpriteFrame, UITransform } from 'cc'; import { IReferenceImageCancelOptions, + IReferenceImageAuthorityMutation, + IReferenceImageAuthoritySnapshot, IReferenceImageCommitOptions, IReferenceImageConfig, IReferenceImageConfigItem, @@ -14,6 +16,8 @@ import { IReferenceImageState, IReferenceImageVisibilityOptions, ReferenceImageVisibilityReason, + normalizeReferenceImageConfig, + validateReferenceImageParameters, } from '../../common'; import { Rpc } from '../rpc'; import { BaseService, register, Service, ServiceEvents } from './core'; @@ -25,14 +29,6 @@ const DEFAULT_CONFIG: IReferenceImageConfig = { desiredVisible: true, }; -const DEFAULT_IMAGE_PARAMETERS: Omit = { - x: 0, - y: 0, - scaleX: 1, - scaleY: 1, - opacity: 100, -}; - type EditorSession = { uuid: string | null; generation: number }; /** @@ -50,18 +46,22 @@ export class ReferenceImageService extends BaseService im private loadedPath: string | null = null; private missingPaths = new Set(); private error: IReferenceImageError | null = null; + /** Last main-process authority revision applied to this Webview's renderer. */ + private authorityRevision: number | null = null; + private authorityApplyQueue: Promise = Promise.resolve(); private loadGeneration = 0; private activeInteractionId: number | null = null; private interactionWatermark = 0; private previewPatch: IReferenceImageParameters | null = null; async init(): Promise { - await this.loadConfig(); + await this.syncFromAuthority(false); ServiceEvents.on('scene:dimension-changed', this.onDimensionChanged); await this.reconcileCurrentEditor(false); } async getState(): Promise { + await this.syncFromAuthority(false); return this.createState(); } @@ -69,96 +69,39 @@ export class ReferenceImageService extends BaseService im const path = this.validatePath(options?.path); const sceneUuid = this.requireCurrentScene(); const frame = await this.createSpriteFrameForPath(path); - const existing = this.config.images.find((image) => image.path === path); - if (!existing) { - this.config.images.push({ path, ...DEFAULT_IMAGE_PARAMETERS }); - } - this.config.sceneBindings[sceneUuid] = path; - this.invalidatePreview(); - this.error = null; - this.replaceSpriteFrame(frame, path); - this.applyCurrentParameters(); - await this.persistAndPublish(); - return this.createState(); + frame.destroy(); + return this.mutateAuthority({ type: 'add-and-select', path, sceneUuid }, true); } async remove(options: IReferenceImagePathOptions): Promise { const path = this.validatePath(options?.path); - const index = this.config.images.findIndex((image) => image.path === path); - if (index === -1) { - return this.createState(); - } - - this.config.images.splice(index, 1); - const currentWasBound = this.currentSceneUuid !== null && this.config.sceneBindings[this.currentSceneUuid] === path; - for (const [sceneUuid, boundPath] of Object.entries(this.config.sceneBindings)) { - if (boundPath === path) { - delete this.config.sceneBindings[sceneUuid]; - } - } - if (currentWasBound && this.currentSceneUuid) { - const next = this.config.images[index] ?? this.config.images[index - 1]; - if (next) { - this.config.sceneBindings[this.currentSceneUuid] = next.path; - } - } this.missingPaths.delete(path); - this.invalidatePreview(); - this.error = null; - await this.loadBoundImage(); - await this.persistAndPublish(); - return this.createState(); + return this.mutateAuthority({ type: 'remove', path }, true); } async select(options: IReferenceImagePathOptions): Promise { const path = this.validatePath(options?.path); const sceneUuid = this.requireCurrentScene(); - if (!this.config.images.some((image) => image.path === path)) { - throw new Error('Reference image is not in the local image library.'); - } const frame = await this.createSpriteFrameForPath(path); - this.config.sceneBindings[sceneUuid] = path; - this.invalidatePreview(); - this.error = null; - this.replaceSpriteFrame(frame, path); - this.applyCurrentParameters(); - await this.persistAndPublish(); - return this.createState(); + frame.destroy(); + return this.mutateAuthority({ type: 'select', path, sceneUuid }, true); } async clearBinding(): Promise { const sceneUuid = this.requireCurrentScene(); - const hasBinding = Object.prototype.hasOwnProperty.call(this.config.sceneBindings, sceneUuid); this.invalidatePreview(); - if (!hasBinding) { - return this.createState(); - } - - delete this.config.sceneBindings[sceneUuid]; - this.error = null; - await this.loadBoundImage(); - await this.persistAndPublish(); - return this.createState(); + return this.mutateAuthority({ type: 'clear-binding', sceneUuid }, true, false); } async setVisible(options: IReferenceImageVisibilityOptions): Promise { if (typeof options?.desiredVisible !== 'boolean') { throw new Error('desiredVisible must be a boolean.'); } - if (this.config.desiredVisible === options.desiredVisible) { - return this.createState(); - } - this.config.desiredVisible = options.desiredVisible; - this.error = null; - if (options.desiredVisible) { - await this.loadBoundImage(); - } - this.applyVisibility(); - await this.persistAndPublish(); - return this.createState(); + return this.mutateAuthority({ type: 'set-visible', desiredVisible: options.desiredVisible }, false); } async refresh(): Promise { + await this.syncFromAuthority(false); this.invalidatePreview(); this.error = null; await this.loadBoundImage(true); @@ -189,19 +132,11 @@ export class ReferenceImageService extends BaseService im return this.createState(); } const patch = this.validateParameters(options?.patch); - const current = this.requireCurrentImage(); - const next = { ...current, ...patch }; - const changed = !this.parametersEqual(current, next); + const sceneUuid = this.requireCurrentScene(); + this.requireCurrentImage(); this.closeInteraction(interactionId); this.error = null; - if (!changed) { - this.applyCurrentParameters(); - return this.createState(); - } - Object.assign(current, patch); - this.applyCurrentParameters(); - await this.persistAndPublish(); - return this.createState(); + return this.mutateAuthority({ type: 'commit-parameters', sceneUuid, patch }, false, false, true); } async cancelPreview(options: IReferenceImageCancelOptions): Promise { @@ -238,45 +173,17 @@ export class ReferenceImageService extends BaseService im this.publishState(); } - private async loadConfig(): Promise { + /** Socket entrypoint and active pull path; failures stay observable without unhandled rejections. */ + async syncFromAuthority(publish = true): Promise { try { - const stored = await Rpc.getInstance().request('sceneConfigInstance', 'get', ['referenceImage', 'local']); - this.config = this.normalizeConfig(stored); + const snapshot = await Rpc.getInstance().request('referenceImageStore', 'getSnapshot'); + await this.enqueueAuthoritySnapshot(snapshot, publish); } catch (error) { - this.config = { ...DEFAULT_CONFIG, images: [], sceneBindings: {} }; this.error = { stage: 'config', message: error instanceof Error ? error.message : String(error) }; + console.warn('[ReferenceImage] failed to synchronize authority:', error); } } - private normalizeConfig(value: unknown): IReferenceImageConfig { - const raw = value && typeof value === 'object' ? value as Partial : {}; - const seen = new Set(); - const images = Array.isArray(raw.images) ? raw.images.flatMap((item) => { - if (!item || typeof item.path !== 'string' || !item.path || seen.has(item.path)) return []; - seen.add(item.path); - try { - return [{ - path: item.path, - x: this.finiteOrDefault(item.x, 0), - y: this.finiteOrDefault(item.y, 0), - scaleX: this.finiteOrDefault(item.scaleX, 1), - scaleY: this.finiteOrDefault(item.scaleY, 1), - opacity: this.opacityOrDefault(item.opacity), - }]; - } catch { - return []; - } - }) : []; - const paths = new Set(images.map((image) => image.path)); - const bindings: Record = {}; - if (raw.sceneBindings && typeof raw.sceneBindings === 'object') { - for (const [sceneUuid, imagePath] of Object.entries(raw.sceneBindings)) { - if (typeof imagePath === 'string' && paths.has(imagePath)) bindings[sceneUuid] = imagePath; - } - } - return { images, sceneBindings: bindings, desiredVisible: raw.desiredVisible !== false }; - } - private async reconcileCurrentEditor(publish: boolean): Promise { const nextSceneUuid = this.getEditorSession().uuid; if (nextSceneUuid !== this.currentSceneUuid) { @@ -285,6 +192,7 @@ export class ReferenceImageService extends BaseService im this.clearRuntime(true); this.error = null; } + await this.syncFromAuthority(false); await this.loadBoundImage(); if (publish) this.publishState(); } @@ -474,9 +382,54 @@ export class ReferenceImageService extends BaseService im return image ? { ...image, ...this.previewPatch } : null; } - private async persistAndPublish(): Promise { - await Rpc.getInstance().request('sceneConfigInstance', 'set', ['referenceImage', this.config, 'local']); - this.publishState(); + private async mutateAuthority( + mutation: IReferenceImageAuthorityMutation, + invalidatePreview: boolean, + clearError = true, + applyRuntimeOnNoop = false, + ): Promise { + const snapshot = await Rpc.getInstance().request('referenceImageStore', 'mutate', [mutation]); + if (invalidatePreview) this.invalidatePreview(); + if (clearError && snapshot.changed) this.error = null; + const applied = await this.enqueueAuthoritySnapshot(snapshot, snapshot.changed); + if (applyRuntimeOnNoop && !snapshot.changed && !applied) this.applyCurrentParameters(); + return this.createState(); + } + + private async enqueueAuthoritySnapshot(snapshot: IReferenceImageAuthoritySnapshot, publish: boolean): Promise { + let resolveTask!: (applied: boolean) => void; + let rejectTask!: (reason: unknown) => void; + const result = new Promise((resolve, reject) => { + resolveTask = resolve; + rejectTask = reject; + }); + this.authorityApplyQueue = this.authorityApplyQueue + .catch(() => undefined) + .then(async () => { + try { + resolveTask(await this.applyAuthoritySnapshot(snapshot, publish)); + } catch (error) { + rejectTask(error); + } + }); + return result; + } + + private async applyAuthoritySnapshot(snapshot: IReferenceImageAuthoritySnapshot, publish: boolean): Promise { + if (!snapshot || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) { + throw new Error('Reference image authority returned an invalid snapshot.'); + } + // A socket notification may arrive before the RPC response that caused it. + // Never let an older or already-applied response roll the renderer back. + if (this.authorityRevision !== null && snapshot.revision <= this.authorityRevision) { + return false; + } + this.config = normalizeReferenceImageConfig(snapshot.config); + this.authorityRevision = snapshot.revision; + this.invalidatePreview(); + await this.loadBoundImage(); + if (publish) this.publishState(); + return true; } private publishState(): void { @@ -510,34 +463,7 @@ export class ReferenceImageService extends BaseService im } private validateParameters(patch: unknown): IReferenceImageParameters { - if (!patch || typeof patch !== 'object') throw new Error('Reference image parameters are required.'); - const result: IReferenceImageParameters = {}; - for (const key of ['x', 'y', 'scaleX', 'scaleY', 'opacity'] as const) { - const value = (patch as Record)[key]; - if (value === undefined) continue; - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new Error(`${key} must be a finite number.`); - } - if (key === 'opacity' && (value < 0 || value > 100)) { - throw new Error('opacity must be between 0 and 100.'); - } - result[key] = value; - } - if (Object.keys(result).length === 0) throw new Error('At least one reference image parameter is required.'); - return result; - } - - private parametersEqual(a: IReferenceImageConfigItem, b: IReferenceImageConfigItem): boolean { - return a.x === b.x && a.y === b.y && a.scaleX === b.scaleX && a.scaleY === b.scaleY && a.opacity === b.opacity; - } - - private finiteOrDefault(value: unknown, fallback: number): number { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; - } - - private opacityOrDefault(value: unknown): number { - const opacity = this.finiteOrDefault(value, 100); - return opacity >= 0 && opacity <= 100 ? opacity : 100; + return validateReferenceImageParameters(patch); } private is2D(): boolean { diff --git a/src/core/scene/test/reference-image-service.test.ts b/src/core/scene/test/reference-image-service.test.ts index 18c91ff60..db3c03b76 100644 --- a/src/core/scene/test/reference-image-service.test.ts +++ b/src/core/scene/test/reference-image-service.test.ts @@ -37,11 +37,44 @@ import { ReferenceImageService } from '../scene-process/service/reference-image' describe('ReferenceImageService state and preview boundary', () => { let service: ReferenceImageService; + let authority: any; beforeEach(() => { request.mockReset(); broadcast.mockReset(); - request.mockResolvedValue(undefined); + authority = { + revision: 1, + changed: false, + config: { + desiredVisible: true, + images: [{ path: 'C:\\design.png', x: 2, y: 3, scaleX: 1, scaleY: 1, opacity: 75 }], + sceneBindings: { 'scene-a': 'C:\\design.png' }, + }, + }; + request.mockImplementation((module: string, method: string, args: any[] = []) => { + if (module !== 'referenceImageStore') return Promise.resolve(undefined); + if (method === 'getSnapshot') return Promise.resolve(authority); + const mutation = args[0]; + const config = { + desiredVisible: authority.config.desiredVisible, + images: authority.config.images.map((image: any) => ({ ...image })), + sceneBindings: { ...authority.config.sceneBindings }, + }; + let changed = false; + if (mutation.type === 'clear-binding' && config.sceneBindings[mutation.sceneUuid]) { + delete config.sceneBindings[mutation.sceneUuid]; + changed = true; + } else if (mutation.type === 'commit-parameters') { + const path = config.sceneBindings[mutation.sceneUuid]; + const image = config.images.find((candidate: any) => candidate.path === path); + if (image && Object.keys(mutation.patch).some((key) => image[key] !== mutation.patch[key])) { + Object.assign(image, mutation.patch); + changed = true; + } + } + if (changed) authority = { revision: authority.revision + 1, config, changed: true }; + return Promise.resolve({ ...authority, changed }); + }); service = new ReferenceImageService(); Object.assign(service as any, { config: { @@ -49,6 +82,7 @@ describe('ReferenceImageService state and preview boundary', () => { images: [{ path: 'C:\\design.png', x: 2, y: 3, scaleX: 1, scaleY: 1, opacity: 75 }], sceneBindings: { 'scene-a': 'C:\\design.png' }, }, + authorityRevision: 1, currentSceneUuid: 'scene-a', spriteFrame: { destroy: jest.fn() }, loadedPath: 'C:\\design.png', @@ -76,7 +110,9 @@ describe('ReferenceImageService state and preview boundary', () => { await service.commitParameters({ interactionId: 4, patch: { opacity: 40 } }); expect((service as any).config.images[0].opacity).toBe(40); - expect(request).toHaveBeenCalledWith('sceneConfigInstance', 'set', ['referenceImage', expect.any(Object), 'local']); + expect(request).toHaveBeenCalledWith('referenceImageStore', 'mutate', [ + expect.objectContaining({ type: 'commit-parameters', sceneUuid: 'scene-a', patch: { opacity: 40 } }), + ]); expect(broadcast).toHaveBeenCalledTimes(1); await service.previewParameters({ interactionId: 4, patch: { opacity: 10 } }); @@ -84,8 +120,40 @@ describe('ReferenceImageService state and preview boundary', () => { expect((service as any).config.images[0].opacity).toBe(40); }); + it('restores committed runtime parameters when a preview commit is a persistence no-op', async () => { + (service as any).config.images[0].opacity = 40; + authority.config.images[0].opacity = 40; + const apply = jest.spyOn(service as any, 'applyCurrentParameters'); + + await service.previewParameters({ interactionId: 8, patch: { opacity: 30 } }); + await service.commitParameters({ interactionId: 8, patch: { opacity: 40 } }); + + expect((service as any).previewPatch).toBeNull(); + expect(apply).toHaveBeenLastCalledWith(); + }); + + it('does not roll back after a newer socket snapshot arrives before an older RPC response', async () => { + const newer = { + revision: 2, + changed: true, + config: { + desiredVisible: true, + images: [{ path: 'C:\\new.png', x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 100 }], + sceneBindings: { 'scene-a': 'C:\\new.png' }, + }, + }; + const older = { revision: 1, changed: true, config: (service as any).config }; + + await (service as any).enqueueAuthoritySnapshot(newer, false); + await (service as any).enqueueAuthoritySnapshot(older, false); + + expect((service as any).authorityRevision).toBe(2); + expect((service as any).config.images).toEqual(newer.config.images); + }); + it('clears only the current binding while preserving the image library and other scene bindings', async () => { (service as any).config.sceneBindings['scene-b'] = 'C:\\design.png'; + authority.config.sceneBindings['scene-b'] = 'C:\\design.png'; const frame = (service as any).spriteFrame; const state = await service.clearBinding(); @@ -98,23 +166,13 @@ describe('ReferenceImageService state and preview boundary', () => { { path: 'C:\\design.png', x: 2, y: 3, scaleX: 1, scaleY: 1, opacity: 75 }, ]); expect((service as any).config.sceneBindings).toEqual({ 'scene-b': 'C:\\design.png' }); - expect(request).toHaveBeenCalledWith('sceneConfigInstance', 'set', ['referenceImage', expect.any(Object), 'local']); + expect(request).toHaveBeenCalledWith('referenceImageStore', 'mutate', [ + { type: 'clear-binding', sceneUuid: 'scene-a' }, + ]); expect(broadcast).toHaveBeenCalledTimes(1); }); it('keeps a cleared binding unbound after reinitialization and ignores its late preview', async () => { - let persisted: any; - request.mockImplementation((serviceName: string, method: string, args: unknown[]) => { - if (serviceName === 'sceneConfigInstance' && method === 'set') { - persisted = args[1]; - return Promise.resolve(); - } - if (serviceName === 'sceneConfigInstance' && method === 'get') { - return Promise.resolve(persisted); - } - return Promise.resolve(); - }); - await service.previewParameters({ interactionId: 7, patch: { opacity: 40 } }); await service.clearBinding(); await service.previewParameters({ interactionId: 7, patch: { opacity: 10 } }); @@ -135,7 +193,9 @@ describe('ReferenceImageService state and preview boundary', () => { const state = await service.clearBinding(); expect(state.visibilityReason).toBe('unbound'); - expect(request).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledWith('referenceImageStore', 'mutate', [ + { type: 'clear-binding', sceneUuid: 'scene-a' }, + ]); expect(broadcast).not.toHaveBeenCalled(); }); diff --git a/src/core/scene/test/reference-image-store.test.ts b/src/core/scene/test/reference-image-store.test.ts new file mode 100644 index 000000000..85d1363b1 --- /dev/null +++ b/src/core/scene/test/reference-image-store.test.ts @@ -0,0 +1,94 @@ +const get = jest.fn(); +const set = jest.fn(); +const emit = jest.fn(); + +jest.mock('../scene-configs', () => ({ + sceneConfigInstance: { get, set }, +})); + +jest.mock('../../../server/socket', () => ({ + socketService: { io: { emit } }, +})); + +import { ReferenceImageStore } from '../main-process/reference-image-store'; + +const emptyConfig = () => ({ images: [], sceneBindings: {}, desiredVisible: true }); + +describe('ReferenceImageStore', () => { + let store: ReferenceImageStore; + let persisted: any; + + beforeEach(() => { + persisted = emptyConfig(); + get.mockReset(); + set.mockReset(); + emit.mockReset(); + get.mockImplementation(async () => persisted); + set.mockImplementation(async (_path: string, value: unknown) => { + persisted = JSON.parse(JSON.stringify(value)); + return true; + }); + store = new ReferenceImageStore(); + }); + + it('serializes concurrent stale-client additions without losing either shared-library record', async () => { + await Promise.all([ + store.mutate({ type: 'add-and-select', path: 'C:\\a.png', sceneUuid: 'scene-a' }), + store.mutate({ type: 'add-and-select', path: 'C:\\b.png', sceneUuid: 'scene-b' }), + ]); + + const snapshot = await store.getSnapshot(); + expect(snapshot.config.images.map((image) => image.path)).toEqual(['C:\\a.png', 'C:\\b.png']); + expect(snapshot.config.sceneBindings).toEqual({ 'scene-a': 'C:\\a.png', 'scene-b': 'C:\\b.png' }); + expect(set).toHaveBeenCalledTimes(2); + expect(emit).toHaveBeenCalledTimes(2); + }); + + it('keeps one public library while scene bindings remain independent', async () => { + await store.mutate({ type: 'add-and-select', path: 'C:\\shared.png', sceneUuid: 'scene-a' }); + const selected = await store.mutate({ type: 'select', path: 'C:\\shared.png', sceneUuid: 'scene-b' }); + const cleared = await store.mutate({ type: 'clear-binding', sceneUuid: 'scene-a' }); + + expect(selected.config.images).toHaveLength(1); + expect(selected.config.sceneBindings).toEqual({ 'scene-a': 'C:\\shared.png', 'scene-b': 'C:\\shared.png' }); + expect(cleared.config.images).toHaveLength(1); + expect(cleared.config.sceneBindings).toEqual({ 'scene-b': 'C:\\shared.png' }); + }); + + it('does not write or fan out an idempotent no-op', async () => { + const snapshot = await store.mutate({ type: 'clear-binding', sceneUuid: 'scene-a' }); + + expect(snapshot).toMatchObject({ revision: 0, changed: false, config: emptyConfig() }); + expect(set).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('deletes a library entry and clears every binding without selecting a neighbor', async () => { + persisted = { + desiredVisible: true, + images: [ + { path: 'C:\\delete.png', x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 100 }, + { path: 'C:\\keep.png', x: 1, y: 2, scaleX: 2, scaleY: 2, opacity: 50 }, + ], + sceneBindings: { + 'scene-a': 'C:\\delete.png', + 'scene-b': 'C:\\delete.png', + 'scene-c': 'C:\\keep.png', + }, + }; + + const snapshot = await store.mutate({ type: 'remove', path: 'C:\\delete.png' }); + + expect(snapshot.config.images.map((image) => image.path)).toEqual(['C:\\keep.png']); + expect(snapshot.config.sceneBindings).toEqual({ 'scene-c': 'C:\\keep.png' }); + }); + + it('reads persisted state again when a new store instance is initialized', async () => { + await store.mutate({ type: 'add-and-select', path: 'C:\\persisted.png', sceneUuid: 'scene-a' }); + const restartedStore = new ReferenceImageStore(); + + const snapshot = await restartedStore.getSnapshot(); + expect(snapshot.revision).toBe(0); + expect(snapshot.config).toEqual(persisted); + }); +}); From 4e28487603522632564225ae560fe293be6d9433 Mon Sep 17 00:00:00 2001 From: shi <1611699255@qq.com> Date: Thu, 27 Aug 2026 17:01:44 +0800 Subject: [PATCH 4/6] fix(scene): tighten reference image synchronization boundaries --- src/core/scene/common/reference-image.ts | 2 + .../main-process/reference-image-store.ts | 5 +- .../scene/scene-process/engine-bootstrap.ts | 7 +- .../scene-process/service/reference-image.ts | 28 +++++--- .../test/reference-image-service.test.ts | 66 ++++++++++++++++++- .../scene/test/reference-image-store.test.ts | 5 +- 6 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/core/scene/common/reference-image.ts b/src/core/scene/common/reference-image.ts index c36b14ba3..d57457115 100644 --- a/src/core/scene/common/reference-image.ts +++ b/src/core/scene/common/reference-image.ts @@ -19,6 +19,8 @@ export interface IReferenceImageConfig { /** Runtime-only authority envelope; revision is never persisted in the profile. */ export interface IReferenceImageAuthoritySnapshot { + /** Runtime-only identity for one main-process authority lifetime. */ + instanceId: string; revision: number; config: IReferenceImageConfig; /** Whether the requested formal mutation changed persisted configuration. */ diff --git a/src/core/scene/main-process/reference-image-store.ts b/src/core/scene/main-process/reference-image-store.ts index b2da6461f..213c005a7 100644 --- a/src/core/scene/main-process/reference-image-store.ts +++ b/src/core/scene/main-process/reference-image-store.ts @@ -7,6 +7,7 @@ import { normalizeReferenceImageConfig, validateReferenceImageParameters, } from '../common/reference-image'; +import { randomUUID } from 'crypto'; import { socketService } from '../../../server/socket'; import { sceneConfigInstance } from '../scene-configs'; @@ -24,6 +25,8 @@ const DEFAULT_IMAGE_PARAMETERS: Omit = { * never overwrite another Webview's changes. */ export class ReferenceImageStore implements IReferenceImageAuthorityStore { + /** Changes after a main-process restart; it is intentionally not persisted. */ + private readonly instanceId = randomUUID(); private revision = 0; private mutationQueue: Promise = Promise.resolve(); @@ -130,7 +133,7 @@ export class ReferenceImageStore implements IReferenceImageAuthorityStore { } private createSnapshot(config: IReferenceImageConfig, changed: boolean): IReferenceImageAuthoritySnapshot { - return { revision: this.revision, config: cloneConfig(config), changed }; + return { instanceId: this.instanceId, revision: this.revision, config: cloneConfig(config), changed }; } } diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 3c423b07d..055145393 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -231,12 +231,7 @@ async function setupBrowserInvokeChannel(serverURL: string) { try { const svc = (DecoratorService as any)[module]; if (svc && typeof svc[method] === 'function') { - const result = svc[method](...(args || [])); - if (result && typeof result.catch === 'function') { - void result.catch((error: unknown) => { - console.warn(`[scene:invoke] ${module}.${method} rejected:`, error); - }); - } + svc[method](...(args || [])); } } catch (e) { console.warn('[scene:invoke] failed:', e); diff --git a/src/core/scene/scene-process/service/reference-image.ts b/src/core/scene/scene-process/service/reference-image.ts index 8ad4c52b4..be528eb80 100644 --- a/src/core/scene/scene-process/service/reference-image.ts +++ b/src/core/scene/scene-process/service/reference-image.ts @@ -48,6 +48,8 @@ export class ReferenceImageService extends BaseService im private error: IReferenceImageError | null = null; /** Last main-process authority revision applied to this Webview's renderer. */ private authorityRevision: number | null = null; + /** Runtime identity paired with authorityRevision to survive main-process restarts. */ + private authorityInstanceId: string | null = null; private authorityApplyQueue: Promise = Promise.resolve(); private loadGeneration = 0; private activeInteractionId: number | null = null; @@ -90,7 +92,7 @@ export class ReferenceImageService extends BaseService im async clearBinding(): Promise { const sceneUuid = this.requireCurrentScene(); this.invalidatePreview(); - return this.mutateAuthority({ type: 'clear-binding', sceneUuid }, true, false); + return this.mutateAuthority({ type: 'clear-binding', sceneUuid }, true); } async setVisible(options: IReferenceImageVisibilityOptions): Promise { @@ -168,8 +170,9 @@ export class ReferenceImageService extends BaseService im private async handleDimensionChanged(): Promise { if (this.is2D()) { await this.loadBoundImage(); + } else { + this.applyVisibility(); } - this.applyVisibility(); this.publishState(); } @@ -321,13 +324,13 @@ export class ReferenceImageService extends BaseService im const color = this.sprite.color.clone(); color.a = Math.round(parameters.opacity / 100 * 255); this.sprite.color = color; - this.applyVisibility(); + this.applyVisibility(false); void Service.Engine.repaintInEditMode(); } - private applyVisibility(): void { + private applyVisibility(repaint = true): void { if (this.imageNode) this.imageNode.active = this.computeVisibility().effectiveVisible; - void Service.Engine.repaintInEditMode(); + if (repaint) void Service.Engine.repaintInEditMode(); } private computeVisibility(): { effectiveVisible: boolean; reason: ReferenceImageVisibilityReason } { @@ -416,15 +419,22 @@ export class ReferenceImageService extends BaseService im } private async applyAuthoritySnapshot(snapshot: IReferenceImageAuthoritySnapshot, publish: boolean): Promise { - if (!snapshot || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0) { + if (!snapshot + || typeof snapshot.instanceId !== 'string' + || !snapshot.instanceId + || !Number.isSafeInteger(snapshot.revision) + || snapshot.revision < 0) { throw new Error('Reference image authority returned an invalid snapshot.'); } // A socket notification may arrive before the RPC response that caused it. // Never let an older or already-applied response roll the renderer back. - if (this.authorityRevision !== null && snapshot.revision <= this.authorityRevision) { + if (this.authorityInstanceId === snapshot.instanceId + && this.authorityRevision !== null + && snapshot.revision <= this.authorityRevision) { return false; } this.config = normalizeReferenceImageConfig(snapshot.config); + this.authorityInstanceId = snapshot.instanceId; this.authorityRevision = snapshot.revision; this.invalidatePreview(); await this.loadBoundImage(); @@ -439,7 +449,9 @@ export class ReferenceImageService extends BaseService im } private invalidatePreview(): void { - this.interactionWatermark = Math.max(this.interactionWatermark, (this.activeInteractionId ?? 0) + 1); + if (this.activeInteractionId !== null) { + this.interactionWatermark = Math.max(this.interactionWatermark, this.activeInteractionId); + } this.activeInteractionId = null; this.previewPatch = null; } diff --git a/src/core/scene/test/reference-image-service.test.ts b/src/core/scene/test/reference-image-service.test.ts index db3c03b76..74c974118 100644 --- a/src/core/scene/test/reference-image-service.test.ts +++ b/src/core/scene/test/reference-image-service.test.ts @@ -1,5 +1,6 @@ const request = jest.fn(); const broadcast = jest.fn(); +const repaintInEditMode = jest.fn(); jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: () => ({ request }) }, @@ -14,7 +15,7 @@ jest.mock('../scene-process/service/core', () => { register: () => (target: unknown) => target, Service: { Camera: { is2D: true }, - Engine: { repaintInEditMode: jest.fn() }, + Engine: { repaintInEditMode }, Editor: { getEditorSession: () => ({ uuid: 'scene-a', generation: 1 }), isCurrentEditorSession: () => true }, Gizmo: { backgroundNode: {} }, }, @@ -42,7 +43,9 @@ describe('ReferenceImageService state and preview boundary', () => { beforeEach(() => { request.mockReset(); broadcast.mockReset(); + repaintInEditMode.mockReset(); authority = { + instanceId: 'authority-a', revision: 1, changed: false, config: { @@ -72,7 +75,12 @@ describe('ReferenceImageService state and preview boundary', () => { changed = true; } } - if (changed) authority = { revision: authority.revision + 1, config, changed: true }; + if (changed) authority = { + instanceId: authority.instanceId, + revision: authority.revision + 1, + config, + changed: true, + }; return Promise.resolve({ ...authority, changed }); }); service = new ReferenceImageService(); @@ -83,6 +91,7 @@ describe('ReferenceImageService state and preview boundary', () => { sceneBindings: { 'scene-a': 'C:\\design.png' }, }, authorityRevision: 1, + authorityInstanceId: 'authority-a', currentSceneUuid: 'scene-a', spriteFrame: { destroy: jest.fn() }, loadedPath: 'C:\\design.png', @@ -118,6 +127,35 @@ describe('ReferenceImageService state and preview boundary', () => { await service.previewParameters({ interactionId: 4, patch: { opacity: 10 } }); expect((service as any).previewPatch).toBeNull(); expect((service as any).config.images[0].opacity).toBe(40); + + await service.previewParameters({ interactionId: 5, patch: { opacity: 30 } }); + expect((service as any).previewPatch).toEqual({ opacity: 30 }); + }); + + it('keeps the first host interaction valid and only invalidates an active ID', async () => { + (service as any).invalidatePreview(); + expect((service as any).interactionWatermark).toBe(0); + + await service.previewParameters({ interactionId: 1, patch: { opacity: 40 } }); + expect((service as any).activeInteractionId).toBe(1); + + (service as any).invalidatePreview(); + expect((service as any).interactionWatermark).toBe(1); + + await service.previewParameters({ interactionId: 2, patch: { opacity: 30 } }); + expect((service as any).activeInteractionId).toBe(2); + expect((service as any).previewPatch).toEqual({ opacity: 30 }); + }); + + it('repaints once for each applied parameter preview', async () => { + Object.assign(service as any, { + imageNode: { setPosition: jest.fn(), setScale: jest.fn(), active: true }, + sprite: { color: { clone: () => ({ a: 255 }) } }, + }); + + await service.previewParameters({ interactionId: 5, patch: { opacity: 40 } }); + + expect(repaintInEditMode).toHaveBeenCalledTimes(1); }); it('restores committed runtime parameters when a preview commit is a persistence no-op', async () => { @@ -134,6 +172,7 @@ describe('ReferenceImageService state and preview boundary', () => { it('does not roll back after a newer socket snapshot arrives before an older RPC response', async () => { const newer = { + instanceId: 'authority-a', revision: 2, changed: true, config: { @@ -142,7 +181,7 @@ describe('ReferenceImageService state and preview boundary', () => { sceneBindings: { 'scene-a': 'C:\\new.png' }, }, }; - const older = { revision: 1, changed: true, config: (service as any).config }; + const older = { instanceId: 'authority-a', revision: 1, changed: true, config: (service as any).config }; await (service as any).enqueueAuthoritySnapshot(newer, false); await (service as any).enqueueAuthoritySnapshot(older, false); @@ -151,15 +190,36 @@ describe('ReferenceImageService state and preview boundary', () => { expect((service as any).config.images).toEqual(newer.config.images); }); + it('applies a lower revision from a restarted main-process authority', async () => { + const restarted = { + instanceId: 'authority-b', + revision: 0, + changed: false, + config: { + desiredVisible: true, + images: [{ path: 'C:\\recovered.png', x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 100 }], + sceneBindings: { 'scene-a': 'C:\\recovered.png' }, + }, + }; + + await (service as any).enqueueAuthoritySnapshot(restarted, false); + + expect((service as any).authorityInstanceId).toBe('authority-b'); + expect((service as any).authorityRevision).toBe(0); + expect((service as any).config.images).toEqual(restarted.config.images); + }); + it('clears only the current binding while preserving the image library and other scene bindings', async () => { (service as any).config.sceneBindings['scene-b'] = 'C:\\design.png'; authority.config.sceneBindings['scene-b'] = 'C:\\design.png'; + (service as any).error = { stage: 'decode', message: 'Reference image decoding failed.' }; const frame = (service as any).spriteFrame; const state = await service.clearBinding(); expect(state.current).toEqual({ sceneUuid: 'scene-a', imagePath: null, image: null }); expect(state.visibilityReason).toBe('unbound'); + expect(state.error).toBeNull(); expect(frame.destroy).toHaveBeenCalledTimes(1); expect((service as any).spriteFrame).toBeNull(); expect((service as any).config.images).toEqual([ diff --git a/src/core/scene/test/reference-image-store.test.ts b/src/core/scene/test/reference-image-store.test.ts index 85d1363b1..f7d193c71 100644 --- a/src/core/scene/test/reference-image-store.test.ts +++ b/src/core/scene/test/reference-image-store.test.ts @@ -58,7 +58,7 @@ describe('ReferenceImageStore', () => { it('does not write or fan out an idempotent no-op', async () => { const snapshot = await store.mutate({ type: 'clear-binding', sceneUuid: 'scene-a' }); - expect(snapshot).toMatchObject({ revision: 0, changed: false, config: emptyConfig() }); + expect(snapshot).toMatchObject({ instanceId: expect.any(String), revision: 0, changed: false, config: emptyConfig() }); expect(set).not.toHaveBeenCalled(); expect(emit).not.toHaveBeenCalled(); }); @@ -88,7 +88,8 @@ describe('ReferenceImageStore', () => { const restartedStore = new ReferenceImageStore(); const snapshot = await restartedStore.getSnapshot(); - expect(snapshot.revision).toBe(0); + expect(snapshot).toMatchObject({ instanceId: expect.any(String), revision: 0 }); + expect(snapshot.instanceId).not.toBe((await store.getSnapshot()).instanceId); expect(snapshot.config).toEqual(persisted); }); }); From 25d06f0c89eba292b396da2c3f140df36682b1c7 Mon Sep 17 00:00:00 2001 From: shi <1611699255@qq.com> Date: Thu, 27 Aug 2026 18:34:56 +0800 Subject: [PATCH 5/6] fix(scene): restore reference image on cold start --- .../scene-process/service/reference-image.ts | 74 ++++++++++---- .../test/reference-image-service.test.ts | 97 ++++++++++++++++++- 2 files changed, 149 insertions(+), 22 deletions(-) diff --git a/src/core/scene/scene-process/service/reference-image.ts b/src/core/scene/scene-process/service/reference-image.ts index be528eb80..7143f0398 100644 --- a/src/core/scene/scene-process/service/reference-image.ts +++ b/src/core/scene/scene-process/service/reference-image.ts @@ -57,13 +57,12 @@ export class ReferenceImageService extends BaseService im private previewPatch: IReferenceImageParameters | null = null; async init(): Promise { - await this.syncFromAuthority(false); ServiceEvents.on('scene:dimension-changed', this.onDimensionChanged); await this.reconcileCurrentEditor(false); } async getState(): Promise { - await this.syncFromAuthority(false); + await this.pullAuthoritySnapshot(); return this.createState(); } @@ -103,7 +102,7 @@ export class ReferenceImageService extends BaseService im } async refresh(): Promise { - await this.syncFromAuthority(false); + await this.pullAuthoritySnapshot(); this.invalidatePreview(); this.error = null; await this.loadBoundImage(true); @@ -168,36 +167,65 @@ export class ReferenceImageService extends BaseService im }; private async handleDimensionChanged(): Promise { - if (this.is2D()) { - await this.loadBoundImage(); - } else { - this.applyVisibility(); - } + await this.reconcileRuntime(); this.publishState(); } - /** Socket entrypoint and active pull path; failures stay observable without unhandled rejections. */ + /** Socket entrypoint; reconnects reconcile runtime even when authority revision is unchanged. */ async syncFromAuthority(publish = true): Promise { + const authorityChanged = await this.pullAuthoritySnapshot(); + const runtimeChanged = await this.reconcileRuntime(); + if (publish && (authorityChanged || runtimeChanged)) this.publishState(); + } + + /** Pulls authority only; callers decide whether the renderer should be reconciled. */ + private async pullAuthoritySnapshot(): Promise { try { const snapshot = await Rpc.getInstance().request('referenceImageStore', 'getSnapshot'); - await this.enqueueAuthoritySnapshot(snapshot, publish); + return await this.enqueueAuthoritySnapshot(snapshot, false, false); } catch (error) { this.error = { stage: 'config', message: error instanceof Error ? error.message : String(error) }; console.warn('[ReferenceImage] failed to synchronize authority:', error); + return false; } } private async reconcileCurrentEditor(publish: boolean): Promise { + let editorChanged = false; const nextSceneUuid = this.getEditorSession().uuid; if (nextSceneUuid !== this.currentSceneUuid) { + editorChanged = true; this.currentSceneUuid = nextSceneUuid; this.invalidatePreview(); this.clearRuntime(true); this.error = null; } - await this.syncFromAuthority(false); - await this.loadBoundImage(); - if (publish) this.publishState(); + const authorityChanged = await this.pullAuthoritySnapshot(); + const runtimeChanged = await this.reconcileRuntime(); + if (publish && (editorChanged || authorityChanged || runtimeChanged)) this.publishState(); + } + + /** Recreates or clears ephemeral editor objects without mutating authority state. */ + private async reconcileRuntime(): Promise { + const before = this.createRuntimeStateKey(); + if (this.is2D()) { + await this.loadBoundImage(); + } else { + this.applyVisibility(); + } + return before !== this.createRuntimeStateKey(); + } + + private createRuntimeStateKey(): string { + const visibility = this.computeVisibility(); + return JSON.stringify({ + sceneUuid: this.currentSceneUuid, + imagePath: this.getCurrentPath(), + desiredVisible: this.config.desiredVisible, + effectiveVisible: visibility.effectiveVisible, + visibilityReason: visibility.reason, + error: this.error, + }); } private async loadBoundImage(force = false): Promise { @@ -394,12 +422,16 @@ export class ReferenceImageService extends BaseService im const snapshot = await Rpc.getInstance().request('referenceImageStore', 'mutate', [mutation]); if (invalidatePreview) this.invalidatePreview(); if (clearError && snapshot.changed) this.error = null; - const applied = await this.enqueueAuthoritySnapshot(snapshot, snapshot.changed); + const applied = await this.enqueueAuthoritySnapshot(snapshot, snapshot.changed, true); if (applyRuntimeOnNoop && !snapshot.changed && !applied) this.applyCurrentParameters(); return this.createState(); } - private async enqueueAuthoritySnapshot(snapshot: IReferenceImageAuthoritySnapshot, publish: boolean): Promise { + private async enqueueAuthoritySnapshot( + snapshot: IReferenceImageAuthoritySnapshot, + publish: boolean, + reconcileRuntime = false, + ): Promise { let resolveTask!: (applied: boolean) => void; let rejectTask!: (reason: unknown) => void; const result = new Promise((resolve, reject) => { @@ -410,7 +442,7 @@ export class ReferenceImageService extends BaseService im .catch(() => undefined) .then(async () => { try { - resolveTask(await this.applyAuthoritySnapshot(snapshot, publish)); + resolveTask(await this.applyAuthoritySnapshot(snapshot, publish, reconcileRuntime)); } catch (error) { rejectTask(error); } @@ -418,7 +450,11 @@ export class ReferenceImageService extends BaseService im return result; } - private async applyAuthoritySnapshot(snapshot: IReferenceImageAuthoritySnapshot, publish: boolean): Promise { + private async applyAuthoritySnapshot( + snapshot: IReferenceImageAuthoritySnapshot, + publish: boolean, + reconcileRuntime: boolean, + ): Promise { if (!snapshot || typeof snapshot.instanceId !== 'string' || !snapshot.instanceId @@ -437,7 +473,7 @@ export class ReferenceImageService extends BaseService im this.authorityInstanceId = snapshot.instanceId; this.authorityRevision = snapshot.revision; this.invalidatePreview(); - await this.loadBoundImage(); + if (reconcileRuntime) await this.reconcileRuntime(); if (publish) this.publishState(); return true; } @@ -480,7 +516,7 @@ export class ReferenceImageService extends BaseService im private is2D(): boolean { try { - return Boolean(Service.Camera.is2D); + return Boolean(Service.Gizmo.is2D); } catch { return false; } diff --git a/src/core/scene/test/reference-image-service.test.ts b/src/core/scene/test/reference-image-service.test.ts index 74c974118..dba501494 100644 --- a/src/core/scene/test/reference-image-service.test.ts +++ b/src/core/scene/test/reference-image-service.test.ts @@ -1,6 +1,12 @@ const request = jest.fn(); const broadcast = jest.fn(); const repaintInEditMode = jest.fn(); +const camera = { is2D: true }; +const gizmo = { is2D: true, backgroundNode: {} }; +const editor = { + getEditorSession: jest.fn(() => ({ uuid: 'scene-a', generation: 1 })), + isCurrentEditorSession: jest.fn(() => true), +}; jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: () => ({ request }) }, @@ -14,10 +20,10 @@ jest.mock('../scene-process/service/core', () => { BaseService, register: () => (target: unknown) => target, Service: { - Camera: { is2D: true }, + Camera: camera, Engine: { repaintInEditMode }, - Editor: { getEditorSession: () => ({ uuid: 'scene-a', generation: 1 }), isCurrentEditorSession: () => true }, - Gizmo: { backgroundNode: {} }, + Editor: editor, + Gizmo: gizmo, }, ServiceEvents: { on: jest.fn() }, }; @@ -44,6 +50,10 @@ describe('ReferenceImageService state and preview boundary', () => { request.mockReset(); broadcast.mockReset(); repaintInEditMode.mockReset(); + camera.is2D = true; + gizmo.is2D = true; + editor.getEditorSession.mockReturnValue({ uuid: 'scene-a', generation: 1 }); + editor.isCurrentEditorSession.mockReturnValue(true); authority = { instanceId: 'authority-a', revision: 1, @@ -109,6 +119,87 @@ describe('ReferenceImageService state and preview boundary', () => { expect(state.visibilityReason).toBe('visible'); }); + it('keeps getState as an authority query without rehydrating runtime objects', async () => { + Object.assign(service as any, { spriteFrame: null, loadedPath: null }); + authority = { + ...authority, + revision: 2, + changed: true, + config: { + ...authority.config, + images: authority.config.images.map((image: any) => ({ ...image, opacity: 50 })), + }, + }; + const reconcileRuntime = jest.spyOn(service as any, 'reconcileRuntime'); + + const state = await service.getState(); + + expect(reconcileRuntime).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledWith('referenceImageStore', 'getSnapshot'); + expect(state.current.image?.opacity).toBe(50); + }); + + it('rehydrates runtime after a same-revision socket sync without writing authority', async () => { + Object.assign(service as any, { spriteFrame: null, loadedPath: null, error: null }); + jest.spyOn(service as any, 'loadBoundImage').mockImplementation(async () => { + Object.assign(service as any, { + spriteFrame: { destroy: jest.fn() }, + loadedPath: 'C:\\design.png', + error: null, + }); + }); + + await service.syncFromAuthority(); + + expect((service as any).loadedPath).toBe('C:\\design.png'); + expect(broadcast).toHaveBeenCalledTimes(1); + expect(request).not.toHaveBeenCalledWith('referenceImageStore', 'mutate', expect.anything()); + }); + + it('rehydrates runtime when the current editor opens with an existing authority snapshot', async () => { + const reinitialized = new ReferenceImageService(); + const loadBoundImage = jest.spyOn(reinitialized as any, 'loadBoundImage').mockResolvedValue(undefined); + + await reinitialized.init(); + + expect(loadBoundImage).toHaveBeenCalledTimes(1); + expect((reinitialized as any).currentSceneUuid).toBe('scene-a'); + expect(request).not.toHaveBeenCalledWith('referenceImageStore', 'mutate', expect.anything()); + }); + + it('loads on cold editor open after Gizmo restores 2D before Camera is ready', async () => { + camera.is2D = false; + gizmo.is2D = true; + const reinitialized = new ReferenceImageService(); + const frame = { destroy: jest.fn() }; + const readDataUrl = jest.spyOn(reinitialized as any, 'readDataUrl').mockResolvedValue('data:image/png;base64,valid'); + jest.spyOn(reinitialized as any, 'createSpriteFrame').mockResolvedValue(frame); + const replaceSpriteFrame = jest.spyOn(reinitialized as any, 'replaceSpriteFrame').mockImplementation((_frame, path) => { + Object.assign(reinitialized as any, { spriteFrame: frame, loadedPath: path }); + }); + jest.spyOn(reinitialized as any, 'applyCurrentParameters').mockImplementation(() => undefined); + + await reinitialized.init(); + + expect(readDataUrl).toHaveBeenCalledWith('C:\\design.png'); + expect(replaceSpriteFrame).toHaveBeenCalledWith(frame, 'C:\\design.png'); + expect((reinitialized as any).loadedPath).toBe('C:\\design.png'); + expect((reinitialized as any).error).toBeNull(); + }); + + it('reconciles runtime for both 3D and return-to-2D dimension lifecycle events', async () => { + const loadBoundImage = jest.spyOn(service as any, 'loadBoundImage').mockResolvedValue(undefined); + + gizmo.is2D = false; + await (service as any).handleDimensionChanged(); + expect(loadBoundImage).not.toHaveBeenCalled(); + gizmo.is2D = true; + await (service as any).handleDimensionChanged(); + + expect(loadBoundImage).toHaveBeenCalledTimes(1); + expect(request).not.toHaveBeenCalledWith('referenceImageStore', 'mutate', expect.anything()); + }); + it('keeps preview ephemeral and rejects a late preview after commit', async () => { await service.previewParameters({ interactionId: 4, patch: { opacity: 40 } }); From 99f1aa5b62831049df64ae3433bcd66faf99eaaa Mon Sep 17 00:00:00 2001 From: shi <1611699255@qq.com> Date: Thu, 27 Aug 2026 20:27:12 +0800 Subject: [PATCH 6/6] fix(scene): preserve reference image preview interactions --- src/api/scene/reference-image-schema.ts | 1 + src/api/scene/reference-image.ts | 1 + src/core/scene/common/reference-image.ts | 1 + .../proxy/reference-image-proxy.ts | 1 + .../main-process/reference-image-files.ts | 1 + .../main-process/reference-image-store.ts | 3 + src/core/scene/main-process/rpc.ts | 1 + src/core/scene/scene-configs.ts | 1 + .../scene/scene-process/engine-bootstrap.ts | 3 +- .../scene-process/service/reference-image.ts | 22 ++++-- .../test/reference-image-service.test.ts | 70 ++++++++++++++++++- .../scene/test/reference-image-store.test.ts | 1 + workflow/build-scene-bundle.js | 1 + 13 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/api/scene/reference-image-schema.ts b/src/api/scene/reference-image-schema.ts index 20cfec1f0..5ae12c734 100644 --- a/src/api/scene/reference-image-schema.ts +++ b/src/api/scene/reference-image-schema.ts @@ -1,3 +1,4 @@ +/** Runtime schemas for the public AI/MCP reference-image operations. */ import { z } from 'zod'; export const SchemaReferenceImageParameters = z.object({ diff --git a/src/api/scene/reference-image.ts b/src/api/scene/reference-image.ts index 8fae0726d..239247887 100644 --- a/src/api/scene/reference-image.ts +++ b/src/api/scene/reference-image.ts @@ -1,3 +1,4 @@ +/** Public AI/MCP facade for formal reference-image operations. */ import { COMMON_STATUS, CommonResultType } from '../base/schema-base'; import { description, param, result, title, tool } from '../decorator/decorator'; import { Scene } from '../../core/scene'; diff --git a/src/core/scene/common/reference-image.ts b/src/core/scene/common/reference-image.ts index d57457115..4801c03b7 100644 --- a/src/core/scene/common/reference-image.ts +++ b/src/core/scene/common/reference-image.ts @@ -1,3 +1,4 @@ +/** Shared reference-image contracts for configuration, Scene services, and Node RPC. */ import type { IServiceEvents } from '../scene-process/service/core'; /** Persisted locally; external files themselves are never imported into AssetDB. */ diff --git a/src/core/scene/main-process/proxy/reference-image-proxy.ts b/src/core/scene/main-process/proxy/reference-image-proxy.ts index 4c7dbe20a..1a0421822 100644 --- a/src/core/scene/main-process/proxy/reference-image-proxy.ts +++ b/src/core/scene/main-process/proxy/reference-image-proxy.ts @@ -1,3 +1,4 @@ +/** Main-process proxy that forwards formal reference-image requests to the active Scene service. */ import { IPublicReferenceImageService, IReferenceImageCommitOptions, diff --git a/src/core/scene/main-process/reference-image-files.ts b/src/core/scene/main-process/reference-image-files.ts index cd55ab769..483460c28 100644 --- a/src/core/scene/main-process/reference-image-files.ts +++ b/src/core/scene/main-process/reference-image-files.ts @@ -1,3 +1,4 @@ +/** Node-side external-image reader used by Scene services without importing files into AssetDB. */ import { promises as fs } from 'fs'; import path from 'path'; import type { IReferenceImageFileService } from '../common/reference-image'; diff --git a/src/core/scene/main-process/reference-image-store.ts b/src/core/scene/main-process/reference-image-store.ts index 213c005a7..c028599a5 100644 --- a/src/core/scene/main-process/reference-image-store.ts +++ b/src/core/scene/main-process/reference-image-store.ts @@ -1,3 +1,4 @@ +/** Main-process authority for the shared, project-local reference-image configuration. */ import { IReferenceImageAuthorityMutation, IReferenceImageAuthoritySnapshot, @@ -28,6 +29,7 @@ export class ReferenceImageStore implements IReferenceImageAuthorityStore { /** Changes after a main-process restart; it is intentionally not persisted. */ private readonly instanceId = randomUUID(); private revision = 0; + /** Serializes the complete read-modify-write operation, not only the disk write. */ private mutationQueue: Promise = Promise.resolve(); async getSnapshot(): Promise { @@ -60,6 +62,7 @@ export class ReferenceImageStore implements IReferenceImageAuthorityStore { } private async mutateLatest(options: IReferenceImageAuthorityMutation): Promise { + // Read inside the queue so another Scene cannot overwrite this mutation with a stale snapshot. const current = normalizeReferenceImageConfig( await sceneConfigInstance.get('referenceImage', 'local') ); diff --git a/src/core/scene/main-process/rpc.ts b/src/core/scene/main-process/rpc.ts index da7fe38b9..f438f0848 100644 --- a/src/core/scene/main-process/rpc.ts +++ b/src/core/scene/main-process/rpc.ts @@ -37,6 +37,7 @@ export class RpcProxy { programming: scriptManager, sceneConfigInstance: sceneConfigInstance, i18n: i18n, + // Feature-owned Node modules: external file reads and serialized local configuration writes. referenceImageFiles, referenceImageStore, }); diff --git a/src/core/scene/scene-configs.ts b/src/core/scene/scene-configs.ts index d688f9476..0f34efe81 100644 --- a/src/core/scene/scene-configs.ts +++ b/src/core/scene/scene-configs.ts @@ -83,6 +83,7 @@ export interface ISceneConfig { * 记录过相机视角信息的节点 uuid 列表,运行期由 Camera 服务写入。 */ 'camera-uuids'?: string[]; + /** Personal editor-only reference-image library and Scene bindings; never committed with Scene data. */ referenceImage?: IReferenceImageConfig; } diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index 055145393..84953ffe3 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -242,7 +242,8 @@ async function setupBrowserInvokeChannel(serverURL: string) { invoke(msg.module, msg.method, msg.args); } }); - // 连接建立时同步一次设计分辨率(首次进入 / 断线重连时补齐错过的变更) + // Reconcile feature-local runtime state after first connection or reconnect. + // Reference images need this because their Sprite objects are not persisted with configuration. socket.on('connect', () => { invoke('Engine', 'syncDesignResolution', []); invoke('ReferenceImage', 'syncFromAuthority', []); diff --git a/src/core/scene/scene-process/service/reference-image.ts b/src/core/scene/scene-process/service/reference-image.ts index 7143f0398..d43012e74 100644 --- a/src/core/scene/scene-process/service/reference-image.ts +++ b/src/core/scene/scene-process/service/reference-image.ts @@ -1,3 +1,4 @@ +/** Scene-side reference-image runtime: it renders ephemeral editor nodes from main-process authority state. */ import { Canvas, CCObject, Color, Layers, Node, Sprite, SpriteFrame, UITransform } from 'cc'; import { IReferenceImageCancelOptions, @@ -102,11 +103,12 @@ export class ReferenceImageService extends BaseService im } async refresh(): Promise { - await this.pullAuthoritySnapshot(); + const before = this.createRuntimeStateKey(); + const authorityChanged = await this.pullAuthoritySnapshot(); this.invalidatePreview(); this.error = null; await this.loadBoundImage(true); - this.publishState(); + if (authorityChanged || before !== this.createRuntimeStateKey()) this.publishState(); return this.createState(); } @@ -167,8 +169,7 @@ export class ReferenceImageService extends BaseService im }; private async handleDimensionChanged(): Promise { - await this.reconcileRuntime(); - this.publishState(); + if (await this.reconcileRuntime()) this.publishState(); } /** Socket entrypoint; reconnects reconcile runtime even when authority revision is unchanged. */ @@ -217,6 +218,7 @@ export class ReferenceImageService extends BaseService im } private createRuntimeStateKey(): string { + // Only these public runtime fields determine whether a lifecycle event needs a state broadcast. const visibility = this.computeVisibility(); return JSON.stringify({ sceneUuid: this.currentSceneUuid, @@ -469,10 +471,20 @@ export class ReferenceImageService extends BaseService im && snapshot.revision <= this.authorityRevision) { return false; } + const previewTargetPath = this.activeInteractionId === null ? null : this.getCurrentPath(); this.config = normalizeReferenceImageConfig(snapshot.config); this.authorityInstanceId = snapshot.instanceId; this.authorityRevision = snapshot.revision; - this.invalidatePreview(); + // Authority snapshots describe the whole shared library. Keep a local slider + // interaction alive when another Scene changes unrelated library entries; its + // commit is still applied against the Store's latest configuration. A changed + // or removed current binding is the actual boundary that invalidates the edit. + if (this.activeInteractionId !== null + && (!previewTargetPath + || this.getCurrentPath() !== previewTargetPath + || !this.config.images.some((image) => image.path === previewTargetPath))) { + this.invalidatePreview(); + } if (reconcileRuntime) await this.reconcileRuntime(); if (publish) this.publishState(); return true; diff --git a/src/core/scene/test/reference-image-service.test.ts b/src/core/scene/test/reference-image-service.test.ts index dba501494..2f994e743 100644 --- a/src/core/scene/test/reference-image-service.test.ts +++ b/src/core/scene/test/reference-image-service.test.ts @@ -1,3 +1,4 @@ +/** Targeted ReferenceImageService tests for authority sync, runtime rendering, and preview boundaries. */ const request = jest.fn(); const broadcast = jest.fn(); const repaintInEditMode = jest.fn(); @@ -74,7 +75,15 @@ describe('ReferenceImageService state and preview boundary', () => { sceneBindings: { ...authority.config.sceneBindings }, }; let changed = false; - if (mutation.type === 'clear-binding' && config.sceneBindings[mutation.sceneUuid]) { + if (mutation.type === 'add-and-select') { + if (!config.images.some((image: any) => image.path === mutation.path)) { + config.images.push({ path: mutation.path, x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 100 }); + } + if (config.sceneBindings[mutation.sceneUuid] !== mutation.path) { + config.sceneBindings[mutation.sceneUuid] = mutation.path; + changed = true; + } + } else if (mutation.type === 'clear-binding' && config.sceneBindings[mutation.sceneUuid]) { delete config.sceneBindings[mutation.sceneUuid]; changed = true; } else if (mutation.type === 'commit-parameters') { @@ -200,6 +209,65 @@ describe('ReferenceImageService state and preview boundary', () => { expect(request).not.toHaveBeenCalledWith('referenceImageStore', 'mutate', expect.anything()); }); + it('keeps a preview committable after another Scene adds a library image', async () => { + await service.previewParameters({ interactionId: 12, patch: { opacity: 40 } }); + + const otherScene = new ReferenceImageService(); + Object.assign(otherScene as any, { + currentSceneUuid: 'scene-b', + authorityRevision: 1, + authorityInstanceId: 'authority-a', + }); + jest.spyOn(otherScene as any, 'createSpriteFrameForPath').mockResolvedValue({ destroy: jest.fn() }); + jest.spyOn(otherScene as any, 'loadBoundImage').mockResolvedValue(undefined); + + await otherScene.addAndSelect({ path: 'C:\\other.png' }); + await service.syncFromAuthority(false); + + expect((service as any).activeInteractionId).toBe(12); + expect((service as any).previewPatch).toEqual({ opacity: 40 }); + expect((service as any).interactionWatermark).toBe(0); + + await service.commitParameters({ interactionId: 12, patch: { opacity: 40 } }); + + expect(authority.config.images).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'C:\\design.png', opacity: 40 }), + expect.objectContaining({ path: 'C:\\other.png' }), + ])); + }); + + it('cancels a preview when an authority snapshot removes its current binding', async () => { + await service.previewParameters({ interactionId: 13, patch: { opacity: 40 } }); + authority = { + instanceId: 'authority-a', + revision: 2, + changed: true, + config: { + desiredVisible: true, + images: [], + sceneBindings: {}, + }, + }; + + await service.syncFromAuthority(false); + + expect((service as any).activeInteractionId).toBeNull(); + expect((service as any).interactionWatermark).toBe(13); + request.mockClear(); + await service.commitParameters({ interactionId: 13, patch: { opacity: 40 } }); + expect(request).not.toHaveBeenCalledWith('referenceImageStore', 'mutate', expect.anything()); + }); + + it('does not publish for a no-op refresh or repeated 2D dimension signal', async () => { + jest.spyOn(service as any, 'loadBoundImage').mockResolvedValue(undefined); + + await service.refresh(); + await (service as any).handleDimensionChanged(); + await (service as any).handleDimensionChanged(); + + expect(broadcast).not.toHaveBeenCalled(); + }); + it('keeps preview ephemeral and rejects a late preview after commit', async () => { await service.previewParameters({ interactionId: 4, patch: { opacity: 40 } }); diff --git a/src/core/scene/test/reference-image-store.test.ts b/src/core/scene/test/reference-image-store.test.ts index f7d193c71..4355bea3d 100644 --- a/src/core/scene/test/reference-image-store.test.ts +++ b/src/core/scene/test/reference-image-store.test.ts @@ -1,3 +1,4 @@ +/** Targeted ReferenceImageStore tests for serialized project-local mutations and fan-out. */ const get = jest.fn(); const set = jest.fn(); const emit = jest.fn(); diff --git a/workflow/build-scene-bundle.js b/workflow/build-scene-bundle.js index 3586accc6..6e688b05d 100644 --- a/workflow/build-scene-bundle.js +++ b/workflow/build-scene-bundle.js @@ -23,6 +23,7 @@ async function buildSceneBundle() { virtual({ entry: ` import * as Bridge from '${bridgeFile}'; + // Keep the decorated ReferenceImage service reachable so Rollup retains its registration side effect. const { startup, serviceManager, EditorExtends, Service, ReferenceImageService } = Bridge; export { startup, serviceManager, EditorExtends, Service, ReferenceImageService }; `