diff --git a/package.json b/package.json index d628f379..7960a341 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "route-graphics", - "version": "1.7.1", + "version": "1.7.2", "description": "A 2D graphics rendering interface that takes JSON input and renders pixels using PixiJS", "main": "dist/RouteGraphics.js", "type": "module", diff --git a/spec/RouteGraphics.publicApi.spec.js b/spec/RouteGraphics.publicApi.spec.js index 2367d41a..3eeb5eee 100644 --- a/spec/RouteGraphics.publicApi.spec.js +++ b/spec/RouteGraphics.publicApi.spec.js @@ -441,6 +441,93 @@ describe("RouteGraphics public API", () => { }); }); + it("keeps same-id prev-only transitions pending until time advances", async () => { + const eventHandler = vi.fn(); + const { app, pixiMock } = await setupRouteGraphics({ + initOptions: { + eventHandler, + }, + pluginsFactory: async () => { + const [{ rectPlugin }, { tweenPlugin }] = await Promise.all([ + import("../src/plugins/elements/rect/index.js"), + import("../src/plugins/animations/tween/index.js"), + ]); + + return { + elements: [rectPlugin], + animations: [tweenPlugin], + audio: [], + }; + }, + }); + + app.render({ + id: "baseline", + elements: [ + { + id: "shared-rect", + type: "rect", + x: 0, + y: 0, + width: 120, + height: 80, + fill: "#FFFFFF", + alpha: 1, + }, + ], + }); + + eventHandler.mockClear(); + + app.render({ + id: "same-id-prev-only", + elements: [ + { + id: "shared-rect", + type: "rect", + x: 0, + y: 0, + width: 120, + height: 80, + fill: "#FFFFFF", + alpha: 1, + }, + ], + animations: [ + { + id: "shared-slide-out", + targetId: "shared-rect", + type: "transition", + prev: { + tween: { + translateX: { + initialValue: 0, + keyframes: [{ duration: 1000, value: -1, easing: "linear" }], + }, + }, + }, + }, + ], + }); + + expect(eventHandler).not.toHaveBeenCalledWith("renderComplete", { + id: "same-id-prev-only", + aborted: false, + }); + + app.setAnimationTime(200); + + const appInstance = pixiMock.__getLastApplication(); + const overlay = appInstance.stage.children.at(-1); + + expect(overlay.children).toHaveLength(1); + expect(overlay.children[0].x).toBeLessThan(0); + expect(eventHandler).not.toHaveBeenCalledWith("renderComplete", { + id: "same-id-prev-only", + aborted: false, + }); + }); + it("does not abort pending async adds when re-rendering the same state", async () => { let resolveAdd; const addPromise = new Promise((resolve) => { diff --git a/spec/animations/runReplaceAnimation.spec.js b/spec/animations/runReplaceAnimation.spec.js index 7573eddc..da51084a 100644 --- a/spec/animations/runReplaceAnimation.spec.js +++ b/spec/animations/runReplaceAnimation.spec.js @@ -1,8 +1,9 @@ -import { Container, Texture } from "pixi.js"; +import { Container, Filter, RenderTexture, Texture } from "pixi.js"; import { describe, expect, it, vi } from "vitest"; import { runReplaceAnimation, sampleMaskReveal, + selectSequenceMaskFrameState, } from "../../src/plugins/animations/replace/runReplaceAnimation.js"; import { queueDeferredAnimatedSpritePlay } from "../../src/plugins/elements/renderContext.js"; @@ -91,6 +92,32 @@ describe("runReplaceAnimation", () => { expect(endReveal).toBe(1); }); + it("selects adjacent sequence mask frames for linear sampling", () => { + expect( + selectSequenceMaskFrameState({ + progress: 0.5, + frameCount: 2, + sampleMode: "linear", + }), + ).toEqual({ + fromIndex: 0, + toIndex: 1, + mix: 0.5, + }); + + expect( + selectSequenceMaskFrameState({ + progress: 0.74, + frameCount: 3, + sampleMode: "hold", + }), + ).toEqual({ + fromIndex: 1, + toIndex: 1, + mix: 0, + }); + }); + it("mounts next-only transitions through hidden add flow and reveals the result on complete", () => { const parent = createParent(); const nextDisplayObject = createDisplayObject("scene-root"); @@ -449,6 +476,573 @@ describe("runReplaceAnimation", () => { expect(deferredEffect).toHaveBeenCalledTimes(1); }); + it("uses only the previous snapshot for same-id prev-only transitions", () => { + const prevDisplayObject = createDisplayObject("scene-root"); + const nextDisplayObject = createDisplayObject("scene-root"); + const parent = createParent(prevDisplayObject); + prevDisplayObject.parent = parent; + + const plugin = { + add: vi.fn(({ parent: targetParent, element }) => { + nextDisplayObject.label = element.id; + targetParent.addChild(nextDisplayObject); + }), + delete: vi.fn(({ parent: targetParent, element }) => { + const child = targetParent.children.find( + (item) => item.label === element.id, + ); + if (child) { + targetParent.removeChild(child); + } + }), + }; + + const animationBus = { + dispatch: vi.fn(), + }; + + const app = { + renderer: { + width: 1280, + height: 720, + generateTexture: vi.fn(() => Texture.EMPTY), + }, + }; + + runReplaceAnimation({ + app, + parent, + prevElement: { id: "scene-root", type: "container" }, + nextElement: { id: "scene-root", type: "container", children: [] }, + animation: { + id: "scene-slide-out", + targetId: "scene-root", + type: "transition", + prev: { + tween: { + translateX: { + initialValue: 0, + keyframes: [{ duration: 1000, value: -1, easing: "linear" }], + }, + }, + }, + }, + animations: new Map(), + animationBus, + completionTracker: { + getVersion: () => 11, + track: vi.fn(), + complete: vi.fn(), + }, + eventHandler: vi.fn(), + elementPlugins: [], + plugin, + zIndex: 0, + signal: new AbortController().signal, + }); + + const dispatched = animationBus.dispatch.mock.calls[0][0]; + const overlay = parent.children.find( + (child) => child !== nextDisplayObject, + ); + + expect(overlay.children).toHaveLength(1); + dispatched.payload.applyFrame(500); + expect(overlay.children[0].x).toBeLessThan(0); + expect(nextDisplayObject.visible).toBe(false); + }); + + it("uses only the next snapshot for same-id next-only transitions", () => { + const prevDisplayObject = createDisplayObject("scene-root"); + const nextDisplayObject = createDisplayObject("scene-root"); + const parent = createParent(prevDisplayObject); + prevDisplayObject.parent = parent; + + const plugin = { + add: vi.fn(({ parent: targetParent, element }) => { + nextDisplayObject.label = element.id; + targetParent.addChild(nextDisplayObject); + }), + delete: vi.fn(({ parent: targetParent, element }) => { + const child = targetParent.children.find( + (item) => item.label === element.id, + ); + if (child) { + targetParent.removeChild(child); + } + }), + }; + + const animationBus = { + dispatch: vi.fn(), + }; + + const app = { + renderer: { + width: 1280, + height: 720, + generateTexture: vi.fn(() => Texture.EMPTY), + }, + }; + + runReplaceAnimation({ + app, + parent, + prevElement: { id: "scene-root", type: "container" }, + nextElement: { id: "scene-root", type: "container", children: [] }, + animation: { + id: "scene-slide-in", + targetId: "scene-root", + type: "transition", + next: { + tween: { + translateX: { + initialValue: 1, + keyframes: [{ duration: 1000, value: 0, easing: "linear" }], + }, + }, + }, + }, + animations: new Map(), + animationBus, + completionTracker: { + getVersion: () => 11, + track: vi.fn(), + complete: vi.fn(), + }, + eventHandler: vi.fn(), + elementPlugins: [], + plugin, + zIndex: 0, + signal: new AbortController().signal, + }); + + const dispatched = animationBus.dispatch.mock.calls[0][0]; + const overlay = parent.children.find( + (child) => child !== nextDisplayObject, + ); + + expect(overlay.children).toHaveLength(1); + dispatched.payload.applyFrame(500); + expect(overlay.children[0].x).toBeGreaterThan(0); + expect(nextDisplayObject.visible).toBe(false); + }); + + it("does not preprocess single masks through CPU extraction", () => { + const prevDisplayObject = createDisplayObject("scene-root"); + const nextDisplayObject = createDisplayObject("scene-root"); + const parent = createParent(prevDisplayObject); + prevDisplayObject.parent = parent; + const maskTexture = document.createElement("canvas"); + maskTexture.width = 1; + maskTexture.height = 1; + + const plugin = { + add: vi.fn(({ parent: targetParent, element }) => { + nextDisplayObject.label = element.id; + targetParent.addChild(nextDisplayObject); + }), + delete: vi.fn(({ parent: targetParent, element }) => { + const child = targetParent.children.find( + (item) => item.label === element.id, + ); + if (child) { + targetParent.removeChild(child); + } + }), + }; + + const animationBus = { + dispatch: vi.fn(), + }; + + const app = { + renderer: { + width: 1280, + height: 720, + generateTexture: vi.fn(() => Texture.EMPTY), + render: vi.fn(), + extract: { + pixels: vi.fn(() => ({ + pixels: new Uint8ClampedArray(100 * 100 * 4), + })), + }, + }, + }; + + runReplaceAnimation({ + app, + parent, + prevElement: { id: "scene-root", type: "container" }, + nextElement: { id: "scene-root", type: "container", children: [] }, + animation: { + id: "scene-mask-replace", + targetId: "scene-root", + type: "transition", + mask: { + kind: "single", + texture: maskTexture, + channel: "red", + progress: { + initialValue: 0, + keyframes: [{ duration: 1000, value: 1, easing: "linear" }], + }, + }, + }, + animations: new Map(), + animationBus, + completionTracker: { + getVersion: () => 11, + track: vi.fn(), + complete: vi.fn(), + }, + eventHandler: vi.fn(), + elementPlugins: [], + plugin, + zIndex: 0, + signal: new AbortController().signal, + }); + + const dispatched = animationBus.dispatch.mock.calls[0][0]; + + expect(app.renderer.extract.pixels).not.toHaveBeenCalled(); + dispatched.payload.applyFrame(500); + + expect(app.renderer.extract.pixels).not.toHaveBeenCalled(); + }); + + it("destroys masked replace filters before their bound textures on completion", () => { + const prevDisplayObject = createDisplayObject("scene-root"); + const nextDisplayObject = createDisplayObject("scene-root"); + const parent = createParent(prevDisplayObject); + prevDisplayObject.parent = parent; + const maskTexture = document.createElement("canvas"); + maskTexture.width = 1; + maskTexture.height = 1; + + const plugin = { + add: vi.fn(({ parent: targetParent, element }) => { + nextDisplayObject.label = element.id; + targetParent.addChild(nextDisplayObject); + }), + delete: vi.fn(({ parent: targetParent, element }) => { + const child = targetParent.children.find( + (item) => item.label === element.id, + ); + if (child) { + targetParent.removeChild(child); + } + }), + }; + + const animationBus = { + dispatch: vi.fn(), + }; + const tracker = { + getVersion: () => 11, + track: vi.fn(), + complete: vi.fn(), + }; + const app = { + renderer: { + width: 1280, + height: 720, + generateTexture: vi.fn(() => Texture.EMPTY), + render: vi.fn(), + }, + }; + + const destroyOrder = []; + let renderTextureIndex = 0; + let filterIndex = 0; + const renderTextureSpy = vi + .spyOn(RenderTexture, "create") + .mockImplementation(() => { + const id = renderTextureIndex++; + const texture = Object.create(Texture.EMPTY); + Object.defineProperty(texture, "source", { + value: { + destroyed: false, + }, + writable: true, + configurable: true, + }); + texture.destroy = vi.fn(() => { + texture.destroyed = true; + texture.source.destroyed = true; + destroyOrder.push(`renderTexture:${id}`); + }); + Object.defineProperty(texture, "destroy", { + value: texture.destroy, + writable: true, + configurable: true, + }); + + return texture; + }); + const filterSpy = vi.spyOn(Filter, "from").mockImplementation(() => { + if (filterIndex++ === 0) { + return { + destroy: vi.fn(() => { + destroyOrder.push("maskChannelFilter.destroy"); + }), + resources: {}, + }; + } + + const replaceMaskUniforms = { + uniforms: {}, + update: vi.fn(), + }; + const replaceMaskFilter = { + resources: { + replaceMaskUniforms, + uNextTexture: Texture.EMPTY.source, + uMaskTextureA: Texture.EMPTY.source, + uMaskTextureB: Texture.EMPTY.source, + }, + destroy: vi.fn(() => { + destroyOrder.push("maskFilter.destroy"); + const boundTextures = [ + replaceMaskFilter.resources.uNextTexture, + replaceMaskFilter.resources.uMaskTextureA, + replaceMaskFilter.resources.uMaskTextureB, + ]; + + if (boundTextures.some((resource) => resource?.destroyed)) { + throw new Error("mask filter destroy ran after texture teardown"); + } + }), + }; + + return replaceMaskFilter; + }); + + try { + runReplaceAnimation({ + app, + parent, + prevElement: { id: "scene-root", type: "container" }, + nextElement: { id: "scene-root", type: "container", children: [] }, + animation: { + id: "scene-mask-replace", + targetId: "scene-root", + type: "transition", + mask: { + kind: "single", + texture: maskTexture, + channel: "red", + progress: { + initialValue: 0, + keyframes: [{ duration: 1000, value: 1, easing: "linear" }], + }, + }, + }, + animations: new Map(), + animationBus, + completionTracker: tracker, + eventHandler: vi.fn(), + elementPlugins: [], + plugin, + zIndex: 0, + signal: new AbortController().signal, + }); + + const dispatched = animationBus.dispatch.mock.calls[0][0]; + + expect(() => dispatched.payload.onComplete()).not.toThrow(); + expect(tracker.complete).toHaveBeenCalledWith(11); + + const maskFilterDestroyIndex = destroyOrder.indexOf("maskFilter.destroy"); + + expect(maskFilterDestroyIndex).toBeGreaterThan(-1); + expect(maskFilterDestroyIndex).toBeLessThan( + destroyOrder.indexOf("renderTexture:0"), + ); + expect(maskFilterDestroyIndex).toBeLessThan( + destroyOrder.indexOf("renderTexture:1"), + ); + expect(maskFilterDestroyIndex).toBeLessThan( + destroyOrder.indexOf("renderTexture:2"), + ); + } finally { + renderTextureSpy.mockRestore(); + filterSpy.mockRestore(); + } + }); + + it("does not preprocess sequence masks through CPU extraction", () => { + const prevDisplayObject = createDisplayObject("scene-root"); + const nextDisplayObject = createDisplayObject("scene-root"); + const parent = createParent(prevDisplayObject); + prevDisplayObject.parent = parent; + const leftMask = document.createElement("canvas"); + const rightMask = document.createElement("canvas"); + leftMask.width = 1; + leftMask.height = 1; + rightMask.width = 1; + rightMask.height = 1; + + const plugin = { + add: vi.fn(({ parent: targetParent, element }) => { + nextDisplayObject.label = element.id; + targetParent.addChild(nextDisplayObject); + }), + delete: vi.fn(({ parent: targetParent, element }) => { + const child = targetParent.children.find( + (item) => item.label === element.id, + ); + if (child) { + targetParent.removeChild(child); + } + }), + }; + + const animationBus = { + dispatch: vi.fn(), + }; + + const app = { + renderer: { + width: 1280, + height: 720, + generateTexture: vi.fn(() => Texture.EMPTY), + render: vi.fn(), + extract: { + pixels: vi.fn(() => ({ + pixels: new Uint8ClampedArray(100 * 100 * 4), + })), + }, + }, + }; + + runReplaceAnimation({ + app, + parent, + prevElement: { id: "scene-root", type: "container" }, + nextElement: { id: "scene-root", type: "container", children: [] }, + animation: { + id: "scene-mask-sequence", + targetId: "scene-root", + type: "transition", + mask: { + kind: "sequence", + textures: [leftMask, rightMask], + channel: "alpha", + sample: "linear", + invert: true, + progress: { + initialValue: 0, + keyframes: [{ duration: 1000, value: 1, easing: "linear" }], + }, + }, + }, + animations: new Map(), + animationBus, + completionTracker: { + getVersion: () => 11, + track: vi.fn(), + complete: vi.fn(), + }, + eventHandler: vi.fn(), + elementPlugins: [], + plugin, + zIndex: 0, + signal: new AbortController().signal, + }); + + const dispatched = animationBus.dispatch.mock.calls[0][0]; + + expect(app.renderer.extract.pixels).not.toHaveBeenCalled(); + dispatched.payload.applyFrame(500); + + expect(app.renderer.extract.pixels).not.toHaveBeenCalled(); + }); + + it("still preprocesses composite masks through the fallback path", () => { + const prevDisplayObject = createDisplayObject("scene-root"); + const nextDisplayObject = createDisplayObject("scene-root"); + const parent = createParent(prevDisplayObject); + prevDisplayObject.parent = parent; + const leftMask = document.createElement("canvas"); + const rightMask = document.createElement("canvas"); + leftMask.width = 1; + leftMask.height = 1; + rightMask.width = 1; + rightMask.height = 1; + + const plugin = { + add: vi.fn(({ parent: targetParent, element }) => { + nextDisplayObject.label = element.id; + targetParent.addChild(nextDisplayObject); + }), + delete: vi.fn(({ parent: targetParent, element }) => { + const child = targetParent.children.find( + (item) => item.label === element.id, + ); + if (child) { + targetParent.removeChild(child); + } + }), + }; + + const animationBus = { + dispatch: vi.fn(), + }; + + const app = { + renderer: { + width: 1280, + height: 720, + generateTexture: vi.fn(() => Texture.EMPTY), + render: vi.fn(), + extract: { + pixels: vi.fn(() => ({ + pixels: new Uint8ClampedArray(100 * 100 * 4), + })), + }, + }, + }; + + runReplaceAnimation({ + app, + parent, + prevElement: { id: "scene-root", type: "container" }, + nextElement: { id: "scene-root", type: "container", children: [] }, + animation: { + id: "scene-mask-composite", + targetId: "scene-root", + type: "transition", + mask: { + kind: "composite", + combine: "max", + items: [ + { texture: leftMask, channel: "red" }, + { texture: rightMask, channel: "red" }, + ], + progress: { + initialValue: 0, + keyframes: [{ duration: 1000, value: 1, easing: "linear" }], + }, + }, + }, + animations: new Map(), + animationBus, + completionTracker: { + getVersion: () => 11, + track: vi.fn(), + complete: vi.fn(), + }, + eventHandler: vi.fn(), + elementPlugins: [], + plugin, + zIndex: 0, + signal: new AbortController().signal, + }); + + expect(app.renderer.extract.pixels).toHaveBeenCalledTimes(2); + }); + it("does not flush deferred activation when a transition is cancelled", () => { const prevDisplayObject = createDisplayObject("scene-root"); const nextDisplayObject = createDisplayObject("scene-root"); diff --git a/spec/elements/renderElements.addUpdate.spec.js b/spec/elements/renderElements.addUpdate.spec.js new file mode 100644 index 00000000..420ebefc --- /dev/null +++ b/spec/elements/renderElements.addUpdate.spec.js @@ -0,0 +1,67 @@ +import { Container } from "pixi.js"; +import { describe, expect, it, vi } from "vitest"; + +import { renderElements } from "../../src/plugins/elements/renderElements.js"; + +describe("renderElements add-time update animations", () => { + it("passes update animations to newly added non-container elements", () => { + const parent = new Container(); + const plugin = { + type: "rect", + add: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + renderElements({ + app: { renderer: { width: 1280, height: 720 } }, + parent, + prevComputedTree: [], + nextComputedTree: [ + { + id: "rect-1", + type: "rect", + x: 0, + y: 0, + width: 100, + height: 100, + fill: "#ffffff", + }, + ], + animations: [ + { + id: "rect-enter-update", + targetId: "rect-1", + type: "update", + tween: { + alpha: { + initialValue: 0, + keyframes: [{ duration: 300, value: 1, easing: "linear" }], + }, + }, + }, + ], + animationBus: { dispatch: vi.fn() }, + completionTracker: { + getVersion: () => 3, + track: vi.fn(), + complete: vi.fn(), + }, + eventHandler: vi.fn(), + elementPlugins: [plugin], + signal: new AbortController().signal, + }); + + expect(plugin.add).toHaveBeenCalledTimes(1); + + const { animations } = plugin.add.mock.calls[0][0]; + + expect(animations.get("rect-1")).toEqual([ + expect.objectContaining({ + id: "rect-enter-update", + targetId: "rect-1", + type: "update", + }), + ]); + }); +}); diff --git a/spec/elements/textHoverLayout.spec.js b/spec/elements/textHoverLayout.spec.js index 8fa0da10..76ccc0bc 100644 --- a/spec/elements/textHoverLayout.spec.js +++ b/spec/elements/textHoverLayout.spec.js @@ -146,6 +146,42 @@ describe("text hover layout", () => { expect(text.style.fontSize).toBe(24); }); + it("maps strokeColor and strokeWidth to Pixi stroke options", () => { + const parent = new Container(); + const shared = createSharedParams(); + const element = parseText({ + state: { + id: "text-stroke-style", + type: "text", + x: 20, + y: 30, + alpha: 1, + content: "Outlined", + textStyle: { + fontSize: 24, + fontFamily: "Arial", + fill: "#FFFFFF", + strokeColor: "#112233", + strokeWidth: 4, + }, + }, + }); + + addText({ + ...shared, + parent, + zIndex: 0, + element, + }); + + const text = parent.getChildByLabel("text-stroke-style"); + + expect(text.style.stroke).toMatchObject({ + color: "#112233", + width: 4, + }); + }); + it("positions centered fixed-width text inside the layout box", () => { const parent = new Container(); const shared = createSharedParams(); diff --git a/spec/parser/parseText.test.yaml b/spec/parser/parseText.test.yaml index 182e8582..ce10eb28 100644 --- a/spec/parser/parseText.test.yaml +++ b/spec/parser/parseText.test.yaml @@ -246,7 +246,7 @@ in: out: id: full-style-text type: text - width: 132 + width: 133 height: 27 x: 50 'y': 100 @@ -254,7 +254,7 @@ out: originY: 0 alpha: 1 content: Fully styled text - measuredWidth: 132 + measuredWidth: 133 textStyle: fill: '#FF5733' fontFamily: Times New Roman diff --git a/src/plugins/animations/replace/runReplaceAnimation.js b/src/plugins/animations/replace/runReplaceAnimation.js index 70cda7cc..e67f9555 100644 --- a/src/plugins/animations/replace/runReplaceAnimation.js +++ b/src/plugins/animations/replace/runReplaceAnimation.js @@ -1,4 +1,13 @@ -import { Container, Matrix, RenderTexture, Sprite, Texture } from "pixi.js"; +import { + Container, + Filter, + Matrix, + Rectangle, + RenderTexture, + Sprite, + Texture, + UniformGroup, +} from "pixi.js"; import { buildTimeline, calculateMaxDuration, @@ -146,6 +155,264 @@ const createMaskProgressTimeline = (mask) => ...(mask?.progress?.keyframes ?? []), ]); +const REPLACE_MASK_FILTER_VERTEX = ` +in vec2 aPosition; +out vec2 vTextureCoord; + +uniform vec4 uInputSize; +uniform vec4 uOutputFrame; +uniform vec4 uOutputTexture; + +vec4 filterVertexPosition(void) +{ + vec2 position = aPosition * uOutputFrame.zw + uOutputFrame.xy; + + position.x = position.x * (2.0 / uOutputTexture.x) - 1.0; + position.y = position.y * (2.0 * uOutputTexture.z / uOutputTexture.y) - uOutputTexture.z; + + return vec4(position, 0.0, 1.0); +} + +vec2 filterTextureCoord(void) +{ + return aPosition * (uOutputFrame.zw * uInputSize.zw); +} + +void main(void) +{ + gl_Position = filterVertexPosition(); + vTextureCoord = filterTextureCoord(); +} +`; + +const REPLACE_MASK_FILTER_FRAGMENT = ` +in vec2 vTextureCoord; +out vec4 finalColor; + +uniform sampler2D uTexture; +uniform sampler2D uNextTexture; +uniform sampler2D uMaskTextureA; +uniform sampler2D uMaskTextureB; +uniform float uProgress; +uniform float uSoftness; +uniform float uMaskMix; +uniform float uMaskInvert; +uniform vec4 uMaskChannelWeights; + +float sampleMaskValue(vec2 uv) +{ + vec4 rawMaskA = texture(uMaskTextureA, uv); + vec4 rawMaskB = texture(uMaskTextureB, uv); + float maskA = dot(rawMaskA, uMaskChannelWeights); + float maskB = dot(rawMaskB, uMaskChannelWeights); + float maskValue = mix(maskA, maskB, clamp(uMaskMix, 0.0, 1.0)); + + return mix(maskValue, 1.0 - maskValue, clamp(uMaskInvert, 0.0, 1.0)); +} + +float sampleReveal(float maskValue) +{ + float progress = clamp(uProgress, 0.0, 1.0); + float lowerEdge = clamp(maskValue - uSoftness, 0.0, 1.0); + float upperEdge = clamp(maskValue + uSoftness, 0.0, 1.0); + + if (lowerEdge == upperEdge) { + return progress < lowerEdge ? 0.0 : 1.0; + } + + float t = clamp((progress - lowerEdge) / (upperEdge - lowerEdge), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +void main() +{ + vec2 uv = clamp(vTextureCoord, vec2(0.0), vec2(1.0)); + vec4 prevColor = texture(uTexture, uv); + vec4 nextColor = texture(uNextTexture, uv); + float reveal = sampleReveal(sampleMaskValue(uv)); + + finalColor = mix(prevColor, nextColor, reveal); +} +`; + +const REPLACE_MASK_FILTER_WGSL = ` +struct GlobalFilterUniforms { + uInputSize: vec4, + uInputPixel: vec4, + uInputClamp: vec4, + uOutputFrame: vec4, + uGlobalFrame: vec4, + uOutputTexture: vec4, +}; + +struct ReplaceMaskUniforms { + uProgress: f32, + uSoftness: f32, + uMaskMix: f32, + uMaskInvert: f32, + uMaskChannelWeights: vec4, +}; + +@group(0) @binding(0) var gfu: GlobalFilterUniforms; +@group(0) @binding(1) var uTexture: texture_2d; +@group(0) @binding(2) var uSampler: sampler; +@group(1) @binding(0) var replaceMaskUniforms: ReplaceMaskUniforms; +@group(1) @binding(1) var uNextTexture: texture_2d; +@group(1) @binding(2) var uMaskTextureA: texture_2d; +@group(1) @binding(3) var uMaskTextureB: texture_2d; + +struct VSOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + +fn filterVertexPosition(aPosition: vec2) -> vec4 +{ + var position = aPosition * gfu.uOutputFrame.zw + gfu.uOutputFrame.xy; + + position.x = position.x * (2.0 / gfu.uOutputTexture.x) - 1.0; + position.y = position.y * (2.0 * gfu.uOutputTexture.z / gfu.uOutputTexture.y) - gfu.uOutputTexture.z; + + return vec4(position, 0.0, 1.0); +} + +fn filterTextureCoord(aPosition: vec2) -> vec2 +{ + return aPosition * (gfu.uOutputFrame.zw * gfu.uInputSize.zw); +} + +fn sampleMaskValue(uv: vec2) -> f32 +{ + let rawMaskA = textureSample(uMaskTextureA, uSampler, uv); + let rawMaskB = textureSample(uMaskTextureB, uSampler, uv); + let maskA = dot(rawMaskA, replaceMaskUniforms.uMaskChannelWeights); + let maskB = dot(rawMaskB, replaceMaskUniforms.uMaskChannelWeights); + let maskValue = mix(maskA, maskB, clamp(replaceMaskUniforms.uMaskMix, 0.0, 1.0)); + + return mix(maskValue, 1.0 - maskValue, clamp(replaceMaskUniforms.uMaskInvert, 0.0, 1.0)); +} + +fn sampleReveal(maskValue: f32) -> f32 +{ + let progress = clamp(replaceMaskUniforms.uProgress, 0.0, 1.0); + let lowerEdge = clamp(maskValue - replaceMaskUniforms.uSoftness, 0.0, 1.0); + let upperEdge = clamp(maskValue + replaceMaskUniforms.uSoftness, 0.0, 1.0); + + if (lowerEdge == upperEdge) { + if (progress < lowerEdge) { + return 0.0; + } + + return 1.0; + } + + let t = clamp((progress - lowerEdge) / (upperEdge - lowerEdge), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +@vertex +fn mainVertex(@location(0) aPosition: vec2) -> VSOutput +{ + return VSOutput( + filterVertexPosition(aPosition), + filterTextureCoord(aPosition), + ); +} + +@fragment +fn mainFragment(@location(0) uv: vec2) -> @location(0) vec4 +{ + let clampedUv = clamp(uv, vec2(0.0), vec2(1.0)); + let prevColor = textureSample(uTexture, uSampler, clampedUv); + let nextColor = textureSample(uNextTexture, uSampler, clampedUv); + let reveal = sampleReveal(sampleMaskValue(clampedUv)); + + return mix(prevColor, nextColor, reveal); +} +`; + +const MASK_CHANNEL_FILTER_FRAGMENT = ` +in vec2 vTextureCoord; +out vec4 finalColor; + +uniform sampler2D uTexture; +uniform float uMaskInvert; +uniform vec4 uMaskChannelWeights; + +void main() +{ + vec4 rawMask = texture(uTexture, clamp(vTextureCoord, vec2(0.0), vec2(1.0))); + float maskValue = dot(rawMask, uMaskChannelWeights); + float outputValue = mix(maskValue, 1.0 - maskValue, clamp(uMaskInvert, 0.0, 1.0)); + + finalColor = vec4(outputValue, outputValue, outputValue, 1.0); +} +`; + +const MASK_CHANNEL_FILTER_WGSL = ` +struct GlobalFilterUniforms { + uInputSize: vec4, + uInputPixel: vec4, + uInputClamp: vec4, + uOutputFrame: vec4, + uGlobalFrame: vec4, + uOutputTexture: vec4, +}; + +struct MaskChannelUniforms { + uMaskInvert: f32, + uMaskChannelWeights: vec4, +}; + +@group(0) @binding(0) var gfu: GlobalFilterUniforms; +@group(0) @binding(1) var uTexture: texture_2d; +@group(0) @binding(2) var uSampler: sampler; +@group(1) @binding(0) var maskChannelUniforms: MaskChannelUniforms; + +struct VSOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + +fn filterVertexPosition(aPosition: vec2) -> vec4 +{ + var position = aPosition * gfu.uOutputFrame.zw + gfu.uOutputFrame.xy; + + position.x = position.x * (2.0 / gfu.uOutputTexture.x) - 1.0; + position.y = position.y * (2.0 * gfu.uOutputTexture.z / gfu.uOutputTexture.y) - gfu.uOutputTexture.z; + + return vec4(position, 0.0, 1.0); +} + +fn filterTextureCoord(aPosition: vec2) -> vec2 +{ + return aPosition * (gfu.uOutputFrame.zw * gfu.uInputSize.zw); +} + +@vertex +fn mainVertex(@location(0) aPosition: vec2) -> VSOutput +{ + return VSOutput( + filterVertexPosition(aPosition), + filterTextureCoord(aPosition), + ); +} + +@fragment +fn mainFragment(@location(0) uv: vec2) -> @location(0) vec4 +{ + let rawMask = textureSample(uTexture, uSampler, clamp(uv, vec2(0.0), vec2(1.0))); + let maskValue = dot(rawMask, maskChannelUniforms.uMaskChannelWeights); + let outputValue = mix( + maskValue, + 1.0 - maskValue, + clamp(maskChannelUniforms.uMaskInvert, 0.0, 1.0), + ); + + return vec4(outputValue, outputValue, outputValue, 1.0); +} +`; + const createCanvasContext = (width, height) => { const canvas = document.createElement("canvas"); canvas.width = width; @@ -261,104 +528,329 @@ const buildCompositeMaskPixels = (app, mask, width, height) => { return combined ?? new Uint8ClampedArray(width * height); }; -const createMaskSampler = (app, mask, width, height) => { - const progressTimeline = createMaskProgressTimeline(mask); - const duration = calculateMaxDuration([{ timeline: progressTimeline }]); - const softness = Math.max(mask?.softness ?? 0.001, 0.0001); +const createMaskChannelWeights = (channel = "red") => { + switch (channel) { + case "green": + return new Float32Array([0, 1, 0, 0]); + case "blue": + return new Float32Array([0, 0, 1, 0]); + case "alpha": + return new Float32Array([0, 0, 0, 1]); + default: + return new Float32Array([1, 0, 0, 0]); + } +}; + +const OUTPUT_MASK_CHANNEL_WEIGHTS = createMaskChannelWeights("red"); + +const createMaskChannelFilter = (channelWeights, invert) => { + const maskChannelUniforms = new UniformGroup({ + uMaskInvert: { + value: invert ? 1 : 0, + type: "f32", + }, + uMaskChannelWeights: { + value: channelWeights, + type: "vec4", + }, + }); + + const filter = Filter.from({ + gpu: { + vertex: { + source: MASK_CHANNEL_FILTER_WGSL, + entryPoint: "mainVertex", + }, + fragment: { + source: MASK_CHANNEL_FILTER_WGSL, + entryPoint: "mainFragment", + }, + }, + gl: { + vertex: REPLACE_MASK_FILTER_VERTEX, + fragment: MASK_CHANNEL_FILTER_FRAGMENT, + name: "replace-mask-channel-filter", + }, + resources: { + maskChannelUniforms, + }, + }); + + return { + filter, + maskChannelUniforms, + }; +}; + +const renderMaskTextureToRenderTexture = ({ + app, + texture, + width, + height, + channelWeights, + invert = false, +}) => { + const sourceTexture = Texture.from(texture); + const maskSprite = new Sprite(sourceTexture); + maskSprite.width = width; + maskSprite.height = height; + maskSprite.filterArea = new Rectangle(0, 0, width, height); + + const maskContainer = new Container(); + maskContainer.addChild(maskSprite); + + const maskRenderTexture = RenderTexture.create({ + width, + height, + }); + const { filter: maskChannelFilter } = createMaskChannelFilter( + channelWeights, + invert, + ); + + maskSprite.filters = [maskChannelFilter]; + app.renderer.render({ + container: maskContainer, + target: maskRenderTexture, + clear: true, + }); + maskSprite.filters = []; + maskContainer.destroy({ children: true }); + maskChannelFilter.destroy(); + + return maskRenderTexture; +}; + +const createMaskTextureFromPixels = (width, height, pixels) => { + const { canvas, context } = createCanvasContext(width, height); + const imageData = context.createImageData(width, height); + const output = imageData.data; + + for (let index = 0, offset = 0; index < pixels.length; index++, offset += 4) { + const value = pixels[index]; + output[offset] = value; + output[offset + 1] = value; + output[offset + 2] = value; + output[offset + 3] = 255; + } + + context.putImageData(imageData, 0, 0); + return Texture.from(canvas); +}; + +const createMaskTextures = (app, mask, width, height) => { if (!mask) { return { - duration, - progressTimeline, - sample: () => 0, + textures: [Texture.WHITE.source], + channelWeights: createMaskChannelWeights("red"), + invert: 0, destroy: () => {}, }; } if (mask.kind === "single") { - const pixels = extractMaskPixelsFromTexture({ + const renderTexture = renderMaskTextureToRenderTexture({ app, texture: mask.texture, width, height, - channel: mask.channel ?? "red", + channelWeights: createMaskChannelWeights(mask.channel ?? "red"), invert: mask.invert ?? false, }); return { - duration, - progressTimeline, - sample: (progress, index) => - sampleMaskReveal({ - progress, - maskValue: pixels[index] / 255, - softness, - }), - destroy: () => {}, + textures: [renderTexture.source], + channelWeights: OUTPUT_MASK_CHANNEL_WEIGHTS, + invert: 0, + destroy: () => { + renderTexture.destroy(true); + }, }; } if (mask.kind === "sequence") { - const frames = mask.textures.map((texture) => - extractMaskPixelsFromTexture({ + const textures = mask.textures.map((texture) => + renderMaskTextureToRenderTexture({ app, texture, width, height, - channel: mask.channel ?? "red", + channelWeights: createMaskChannelWeights(mask.channel ?? "red"), invert: mask.invert ?? false, }), ); return { - duration, - progressTimeline, - sample: (progress, index) => { - const scaled = clamp01(progress) * Math.max(0, frames.length - 1); - - if (mask.sample === "linear" && frames.length > 1) { - const lowerIndex = Math.floor(scaled); - const upperIndex = Math.min(frames.length - 1, lowerIndex + 1); - const ratio = scaled - lowerIndex; - const maskValue = - (frames[lowerIndex][index] * (1 - ratio) + - frames[upperIndex][index] * ratio) / - 255; - - return sampleMaskReveal({ - progress, - maskValue, - softness, - }); + textures: textures.map((texture) => texture.source), + channelWeights: OUTPUT_MASK_CHANNEL_WEIGHTS, + invert: 0, + destroy: () => { + for (const texture of textures) { + texture.destroy(true); } + }, + }; + } - const frameIndex = Math.min( - frames.length - 1, - Math.max(0, Math.round(scaled)), - ); + const texture = createMaskTextureFromPixels( + width, + height, + buildCompositeMaskPixels(app, mask, width, height), + ); - return sampleMaskReveal({ - progress, - maskValue: frames[frameIndex][index] / 255, - softness, - }); + return { + textures: [texture.source], + channelWeights: OUTPUT_MASK_CHANNEL_WEIGHTS, + invert: 0, + destroy: () => { + if (!texture.destroyed) { + texture.destroy(true); + } + }, + }; +}; + +const createReplaceMaskFilter = () => { + const replaceMaskUniforms = new UniformGroup({ + uProgress: { + value: 0, + type: "f32", + }, + uSoftness: { + value: 0.001, + type: "f32", + }, + uMaskMix: { + value: 0, + type: "f32", + }, + uMaskInvert: { + value: 0, + type: "f32", + }, + uMaskChannelWeights: { + value: new Float32Array([1, 0, 0, 0]), + type: "vec4", + }, + }); + + const filter = Filter.from({ + gpu: { + vertex: { + source: REPLACE_MASK_FILTER_WGSL, + entryPoint: "mainVertex", }, - destroy: () => {}, + fragment: { + source: REPLACE_MASK_FILTER_WGSL, + entryPoint: "mainFragment", + }, + }, + gl: { + vertex: REPLACE_MASK_FILTER_VERTEX, + fragment: REPLACE_MASK_FILTER_FRAGMENT, + name: "replace-mask-filter", + }, + resources: { + replaceMaskUniforms, + uNextTexture: Texture.EMPTY.source, + uMaskTextureA: Texture.EMPTY.source, + uMaskTextureB: Texture.EMPTY.source, + }, + }); + + return { + filter, + replaceMaskUniforms, + }; +}; + +export const selectSequenceMaskFrameState = ({ + progress = 0, + frameCount = 0, + sampleMode = "hold", +} = {}) => { + if (frameCount <= 1) { + return { + fromIndex: 0, + toIndex: 0, + mix: 0, }; } - const pixels = buildCompositeMaskPixels(app, mask, width, height); + const scaled = clamp01(progress) * Math.max(0, frameCount - 1); + + if (sampleMode === "linear") { + const fromIndex = Math.floor(scaled); + const toIndex = Math.min(frameCount - 1, fromIndex + 1); + + return { + fromIndex, + toIndex, + mix: scaled - fromIndex, + }; + } + + const fromIndex = Math.min(frameCount - 1, Math.max(0, Math.round(scaled))); + + return { + fromIndex, + toIndex: fromIndex, + mix: 0, + }; +}; + +const createMaskTextureController = (app, mask, width, height, filter) => { + const progressTimeline = createMaskProgressTimeline(mask); + const duration = calculateMaxDuration([{ timeline: progressTimeline }]); + const softness = Math.max(mask?.softness ?? 0.001, 0.0001); + const { textures, channelWeights, invert, destroy } = createMaskTextures( + app, + mask, + width, + height, + ); + const replaceMaskUniforms = filter.resources.replaceMaskUniforms; + let lastFromIndex = -1; + let lastToIndex = -1; return { duration, progressTimeline, - sample: (progress, index) => - sampleMaskReveal({ - progress, - maskValue: pixels[index] / 255, - softness, - }), - destroy: () => {}, + apply: (progress) => { + const selection = + mask?.kind === "sequence" + ? selectSequenceMaskFrameState({ + progress, + frameCount: textures.length, + sampleMode: mask.sample ?? "hold", + }) + : { + fromIndex: 0, + toIndex: 0, + mix: 0, + }; + + if (selection.fromIndex !== lastFromIndex) { + filter.resources.uMaskTextureA = + textures[selection.fromIndex] ?? Texture.EMPTY.source; + lastFromIndex = selection.fromIndex; + } + + if (selection.toIndex !== lastToIndex) { + filter.resources.uMaskTextureB = + textures[selection.toIndex] ?? Texture.EMPTY.source; + lastToIndex = selection.toIndex; + } + + replaceMaskUniforms.uniforms.uProgress = clamp01(progress); + replaceMaskUniforms.uniforms.uSoftness = softness; + replaceMaskUniforms.uniforms.uMaskMix = selection.mix; + replaceMaskUniforms.uniforms.uMaskInvert = invert; + replaceMaskUniforms.uniforms.uMaskChannelWeights = channelWeights; + replaceMaskUniforms.update(); + }, + destroy, }; }; @@ -384,9 +876,52 @@ const renderOffscreenContainer = ({ app, container, target, frame }) => { }; const destroySubjectSnapshot = (subject) => { + if (subject?.wrapper && !subject.wrapper.destroyed) { + subject.wrapper.destroy({ children: true }); + } + subject?.texture?.destroy(true); }; +const resolveOverlaySubjects = ({ + prevElement, + nextElement, + animation, + prevSubject, + nextSubject, +}) => { + if (!prevSubject || !nextSubject || prevElement?.id !== nextElement?.id) { + return { prevSubject, nextSubject }; + } + + let overlayPrevSubject = prevSubject; + let overlayNextSubject = nextSubject; + + if ( + animation.mask !== undefined && + animation.prev === undefined && + animation.next === undefined + ) { + return { + prevSubject: overlayPrevSubject, + nextSubject: overlayNextSubject, + }; + } else { + if (animation.prev === undefined) { + overlayPrevSubject = null; + } + + if (animation.next === undefined) { + overlayNextSubject = null; + } + } + + return { + prevSubject: overlayPrevSubject, + nextSubject: overlayNextSubject, + }; +}; + const createPlainOverlay = ({ app, animation, @@ -460,29 +995,30 @@ const createMaskedOverlay = ({ height: unionBounds.height, }); - const { canvas: outputCanvas, context: outputContext } = createCanvasContext( - unionBounds.width, - unionBounds.height, - ); - const outputImageData = outputContext.createImageData( - unionBounds.width, - unionBounds.height, - ); - const outputTexture = Texture.from(outputCanvas); - const overlay = new Container(); overlay.zIndex = zIndex; - const sprite = new Sprite(outputTexture); + const sprite = new Sprite(prevTexture); sprite.x = unionBounds.x; sprite.y = unionBounds.y; + sprite.filterArea = new Rectangle( + 0, + 0, + unionBounds.width, + unionBounds.height, + ); overlay.addChild(sprite); - const maskSampler = createMaskSampler( + const { filter: maskFilter } = createReplaceMaskFilter(); + maskFilter.resources.uNextTexture = nextTexture.source; + sprite.filters = [maskFilter]; + + const maskTextureController = createMaskTextureController( app, animation.mask, unionBounds.width, unionBounds.height, + maskFilter, ); const prevController = createSubjectController( prevSubject?.wrapper ?? null, @@ -494,85 +1030,81 @@ const createMaskedOverlay = ({ animation.next?.tween, app, ); - const transparentPixels = new Uint8ClampedArray( - unionBounds.width * unionBounds.height * 4, - ); + let prevStaticRendered = false; + let nextStaticRendered = false; + + if (!prevSubject?.wrapper) { + renderOffscreenContainer({ + app, + container: prevRoot, + target: prevTexture, + frame: unionBounds, + }); + } + + if (!nextSubject?.wrapper) { + renderOffscreenContainer({ + app, + container: nextRoot, + target: nextTexture, + frame: unionBounds, + }); + } return { overlay, duration: Math.max( prevController.duration, nextController.duration, - maskSampler.duration, + maskTextureController.duration, ), apply: (time) => { prevController.apply(time); nextController.apply(time); - let prevPixels = transparentPixels; - let nextPixels = transparentPixels; - - if (prevSubject?.wrapper) { + if ( + prevSubject?.wrapper && + (prevController.duration > 0 || !prevStaticRendered) + ) { renderOffscreenContainer({ app, container: prevRoot, target: prevTexture, frame: unionBounds, }); - prevPixels = app.renderer.extract.pixels(prevTexture).pixels; + prevStaticRendered = true; } - if (nextSubject?.wrapper) { + if ( + nextSubject?.wrapper && + (nextController.duration > 0 || !nextStaticRendered) + ) { renderOffscreenContainer({ app, container: nextRoot, target: nextTexture, frame: unionBounds, }); - nextPixels = app.renderer.extract.pixels(nextTexture).pixels; + nextStaticRendered = true; } const progress = clamp01( - getValueAtTime(maskSampler.progressTimeline, time), + getValueAtTime(maskTextureController.progressTimeline, time), ); - const outputPixels = outputImageData.data; - - for ( - let offset = 0, pixelIndex = 0; - offset < outputPixels.length; - offset += 4, pixelIndex += 1 - ) { - const reveal = maskSampler.sample(progress, pixelIndex); - const keep = 1 - reveal; - - outputPixels[offset] = Math.round( - prevPixels[offset] * keep + nextPixels[offset] * reveal, - ); - outputPixels[offset + 1] = Math.round( - prevPixels[offset + 1] * keep + nextPixels[offset + 1] * reveal, - ); - outputPixels[offset + 2] = Math.round( - prevPixels[offset + 2] * keep + nextPixels[offset + 2] * reveal, - ); - outputPixels[offset + 3] = Math.round( - prevPixels[offset + 3] * keep + nextPixels[offset + 3] * reveal, - ); - } - - outputContext.putImageData(outputImageData, 0, 0); - outputTexture.source.update(); + maskTextureController.apply(progress); }, destroy: () => { overlay.removeFromParent(); + sprite.filters = []; + maskFilter.destroy(); overlay.destroy({ children: true }); prevRoot.destroy({ children: true }); nextRoot.destroy({ children: true }); prevTexture.destroy(true); nextTexture.destroy(true); - outputTexture.destroy(true); destroySubjectSnapshot(prevSubject); destroySubjectSnapshot(nextSubject); - maskSampler.destroy(); + maskTextureController.destroy(); }, }; }; @@ -776,6 +1308,21 @@ export const runReplaceAnimation = ({ const nextSubject = nextDisplayObject ? createSnapshotSubject(app, nextDisplayObject) : null; + const overlaySubjects = resolveOverlaySubjects({ + prevElement, + nextElement, + animation, + prevSubject, + nextSubject, + }); + + if (overlaySubjects.prevSubject !== prevSubject) { + destroySubjectSnapshot(prevSubject); + } + + if (overlaySubjects.nextSubject !== nextSubject) { + destroySubjectSnapshot(nextSubject); + } transitionMountParent.destroy({ children: false }); @@ -803,8 +1350,8 @@ export const runReplaceAnimation = ({ const replaceOverlay = createReplaceOverlay({ app, animation, - prevSubject, - nextSubject, + prevSubject: overlaySubjects.prevSubject, + nextSubject: overlaySubjects.nextSubject, zIndex, }); replaceOverlayRef.value = replaceOverlay; @@ -821,8 +1368,11 @@ export const runReplaceAnimation = ({ finalize({ flushDeferredEffects: false }); }, onComplete: () => { - finalize({ flushDeferredEffects: true }); - completeTransition(); + try { + finalize({ flushDeferredEffects: true }); + } finally { + completeTransition(); + } }, onCancel: () => { completeTransition(); diff --git a/src/plugins/elements/input/inputShared.js b/src/plugins/elements/input/inputShared.js index b2fb37f1..db5a9161 100644 --- a/src/plugins/elements/input/inputShared.js +++ b/src/plugins/elements/input/inputShared.js @@ -7,6 +7,7 @@ import { } from "pixi.js"; import applyTextStyle from "../../../util/applyTextStyle.js"; import { DEFAULT_TEXT_STYLE } from "../../../types.js"; +import { toPixiTextStyle } from "../../../util/toPixiTextStyle.js"; export const INPUT_RUNTIME = Symbol("routeGraphicsInputRuntime"); @@ -113,7 +114,7 @@ const getHorizontalOffset = (layoutWidth, measuredWidth, align) => { return 0; }; -const createMeasuredStyle = (style) => new TextStyle(style); +const createMeasuredStyle = (style) => new TextStyle(toPixiTextStyle(style)); const measureWidth = (text, style) => CanvasTextMetrics.measureText(text, createMeasuredStyle(style)).width; diff --git a/src/plugins/elements/renderElements.js b/src/plugins/elements/renderElements.js index 4f8c39f0..f67f0456 100644 --- a/src/plugins/elements/renderElements.js +++ b/src/plugins/elements/renderElements.js @@ -194,10 +194,7 @@ export const renderElements = ({ app, parent, element, - animations: - renderContext.suppressAnimations || element.type === "container" - ? animationsByTarget - : [], + animations: animationsByTarget, eventHandler, animationBus, completionTracker, diff --git a/src/plugins/elements/text-revealing/parseTextRevealing.js b/src/plugins/elements/text-revealing/parseTextRevealing.js index c867e68c..9d90b523 100644 --- a/src/plugins/elements/text-revealing/parseTextRevealing.js +++ b/src/plugins/elements/text-revealing/parseTextRevealing.js @@ -1,6 +1,7 @@ import { CanvasTextMetrics, TextStyle } from "pixi.js"; import { parseCommonObject } from "../util/parseCommonObject.js"; import { DEFAULT_TEXT_STYLE } from "../../../types.js"; +import { toPixiTextStyle } from "../../../util/toPixiTextStyle.js"; /** * @typedef {import('../../../types.js').BaseElement} BaseElement @@ -155,7 +156,7 @@ const createTextChunks = (segments, wordWrapWidth) => { const measurements = CanvasTextMetrics.measureText( segment.text, - new TextStyle(styleWithWordWrap), + new TextStyle(toPixiTextStyle(styleWithWordWrap)), ); // Check if text fits on current line @@ -217,7 +218,7 @@ const createTextChunks = (segments, wordWrapWidth) => { const measurementsWithNoWrapping = CanvasTextMetrics.measureText( textPart, new TextStyle({ - ...segment.textStyle, + ...toPixiTextStyle(segment.textStyle), wordWrap: false, breakWords: false, }), @@ -244,7 +245,7 @@ const createTextChunks = (segments, wordWrapWidth) => { const furiganaMeasurements = CanvasTextMetrics.measureText( segment.furigana.text, - new TextStyle(segment.furigana.textStyle), + new TextStyle(toPixiTextStyle(segment.furigana.textStyle)), ); // Calculate furigana position relative to current line's max height diff --git a/src/plugins/elements/text-revealing/textRevealingRuntime.js b/src/plugins/elements/text-revealing/textRevealingRuntime.js index b7b95e77..10154405 100644 --- a/src/plugins/elements/text-revealing/textRevealingRuntime.js +++ b/src/plugins/elements/text-revealing/textRevealingRuntime.js @@ -8,6 +8,7 @@ import { } from "pixi.js"; import { getCharacterXPositionInATextObject } from "../../../util/getCharacterXPositionInATextObject"; import abortableSleep from "../../../util/abortableSleep"; +import { toPixiTextStyle } from "../../../util/toPixiTextStyle.js"; const TEXT_REVEAL_RUNTIME = Symbol("textRevealRuntime"); const TEXT_REVEAL_SNAPSHOT = Symbol("textRevealSnapshot"); @@ -137,7 +138,7 @@ export const clearTextRevealingContainer = (container) => { }; const createPartObjects = (part, textValue = "", furiganaValue = "") => { - const textStyle = new TextStyle(part.textStyle); + const textStyle = new TextStyle(toPixiTextStyle(part.textStyle)); const text = new Text({ text: textValue, style: textStyle, @@ -148,7 +149,9 @@ const createPartObjects = (part, textValue = "", furiganaValue = "") => { let furiganaText = null; if (part.furigana) { - const furiganaTextStyle = new TextStyle(part.furigana.textStyle); + const furiganaTextStyle = new TextStyle( + toPixiTextStyle(part.furigana.textStyle), + ); furiganaText = new Text({ text: furiganaValue, diff --git a/src/plugins/elements/text/parseText.js b/src/plugins/elements/text/parseText.js index 673c5af0..c582b14e 100644 --- a/src/plugins/elements/text/parseText.js +++ b/src/plugins/elements/text/parseText.js @@ -1,6 +1,7 @@ import { CanvasTextMetrics, TextStyle } from "pixi.js"; import { parseCommonObject } from "../util/parseCommonObject.js"; import { DEFAULT_TEXT_STYLE } from "../../../types.js"; +import { toPixiTextStyle } from "../../../util/toPixiTextStyle.js"; /** * @typedef {import('../../../types.js').BaseElement} BaseElement @@ -33,7 +34,7 @@ export const parseText = ({ state }) => { const { width, height } = CanvasTextMetrics.measureText( contentString, - new TextStyle(textStyle), + new TextStyle(toPixiTextStyle(textStyle)), ); // Round pixel calculations diff --git a/src/util/applyTextStyle.js b/src/util/applyTextStyle.js index 7734a464..950e3732 100644 --- a/src/util/applyTextStyle.js +++ b/src/util/applyTextStyle.js @@ -1,4 +1,5 @@ import { DEFAULT_TEXT_STYLE } from "../types.js"; +import { toPixiTextStyle } from "./toPixiTextStyle.js"; export default (element, style) => { const appliedStyle = { @@ -14,5 +15,5 @@ export default (element, style) => { wordWrapWidth: style?.wordWrapWidth ?? DEFAULT_TEXT_STYLE.wordWrapWidth, }; - element.style = appliedStyle; + element.style = toPixiTextStyle(appliedStyle); }; diff --git a/src/util/toPixiTextStyle.js b/src/util/toPixiTextStyle.js new file mode 100644 index 00000000..eab42792 --- /dev/null +++ b/src/util/toPixiTextStyle.js @@ -0,0 +1,25 @@ +import { DEFAULT_TEXT_STYLE } from "../types.js"; + +const getStrokeStyle = (style = {}) => { + const baseStroke = + typeof style.stroke === "object" && style.stroke !== null + ? style.stroke + : {}; + + return { + ...baseStroke, + color: + style.strokeColor ?? baseStroke.color ?? DEFAULT_TEXT_STYLE.strokeColor, + width: + style.strokeWidth ?? baseStroke.width ?? DEFAULT_TEXT_STYLE.strokeWidth, + }; +}; + +export const toPixiTextStyle = (style = {}) => { + const { strokeColor, strokeWidth, stroke, ...rest } = style; + + return { + ...rest, + stroke: getStrokeStyle({ strokeColor, strokeWidth, stroke }), + }; +}; diff --git a/vt/reference/rendercompleteevent/render-complete-replace-mask-02.webp b/vt/reference/rendercompleteevent/render-complete-replace-mask-02.webp index acd11e59..0f44cb5d 100644 --- a/vt/reference/rendercompleteevent/render-complete-replace-mask-02.webp +++ b/vt/reference/rendercompleteevent/render-complete-replace-mask-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57e040ae87d4778d2bb3b787a0bce3465804147fa5902e952629c5aa3f29c9a3 -size 5272 +oid sha256:acec48014082d2e9c6a6289b4de3dab225cd35372cb8dcb77bcafd878c727001 +size 4998 diff --git a/vt/reference/replacetransition/rect-composite-mask-02.webp b/vt/reference/replacetransition/rect-composite-mask-02.webp index a95ce39b..4f1caf99 100644 --- a/vt/reference/replacetransition/rect-composite-mask-02.webp +++ b/vt/reference/replacetransition/rect-composite-mask-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d52d4940579ecffea22ed07663f4a1ca183ffb83318b2bc1a75ff1a30df101fe -size 1934 +oid sha256:d32382bee2dfdf814d7d54e25667f13092e023624352c56cec4640a875efee6f +size 1912 diff --git a/vt/reference/replacetransition/rect-composite-mask-modes-02.webp b/vt/reference/replacetransition/rect-composite-mask-modes-02.webp index 0a0f465a..1bc6d0c2 100644 --- a/vt/reference/replacetransition/rect-composite-mask-modes-02.webp +++ b/vt/reference/replacetransition/rect-composite-mask-modes-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5fac2603c192655fd9dbd451100a169228fcd763b82e2a2d072f040c7bdd2a71 -size 7638 +oid sha256:35670a01be1d8eebc2c9cdbfb20697e940dc6f67b67f9274cb7c4e2e45800418 +size 7602 diff --git a/vt/reference/replacetransition/rect-composite-mask-modes-03.webp b/vt/reference/replacetransition/rect-composite-mask-modes-03.webp index 858f62b9..108102a3 100644 --- a/vt/reference/replacetransition/rect-composite-mask-modes-03.webp +++ b/vt/reference/replacetransition/rect-composite-mask-modes-03.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:848658a55b43f294a915c1f8113d0d667869eed9e42a83846f553fe2e3fc3503 -size 3622 +oid sha256:4924326ffd9bf41574b519334adef40527f05c840dfaf27f18c8984c26007b73 +size 3776 diff --git a/vt/reference/replacetransition/rect-composite-mask-modes-04.webp b/vt/reference/replacetransition/rect-composite-mask-modes-04.webp index d64403c9..48365580 100644 --- a/vt/reference/replacetransition/rect-composite-mask-modes-04.webp +++ b/vt/reference/replacetransition/rect-composite-mask-modes-04.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e5ad22c4737d59551429548d5c66a4e0fbedbead1ab84ea3b21027f4c4dcd3cb -size 3784 +oid sha256:fab14099e6788bea1d9a60391b6808c5b086bb880b19b2ed0da38826225f2bec +size 3830 diff --git a/vt/reference/replacetransition/rect-mask-channel-showcase-02.webp b/vt/reference/replacetransition/rect-mask-channel-showcase-02.webp index 29542262..976db6fe 100644 --- a/vt/reference/replacetransition/rect-mask-channel-showcase-02.webp +++ b/vt/reference/replacetransition/rect-mask-channel-showcase-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2390a72965791980c73620a3de349206607fd0a5b59ad70b8311db1c1d570b51 -size 3232 +oid sha256:7aa400237c4a6bd71cc219ed836a161123ef52f8216cfc165ab18f772bfbc56f +size 2934 diff --git a/vt/reference/replacetransition/rect-mask-channel-showcase-03.webp b/vt/reference/replacetransition/rect-mask-channel-showcase-03.webp index 7abe7580..9025d4ca 100644 --- a/vt/reference/replacetransition/rect-mask-channel-showcase-03.webp +++ b/vt/reference/replacetransition/rect-mask-channel-showcase-03.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d4eb3b373560bef5b501ab5980e6d7e4510ba74d9faa29de291f8e7a10932cc -size 3260 +oid sha256:8d4992cf73de5ca56dfbce2e822e511c6eabae2cdfb38ad107cbb489fe0f2115 +size 2988 diff --git a/vt/reference/replacetransition/rect-mask-channel-showcase-04.webp b/vt/reference/replacetransition/rect-mask-channel-showcase-04.webp index c51f4731..99498c00 100644 --- a/vt/reference/replacetransition/rect-mask-channel-showcase-04.webp +++ b/vt/reference/replacetransition/rect-mask-channel-showcase-04.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1341418f47331362e8ea872b42014070cb06f24f5fb1187b4a049f0601ddb5f1 -size 3082 +oid sha256:38356cb4ed5a746b1cb796df80f245c372386c965c8955dc18af4832b5e462aa +size 2914 diff --git a/vt/reference/replacetransition/rect-mask-channel-showcase-05.webp b/vt/reference/replacetransition/rect-mask-channel-showcase-05.webp index 9a664ed1..450ef88b 100644 --- a/vt/reference/replacetransition/rect-mask-channel-showcase-05.webp +++ b/vt/reference/replacetransition/rect-mask-channel-showcase-05.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9fed5576efba875c0b27b7b8824a686eae7aaa370f2cb04e26467c8ac89c8a02 -size 5202 +oid sha256:4a60bdb7a5729ccc52dfd272180b4b525f74c6ae0e1ae3a71d65a07be8ab7c14 +size 4436 diff --git a/vt/reference/replacetransition/rect-mask-channel-showcase-06.webp b/vt/reference/replacetransition/rect-mask-channel-showcase-06.webp index 071f2bdc..a2f6b8ec 100644 --- a/vt/reference/replacetransition/rect-mask-channel-showcase-06.webp +++ b/vt/reference/replacetransition/rect-mask-channel-showcase-06.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58680ce4a4107086583eab7276dbfdb69e1138466b172c9df475d7491ab16df2 -size 3244 +oid sha256:4933b83402ba377612a617bd485d6922b33a37d56b88e16a3e93a2b85f2a4d6b +size 2952 diff --git a/vt/reference/replacetransition/rect-mask-clockwipe-02.webp b/vt/reference/replacetransition/rect-mask-clockwipe-02.webp index 3b59931b..02a59ffa 100644 --- a/vt/reference/replacetransition/rect-mask-clockwipe-02.webp +++ b/vt/reference/replacetransition/rect-mask-clockwipe-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ebb29d2343607de02eeff5a205f37b3f8d00f9608a7d5ea47042fbb5cc22c47 -size 2942 +oid sha256:9387bd7527a944a49ee8ac116a803e19fb688a2341856f9e018f0dee9a8701f7 +size 2870 diff --git a/vt/reference/replacetransition/rect-mask-clockwipe-03.webp b/vt/reference/replacetransition/rect-mask-clockwipe-03.webp index 0c0ce5d3..32becdfe 100644 --- a/vt/reference/replacetransition/rect-mask-clockwipe-03.webp +++ b/vt/reference/replacetransition/rect-mask-clockwipe-03.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fc2649193f97db4075c872b0a70f3f625aad2a2bd96df4e77ac9982935cd3dfd -size 3108 +oid sha256:1a2ac854ff50c43bbffec99f32944c912f180a71c967a78ab02ad1f4f464e565 +size 3032 diff --git a/vt/reference/replacetransition/rect-mask-dissolve-02.webp b/vt/reference/replacetransition/rect-mask-dissolve-02.webp index acd11e59..0f44cb5d 100644 --- a/vt/reference/replacetransition/rect-mask-dissolve-02.webp +++ b/vt/reference/replacetransition/rect-mask-dissolve-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57e040ae87d4778d2bb3b787a0bce3465804147fa5902e952629c5aa3f29c9a3 -size 5272 +oid sha256:acec48014082d2e9c6a6289b4de3dab225cd35372cb8dcb77bcafd878c727001 +size 4998 diff --git a/vt/reference/replacetransition/rect-mask-enter-02.webp b/vt/reference/replacetransition/rect-mask-enter-02.webp index e1f60a47..526a79f3 100644 --- a/vt/reference/replacetransition/rect-mask-enter-02.webp +++ b/vt/reference/replacetransition/rect-mask-enter-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6cdf6031ee956d00d85d5fe41f1459193048dc144a9b22757330067fb4f77d3b -size 7050 +oid sha256:70ef328f245d6a44e1e663da63c71be13bf7441a0168b0fcc0cbb663f95c8adb +size 5612 diff --git a/vt/reference/replacetransition/rect-push-mask-dissolve-02.webp b/vt/reference/replacetransition/rect-push-mask-dissolve-02.webp index 9fa035ea..fda48944 100644 --- a/vt/reference/replacetransition/rect-push-mask-dissolve-02.webp +++ b/vt/reference/replacetransition/rect-push-mask-dissolve-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:74da1253b76ea1eba56c0179c088cad1b3357c9ce74a32165228cc1115bd207c -size 5920 +oid sha256:c7d9806582324f15b7834ec363b0500fcd337ce34f2ede0a9bf59be9885e1848 +size 5014 diff --git a/vt/reference/replacetransition/rect-sequence-mask-03.webp b/vt/reference/replacetransition/rect-sequence-mask-03.webp index f1d11a68..6ac46f52 100644 --- a/vt/reference/replacetransition/rect-sequence-mask-03.webp +++ b/vt/reference/replacetransition/rect-sequence-mask-03.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4eddd0e47d85c8e1cd2199762f3422fda6bf58d6c0b4db70e9f6260ea0a805e -size 1930 +oid sha256:a4ff5e1d1978ea6d3a5b893db3ed79be1bac6f24a696b819872b6417d6e038ab +size 1914 diff --git a/vt/reference/replacetransition/rect-sequence-mask-linear-02.webp b/vt/reference/replacetransition/rect-sequence-mask-linear-02.webp index ccdf1451..0e72acec 100644 --- a/vt/reference/replacetransition/rect-sequence-mask-linear-02.webp +++ b/vt/reference/replacetransition/rect-sequence-mask-linear-02.webp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f648967f40794659ed7037e5dba897ecd9c16e197c05db5e3728c101de25f5eb -size 1922 +oid sha256:2d7fabe532d47827e00da8b76b2cafdca7419a946720ff73b3a88d2bcca92722 +size 1924 diff --git a/vt/reference/textbasic/text-stroke-01.webp b/vt/reference/textbasic/text-stroke-01.webp new file mode 100644 index 00000000..906e52d6 --- /dev/null +++ b/vt/reference/textbasic/text-stroke-01.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea28da9aff4ac1bcac867c9bd93370b83463bc19659a698cf5c1e6c654bb6fdf +size 7892 diff --git a/vt/specs/textbasic/text-stroke.yaml b/vt/specs/textbasic/text-stroke.yaml new file mode 100644 index 00000000..8cc58a72 --- /dev/null +++ b/vt/specs/textbasic/text-stroke.yaml @@ -0,0 +1,31 @@ +--- +title: Text Stroke +description: Text stroke rendering is the subject of this spec, so it uses grayscale contrast to verify outline color and thickness. +specs: + - text should render the configured strokeColor as the outline color + - text should render the configured strokeWidth as the outline thickness +--- +states: + - elements: + - id: text-thin-stroke + type: text + x: 40 + "y": 50 + content: Thin Stroke + textStyle: + fontFamily: Arial + fontSize: 52 + fill: "#4D4D4D" + strokeColor: "#A6A6A6" + strokeWidth: 2 + - id: text-thick-stroke + type: text + x: 40 + "y": 150 + content: Thick Stroke + textStyle: + fontFamily: Arial + fontSize: 52 + fill: "#4D4D4D" + strokeColor: "#FFFFFF" + strokeWidth: 8