From b7e35496ef7cee846c9a47e56ac70430d92384ec Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sat, 23 May 2026 12:45:29 +0800 Subject: [PATCH 1/3] feat: add static form submit role --- package.json | 2 +- spec/RouteEngine.form.test.js | 62 +++++--------- ...EffectsHandler.routeGraphicsEvents.test.js | 17 +++- src/createEffectsHandler.js | 53 +++++++++--- src/schemas/presentationActions.yaml | 4 +- src/schemas/systemActions.yaml | 6 -- src/stores/constructRenderState.js | 82 ++++++++++--------- src/stores/system.store.js | 2 - 8 files changed, 124 insertions(+), 104 deletions(-) diff --git a/package.json b/package.json index c2e573c..d0056a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "1.20.2", + "version": "1.20.3", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git", diff --git a/spec/RouteEngine.form.test.js b/spec/RouteEngine.form.test.js index bb1556b..3046c6a 100644 --- a/spec/RouteEngine.form.test.js +++ b/spec/RouteEngine.form.test.js @@ -31,28 +31,11 @@ const createProjectData = () => ({ { id: "submit-button", type: "rect", + formRole: "submit", x: 100, y: 230, width: 120, height: 48, - click: { - payload: { - actions: "${form.submitActions}", - }, - }, - }, - { - id: "cancel-button", - type: "rect", - x: 240, - y: 230, - width: 120, - height: 48, - click: { - payload: { - actions: "${form.cancelActions}", - }, - }, }, ], }, @@ -95,6 +78,7 @@ const createProjectData = () => ({ id: "line1", actions: { form: { + id: "profile-contact-form", resourceId: "profileForm", fields: { name: { @@ -113,9 +97,6 @@ const createProjectData = () => ({ submitActions: { nextLine: {}, }, - cancelActions: { - rollbackByOffset: {}, - }, }, }, }, @@ -171,13 +152,11 @@ const findElement = (node, id) => { }; describe("RouteEngine forms", () => { - it("renders form inputs with field drafts and prepared submit/cancel actions", () => { + it("renders form inputs with field drafts and formRole submit actions", () => { const engine = createEngine(); const renderState = engine.selectRenderState(); const nameInput = findElement(renderState.elements, "name-input"); const submitButton = findElement(renderState.elements, "submit-button"); - const cancelButton = findElement(renderState.elements, "cancel-button"); - expect(nameInput).toMatchObject({ type: "input", value: "", @@ -189,7 +168,6 @@ describe("RouteEngine forms", () => { updateFormField: { field: "name", value: "_event.value", - _interactionSource: "form", }, }, }, @@ -199,37 +177,36 @@ describe("RouteEngine forms", () => { _interactionSource: "form", actions: { submitForm: { - formId: "profileForm", + formKey: "section1:line1:profile-contact-form", }, }, }, }, }); - expect(nameInput.change.payload._formKey).toBe( - "section1:line1:profileForm", + expect(nameInput.change.payload).not.toHaveProperty("_formId"); + expect(nameInput.change.payload).not.toHaveProperty("_formKey"); + expect(nameInput.change.payload.actions.updateFormField.formKey).toBe( + "section1:line1:profile-contact-form", + ); + expect(nameInput.change.payload.actions.updateFormField).not.toHaveProperty( + "formId", ); + expect(submitButton.click.payload).not.toHaveProperty("_formId"); + expect(submitButton.click.payload).not.toHaveProperty("_formKey"); expect(submitButton.click.payload).toMatchObject({ _interactionSource: "form", - _formKey: "section1:line1:profileForm", actions: { submitForm: { - formId: "profileForm", - formKey: "section1:line1:profileForm", + formKey: "section1:line1:profile-contact-form", actions: { nextLine: {}, }, }, }, }); - expect(cancelButton.click.payload.actions).toEqual({ - cancelForm: { - formId: "profileForm", - formKey: "section1:line1:profileForm", - actions: { - rollbackByOffset: {}, - }, - }, - }); + expect(submitButton.click.payload.actions.submitForm).not.toHaveProperty( + "formId", + ); }); it("keeps edits transient until a valid multi-field submit commits variables and runs actions", () => { @@ -260,8 +237,9 @@ describe("RouteEngine forms", () => { expect(nameInput.value).toBe(" Ada "); expect(emailInput.value).toBe(""); expect( - engine.selectSystemState().global.formDrafts["section1:line1:profileForm"] - .errors.email, + engine.selectSystemState().global.formDrafts[ + "section1:line1:profile-contact-form" + ].errors.email, ).toBe("required"); engine.handleActions(emailInput.change.payload.actions, { diff --git a/spec/createEffectsHandler.routeGraphicsEvents.test.js b/spec/createEffectsHandler.routeGraphicsEvents.test.js index e068082..b218b3f 100644 --- a/spec/createEffectsHandler.routeGraphicsEvents.test.js +++ b/spec/createEffectsHandler.routeGraphicsEvents.test.js @@ -344,7 +344,6 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { await eventHandler("change", { _interactionSource: "form", - _formKey: "section1:line1:profileForm", actions: { updateFormField: { formKey: "section1:line1:profileForm", @@ -374,6 +373,22 @@ describe("createEffectsHandler RouteGraphics event bridge", () => { interactionSource: "form", }, ); + + engine.handleActions.mockClear(); + + await eventHandler("click", { + _interactionSource: "form", + actions: { + submitForm: { + formKey: "section1:line9:profileForm", + actions: { + nextLine: {}, + }, + }, + }, + }); + + expect(engine.handleActions).not.toHaveBeenCalled(); }); it("coalesces replaceable effects by name and keeps the last payload", () => { diff --git a/src/createEffectsHandler.js b/src/createEffectsHandler.js index 78cf348..966e8ec 100644 --- a/src/createEffectsHandler.js +++ b/src/createEffectsHandler.js @@ -382,6 +382,12 @@ const createEffectsHandler = ({ return true; }; + const formInteractionActionTypes = new Set([ + "updateFormField", + "submitForm", + "cancelForm", + ]); + const getActiveInteraction = (engine) => { if (typeof engine?.selectActiveInteraction === "function") { return engine.selectActiveInteraction(); @@ -399,6 +405,12 @@ const createEffectsHandler = ({ return null; }; + const getFormInteractionKey = (value) => value?._formKey ?? value?.formKey; + + const hasMatchingFormKey = (value, activeInteraction) => { + return getFormInteractionKey(value) === activeInteraction?.formKey; + }; + const matchesInteraction = (value, activeInteraction) => { if (!value || typeof value !== "object" || Array.isArray(value)) { return false; @@ -408,31 +420,48 @@ const createEffectsHandler = ({ return false; } - if ( - activeInteraction.source === "form" && - value._formKey && - value._formKey !== activeInteraction.formKey - ) { + if (activeInteraction.source === "form") { + return hasMatchingFormKey(value, activeInteraction); + } + + return true; + }; + + const matchesFormAction = (value, activeInteraction) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { return false; } - if ( - activeInteraction.source === "form" && - value.formKey && - value.formKey !== activeInteraction.formKey - ) { + if (activeInteraction?.source !== "form") { return false; } - return true; + return hasMatchingFormKey(value, activeInteraction); }; const isInteractionPayload = (payload = {}, activeInteraction) => { + const actions = payload?.actions; + + if (activeInteraction?.source === "form") { + if (matchesInteraction(payload, activeInteraction)) { + return true; + } + + if (!actions || typeof actions !== "object" || Array.isArray(actions)) { + return false; + } + + return Object.entries(actions).some( + ([actionType, actionPayload]) => + formInteractionActionTypes.has(actionType) && + matchesFormAction(actionPayload, activeInteraction), + ); + } + if (matchesInteraction(payload, activeInteraction)) { return true; } - const actions = payload?.actions; if (!actions || typeof actions !== "object" || Array.isArray(actions)) { return false; } diff --git a/src/schemas/presentationActions.yaml b/src/schemas/presentationActions.yaml index 4733ea7..ac69855 100644 --- a/src/schemas/presentationActions.yaml +++ b/src/schemas/presentationActions.yaml @@ -372,10 +372,10 @@ properties: properties: id: type: string - description: Optional stable form ID. Defaults to resourceId. + description: Stable generated form ID. Defaults to resourceId for compatibility. resourceId: type: string - description: ID of the form layout UI element + description: ID of the form layout resource to render fields: type: object description: Form fields keyed by layout field ID diff --git a/src/schemas/systemActions.yaml b/src/schemas/systemActions.yaml index 939c35c..e04b72a 100644 --- a/src/schemas/systemActions.yaml +++ b/src/schemas/systemActions.yaml @@ -434,8 +434,6 @@ properties: type: object description: Update a transient active form field draft properties: - formId: - type: string formKey: type: string field: @@ -449,8 +447,6 @@ properties: type: object description: Submit the active form and run actions after a valid commit properties: - formId: - type: string formKey: type: string actions: @@ -462,8 +458,6 @@ properties: type: object description: Cancel the active form and run follow-up actions properties: - formId: - type: string formKey: type: string actions: diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 87a1899..1406b7c 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -1540,6 +1540,8 @@ const FORM_EVENT_KEYS = new Set([ "compositionEnd", ]); +const FORM_ROLE_SUBMIT = "submit"; + const mergeEventActions = (eventConfig, actions) => { const payload = eventConfig?.payload && @@ -1564,9 +1566,9 @@ const mergeEventActions = (eventConfig, actions) => { }; }; -const enrichFormInputs = (node, form) => { +const enrichFormElements = (node, form) => { if (Array.isArray(node)) { - return node.map((item) => enrichFormInputs(item, form)); + return node.map((item) => enrichFormElements(item, form)); } if (!node || typeof node !== "object") { @@ -1575,45 +1577,51 @@ const enrichFormInputs = (node, form) => { const enrichedNode = {}; for (const [key, value] of Object.entries(node)) { - enrichedNode[key] = enrichFormInputs(value, form); + enrichedNode[key] = enrichFormElements(value, form); } - if (enrichedNode.type !== "input" || typeof enrichedNode.field !== "string") { - return enrichedNode; - } + let formNode = enrichedNode; + + if (formNode.type === "input" && typeof formNode.field === "string") { + const field = form?.fields?.[formNode.field]; - const field = form?.fields?.[enrichedNode.field]; - if (!field) { - return enrichedNode; + if (field) { + const updateFormField = { + updateFormField: { + formKey: form.key, + field: field.id, + value: "_event.value", + }, + }; + + formNode = { + ...formNode, + value: field.value, + placeholder: + formNode.placeholder === undefined + ? field.placeholder + : formNode.placeholder, + multiline: + formNode.multiline === undefined + ? field.multiline + : formNode.multiline, + ...(formNode.maxLength === undefined && field.maxLength !== undefined + ? { maxLength: field.maxLength } + : {}), + change: mergeEventActions(formNode.change, updateFormField), + submit: mergeEventActions(formNode.submit, form.submitActions), + }; + } } - const updateFormField = { - updateFormField: { - formId: form.id, - formKey: form.key, - field: field.id, - value: "_event.value", - _interactionSource: "form", - }, - }; + if (formNode.formRole === FORM_ROLE_SUBMIT) { + return { + ...formNode, + click: mergeEventActions(formNode.click, form.submitActions), + }; + } - return { - ...enrichedNode, - value: field.value, - placeholder: - enrichedNode.placeholder === undefined - ? field.placeholder - : enrichedNode.placeholder, - multiline: - enrichedNode.multiline === undefined - ? field.multiline - : enrichedNode.multiline, - ...(enrichedNode.maxLength === undefined && field.maxLength !== undefined - ? { maxLength: field.maxLength } - : {}), - change: mergeEventActions(enrichedNode.change, updateFormField), - submit: mergeEventActions(enrichedNode.submit, form.submitActions), - }; + return formNode; }; const tagFormInteractionSource = (node, form) => { @@ -1641,8 +1649,6 @@ const tagFormInteractionSource = (node, form) => { payload: { ...payload, _interactionSource: "form", - _formId: form.id, - _formKey: form.key, }, }; } @@ -2987,7 +2993,7 @@ export const addForm = ( }); const formElements = tagFormInteractionSource( resolveLayoutResourceIds( - settleTextRevealIfCompleted(enrichFormInputs(processedForm, form), { + settleTextRevealIfCompleted(enrichFormElements(processedForm, form), { isLineCompleted, skipMode, skipTransitionsAndAnimations, diff --git a/src/stores/system.store.js b/src/stores/system.store.js index c56ddad..4c2bbab 100644 --- a/src/stores/system.store.js +++ b/src/stores/system.store.js @@ -885,14 +885,12 @@ const buildFormTemplateData = ({ state, form, pointer }) => { values, submitActions: { submitForm: { - formId, formKey, actions: cloneStateValue(form.submitActions ?? {}), }, }, cancelActions: { cancelForm: { - formId, formKey, actions: cloneStateValue(form.cancelActions ?? {}), }, From 5241079b04b080ddc5542a025479f5913b0b1c3d Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sun, 24 May 2026 17:28:43 +0800 Subject: [PATCH 2/3] Add animation playback speed control --- docs/AnimationModel.md | 16 ++ docs/RouteEngine.md | 5 + package.json | 2 +- spec/RouteEngine.rollbackRenderState.test.js | 58 ++++++ ...RenderState.animationPlaybackSpeed.test.js | 183 ++++++++++++++++++ spec/projectDataSchema.test.js | 50 +++++ src/schemas/presentationActions.yaml | 6 +- src/stores/constructRenderState.js | 76 +++++++- vt/reference/visual/playback-speed-01.webp | 3 + vt/specs/visual/playback-speed.yaml | 81 ++++++++ 10 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 spec/constructRenderState.animationPlaybackSpeed.test.js create mode 100644 vt/reference/visual/playback-speed-01.webp create mode 100644 vt/specs/visual/playback-speed.yaml diff --git a/docs/AnimationModel.md b/docs/AnimationModel.md index 72d6e10..eaba7f2 100644 --- a/docs/AnimationModel.md +++ b/docs/AnimationModel.md @@ -119,6 +119,22 @@ In practice: - the selection stops when the background changes or a later background action replaces the animation selection +### Playback speed + +Any action-level animation selection may set: + +```yaml +animations: + resourceId: bg-dissolve + playback: + speed: 2 +``` + +`speed` is a unitless multiplier. `1` is authored speed, `2` is twice as fast, +and `0.5` is half speed. Keyframe `duration` values stay authored in +milliseconds; playback speed scales elapsed time at runtime and scales +persistent animation expiry by the same multiplier. + ### Same-subject transitions Because the comparison is done against resolved previous and next presentation diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index 275e145..12a4540 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -672,6 +672,11 @@ Actions that can be attached to lines to control presentation: | `form` | `{ resourceId, fields, submitActions?, cancelActions? }` | Display a blocking multi-input form | | `cleanAll` | `true` | Clear all presentation state | +Animation selections use `animations.resourceId` plus optional +`animations.playback`. `playback.speed` is a unitless multiplier: `1` is normal, +`2` is twice as fast, and `0.5` is half speed. `playback.continuity` defaults to +render-scoped behavior when omitted. + ### Visual Layers Visual items use a flat `items` array. Each item can set numeric `layer` to diff --git a/package.json b/package.json index d0056a4..28c554e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-engine-js", - "version": "1.20.3", + "version": "1.21.0", "description": "A lightweight Visual Novel engine built in JavaScript for creating interactive narrative games with branching storylines", "repository": { "type": "git", diff --git a/spec/RouteEngine.rollbackRenderState.test.js b/spec/RouteEngine.rollbackRenderState.test.js index 81488e6..81e5782 100644 --- a/spec/RouteEngine.rollbackRenderState.test.js +++ b/spec/RouteEngine.rollbackRenderState.test.js @@ -159,6 +159,7 @@ const createProjectData = () => ({ const createPersistentBackgroundProjectData = ({ continuationLineCount = 1, + playbackSpeed, } = {}) => ({ screen: { width: 1920, @@ -244,6 +245,9 @@ const createPersistentBackgroundProjectData = ({ resourceId: "fadeInLong", playback: { continuity: "persistent", + ...(playbackSpeed === undefined + ? {} + : { speed: playbackSpeed }), }, }, }, @@ -640,6 +644,60 @@ describe("RouteEngine rollback render state", () => { } }); + it("expires persistent background playback using playback speed", () => { + const routeGraphics = { + render: vi.fn(), + }; + + let engine; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + routeGraphics, + ticker: createTicker(), + persistence: createNoopPersistence(), + }); + + engine = createRouteEngine({ + handlePendingEffects: effectsHandler, + }); + + const nowSpy = vi.spyOn(Date, "now"); + + try { + nowSpy.mockReturnValue(0); + engine.init({ + initialState: { + projectData: createPersistentBackgroundProjectData({ + playbackSpeed: 2, + }), + }, + }); + + const initialRender = routeGraphics.render.mock.calls.at(-1)?.[0]; + expect(initialRender.animations).toEqual([ + expect.objectContaining({ + playback: { + continuity: "persistent", + speed: 2, + }, + }), + ]); + + nowSpy.mockReturnValue(5001); + expect( + effectsHandler.handleRouteGraphicsEvent("renderComplete", { + id: initialRender.id, + aborted: false, + }), + ).toBe(true); + + const completedRender = routeGraphics.render.mock.calls.at(-1)?.[0]; + expect(completedRender.animations).toEqual([]); + } finally { + nowSpy.mockRestore(); + } + }); + it("does not renew persistent background playback when commit crosses the expiry boundary", () => { const routeGraphics = { render: vi.fn(), diff --git a/spec/constructRenderState.animationPlaybackSpeed.test.js b/spec/constructRenderState.animationPlaybackSpeed.test.js new file mode 100644 index 0000000..eeb8f65 --- /dev/null +++ b/spec/constructRenderState.animationPlaybackSpeed.test.js @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { + constructRenderState, + getAnimationInstanceDurationMs, + getPersistentAnimationContinuationKey, +} from "../src/stores/constructRenderState.js"; + +const createResources = () => ({ + images: { + bg1: { + fileId: "bg.png", + width: 1920, + height: 1080, + }, + }, + animations: { + fadeIn: { + type: "transition", + next: { + tween: { + alpha: { + initialValue: 0, + keyframes: [{ duration: 1000, value: 1 }], + }, + }, + }, + }, + }, +}); + +describe("constructRenderState animation playback speed", () => { + it("passes action-level playback speed through animation instances", () => { + const renderState = constructRenderState({ + presentationState: { + background: { + resourceId: "bg1", + animations: { + resourceId: "fadeIn", + playback: { + speed: 2, + }, + }, + }, + }, + resources: createResources(), + }); + + expect(renderState.animations).toEqual([ + expect.objectContaining({ + id: "bg-cg-animation-in", + playback: { + speed: 2, + }, + }), + ]); + }); + + it("uses playback speed when calculating effective animation duration", () => { + expect( + getAnimationInstanceDurationMs({ + type: "update", + playback: { + speed: 2, + }, + tween: { + x: { + keyframes: [{ duration: 1000, value: 100 }], + }, + }, + }), + ).toBe(500); + + expect( + getAnimationInstanceDurationMs({ + type: "transition", + playback: { + speed: 0.5, + }, + next: { + tween: { + alpha: { + keyframes: [{ duration: 1000, value: 1 }], + }, + }, + }, + }), + ).toBe(2000); + }); + + it("uses speed in persistent continuation keys but ignores continuity", () => { + const baseAnimation = { + id: "bg-cg-animation-transition", + targetId: "bg-cg-background-sprite", + type: "transition", + playback: { + continuity: "persistent", + speed: 1, + }, + next: { + tween: { + alpha: { + keyframes: [{ duration: 1000, value: 1 }], + }, + }, + }, + }; + + expect(getPersistentAnimationContinuationKey(baseAnimation)).toBe( + getPersistentAnimationContinuationKey({ + ...baseAnimation, + playback: { + continuity: "render", + speed: 1, + }, + }), + ); + + expect( + getPersistentAnimationContinuationKey({ + ...baseAnimation, + playback: { + continuity: "persistent", + }, + }), + ).toBe( + getPersistentAnimationContinuationKey({ + ...baseAnimation, + playback: { + continuity: "persistent", + speed: 1, + }, + }), + ); + + expect(getPersistentAnimationContinuationKey(baseAnimation)).not.toBe( + getPersistentAnimationContinuationKey({ + ...baseAnimation, + playback: { + continuity: "persistent", + speed: 2, + }, + }), + ); + }); + + it("rejects invalid playback speeds before creating render-state animations", () => { + expect(() => + constructRenderState({ + presentationState: { + background: { + resourceId: "bg1", + animations: { + resourceId: "fadeIn", + playback: { + speed: 0, + }, + }, + }, + }, + resources: createResources(), + }), + ).toThrow( + "[background.animations.playback] playback.speed must be a finite number greater than 0.", + ); + }); + + it("rejects non-object playback before creating render-state animations", () => { + expect(() => + constructRenderState({ + presentationState: { + background: { + resourceId: "bg1", + animations: { + resourceId: "fadeIn", + playback: null, + }, + }, + }, + resources: createResources(), + }), + ).toThrow("[background.animations.playback] playback must be an object."); + }); +}); diff --git a/spec/projectDataSchema.test.js b/spec/projectDataSchema.test.js index b45d7bf..80a5a68 100644 --- a/spec/projectDataSchema.test.js +++ b/spec/projectDataSchema.test.js @@ -529,6 +529,7 @@ describe("projectData schema", () => { resourceId: "fadeIn", playback: { continuity: "persistent", + speed: 2, }, }, }, @@ -537,6 +538,55 @@ describe("projectData schema", () => { expect(validatePresentationActions.errors).toBeNull(); }); + it("accepts animation playback speed without explicit continuity", () => { + expect( + validatePresentationActions({ + character: { + items: [ + { + id: "hero", + animations: { + resourceId: "slide", + playback: { + speed: 0.5, + }, + }, + }, + ], + }, + }), + ).toBe(true); + expect(validatePresentationActions.errors).toBeNull(); + }); + + it("rejects invalid animation playback speed in presentation actions", () => { + expect( + validatePresentationActions({ + visual: { + items: [ + { + id: "burst", + animations: { + resourceId: "pulse", + playback: { + speed: 0, + }, + }, + }, + ], + }, + }), + ).toBe(false); + expect(validatePresentationActions.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + instancePath: "/visual/items/0/animations/playback/speed", + keyword: "exclusiveMinimum", + }), + ]), + ); + }); + it("accepts whole-screen animation selections in presentation actions", () => { expect( validatePresentationActions({ diff --git a/src/schemas/presentationActions.yaml b/src/schemas/presentationActions.yaml index ac69855..d0ca1c9 100644 --- a/src/schemas/presentationActions.yaml +++ b/src/schemas/presentationActions.yaml @@ -10,8 +10,12 @@ definitions: continuity: type: string enum: [render, persistent] + default: render description: Whether animation continuity is render-scoped or persists across later renders while the action remains active - required: [continuity] + speed: + type: number + exclusiveMinimum: 0 + description: Playback speed multiplier. 1 is authored speed, 2 is twice as fast, 0.5 is half speed. additionalProperties: false animationRef: diff --git a/src/stores/constructRenderState.js b/src/stores/constructRenderState.js index 1406b7c..7a589a1 100644 --- a/src/stores/constructRenderState.js +++ b/src/stores/constructRenderState.js @@ -141,8 +141,14 @@ const createAnimationInstance = ({ delete normalized.playback; normalized.id = id; normalized.targetId = targetId; - if (playback) { - normalized.playback = structuredClone(playback); + if (playback !== undefined) { + const normalizedPlayback = normalizeAnimationPlayback( + playback, + `${animationPath}.playback`, + ); + if (normalizedPlayback !== undefined) { + normalized.playback = normalizedPlayback; + } } return normalized; }; @@ -196,6 +202,59 @@ const resolveAnimationPlayback = (animationDef) => { return animationDef.playback; }; +const normalizeAnimationPlayback = ( + playback, + animationPath = "animation.playback", +) => { + if (playback === undefined) { + return undefined; + } + + if (!playback || typeof playback !== "object" || Array.isArray(playback)) { + throw new Error(`[${animationPath}] playback must be an object.`); + } + + if ( + playback.continuity !== undefined && + playback.continuity !== "render" && + playback.continuity !== "persistent" + ) { + throw new Error( + `[${animationPath}] playback.continuity must be "render" or "persistent".`, + ); + } + + if ( + playback.speed !== undefined && + (typeof playback.speed !== "number" || + !Number.isFinite(playback.speed) || + playback.speed <= 0) + ) { + throw new Error( + `[${animationPath}] playback.speed must be a finite number greater than 0.`, + ); + } + + const normalized = {}; + + if (playback.continuity !== undefined) { + normalized.continuity = playback.continuity; + } + + if (playback.speed !== undefined && playback.speed !== 1) { + normalized.speed = playback.speed; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +}; + +const getAnimationPlaybackSpeed = (animationInstance) => { + const speed = animationInstance?.playback?.speed; + return typeof speed === "number" && Number.isFinite(speed) && speed > 0 + ? speed + : 1; +}; + const hasOwnProperty = (value, key) => Object.prototype.hasOwnProperty.call(value, key); @@ -229,7 +288,15 @@ export const getPersistentAnimationContinuationKey = (animationInstance) => { } const normalized = structuredClone(animationInstance); - delete normalized.playback; + if (normalized.playback) { + delete normalized.playback.continuity; + if (normalized.playback.speed === 1) { + delete normalized.playback.speed; + } + if (Object.keys(normalized.playback).length === 0) { + delete normalized.playback; + } + } return JSON.stringify(sortObjectKeysDeep(normalized)); }; @@ -274,12 +341,13 @@ export const getAnimationInstanceDurationMs = (animationInstance) => { return 0; } - return Math.max( + const authoredDurationMs = Math.max( getTweenDurationMs(animationInstance.tween), getTweenDurationMs(animationInstance.prev?.tween), getTweenDurationMs(animationInstance.next?.tween), getTweenPropertyDurationMs(animationInstance.mask?.progress), ); + return authoredDurationMs / getAnimationPlaybackSpeed(animationInstance); }; const hasPersistentAnimationContinuation = ({ diff --git a/vt/reference/visual/playback-speed-01.webp b/vt/reference/visual/playback-speed-01.webp new file mode 100644 index 0000000..6a49f50 --- /dev/null +++ b/vt/reference/visual/playback-speed-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56c195a05f3be623af00fc5c6ad4682d2cf933378cc85b28c81625d3fe88f6a6 +size 2098 diff --git a/vt/specs/visual/playback-speed.yaml b/vt/specs/visual/playback-speed.yaml new file mode 100644 index 0000000..408287f --- /dev/null +++ b/vt/specs/visual/playback-speed.yaml @@ -0,0 +1,81 @@ +--- +title: Visual Playback Speed +skipInitialScreenshot: true +description: | + Visual update animation playback speed should scale elapsed time without + duplicating the reusable animation resource. + + - the top visual uses speed 2 and should reach the end after 500ms + - the bottom visual uses speed 0.5 and should only be a quarter through after 500ms +steps: + - action: wait + ms: 80 + - action: customEvent + name: snapShotKeyFrame + detail: + deltaMS: 500 + - action: screenshot +--- +screen: + width: 1920 + height: 1080 +resources: + images: + marker: + fileId: a32kf3 + width: 100 + height: 100 + transforms: + topStart: + x: 300 + y: 360 + anchorX: 0.5 + anchorY: 0.5 + rotation: 0 + scaleX: 1 + scaleY: 1 + bottomStart: + x: 300 + y: 720 + anchorX: 0.5 + anchorY: 0.5 + rotation: 0 + scaleX: 1 + scaleY: 1 + animations: + slideRight: + type: update + tween: + x: + initialValue: 300 + keyframes: + - duration: 1000 + value: 1300 + easing: linear +story: + initialSceneId: speedScene + scenes: + speedScene: + name: Speed Scene + initialSectionId: main + sections: + main: + lines: + - id: line1 + actions: + visual: + items: + - id: fast-marker + resourceId: marker + transformId: topStart + animations: + resourceId: slideRight + playback: + speed: 2 + - id: slow-marker + resourceId: marker + transformId: bottomStart + animations: + resourceId: slideRight + playback: + speed: 0.5 From d142fd091fb3ef0bf92a7f0d7180c6e3bed68b7d Mon Sep 17 00:00:00 2001 From: han4wluc Date: Sun, 24 May 2026 21:03:53 +0800 Subject: [PATCH 3/3] Use route-graphics 1.22.0 --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 5a596e1..efdf161 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ "ajv": "^8.18.0", "husky": "^9.1.7", "prettier": "^3.7.4", - "route-graphics": "1.17.1", + "route-graphics": "1.22.0", "vitest": "4.0.16", }, }, @@ -327,7 +327,7 @@ "rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="], - "route-graphics": ["route-graphics@1.17.1", "", { "dependencies": { "@pixi/unsafe-eval": "^7.4.3", "hotkeys-js": "^4.0.0-beta.7", "js-yaml": "^4.1.0", "pixi.js": "^8.7.1", "playwright": "^1.44.0" }, "bin": { "route-graphics": "bin/route-graphics.js" } }, "sha512-o7Dz1sqT1kGTWZEaEW90Ul89UfYJzeWiq6CpoGizvyqLCHWOqyA1KMgwn5odfGYglbcYFS8ZbbY+fcfaZNd8mQ=="], + "route-graphics": ["route-graphics@1.22.0", "", { "dependencies": { "@pixi/unsafe-eval": "^7.4.3", "hotkeys-js": "^4.0.0-beta.7", "js-yaml": "^4.1.0", "pixi.js": "^8.7.1", "playwright": "^1.44.0" }, "bin": { "route-graphics": "bin/route-graphics.js" } }, "sha512-zwMbJCo8ILHk+hjWVK1oYohhmD7IXfphSpy+SQEMeMaH5KgJvqDYgUxdnmFxbOjEMFybyjL2lbBCttnUBgMZfw=="], "semver": ["semver@7.6.3", "", { "bin": "bin/semver.js" }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], diff --git a/package.json b/package.json index 28c554e..1efcb54 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "ajv": "^8.18.0", "husky": "^9.1.7", "prettier": "^3.7.4", - "route-graphics": "1.17.1", + "route-graphics": "1.22.0", "vitest": "4.0.16" } }