diff --git a/packages/javascript/cypress.config.ts b/packages/javascript/cypress.config.ts index c8e8d88..aebe532 100644 --- a/packages/javascript/cypress.config.ts +++ b/packages/javascript/cypress.config.ts @@ -1,8 +1,19 @@ import { defineConfig } from 'cypress'; +import z from 'zod'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const sharp = require('sharp'); + +import { rename } from 'node:fs/promises'; export default defineConfig({ defaultBrowser: 'electron', component: { + setupNodeEvents(on) { + on('after:screenshot', flattenScreenshots); + on('task', { + 'get-pixel-value': getPixelValue, + }); + }, devServer: { framework: 'react', bundler: 'vite', @@ -14,8 +25,10 @@ export default defineConfig({ e2e: { setupNodeEvents(on) { - // This is required to log progress to the terminal whilst generating frames + on('after:screenshot', flattenScreenshots); on('task', { + 'get-pixel-value': getPixelValue, + // This is required to log progress to the terminal whilst generating frames log(message) { console.log(message); return null; @@ -24,3 +37,43 @@ export default defineConfig({ }, }, }); + +const getPixelValueArgs = z.object({ + id: z.string(), + x: z.number().gte(0), + y: z.number().gte(0), +}); +async function getPixelValue(args: unknown) { + try { + console.debug('getPixelValue()', args); + const { id, x, y } = getPixelValueArgs.parse(args); + const path = `cypress/screenshots/${id}.png`; + + // Approach derived from: https://github.com/lovell/sharp/issues/934#issuecomment-327181099 + const metadata = await sharp(path).metadata(); + const { channels, width } = metadata; + const pixelOffset = channels * (width * y + x); + + const data = await sharp(path).raw().toBuffer(); + const r = data[pixelOffset + 0]; + const g = data[pixelOffset + 1]; + const b = data[pixelOffset + 2]; + + return { r, g, b }; + } catch (e) { + console.error(e); + return e; + } +} + +// If cypress is in interactive mode screenshots are saved in /screenshots/{name}.png +// If cypress is in run mode, screenshots are saved in /screenshots/{test-name}/{name}.png +const CYPRESS_SCREENSHOTS = 'cypress/screenshots'; +async function flattenScreenshots(details: { path: string; specName: string }) { + if (details.specName.length) { + const newPath = details.path.replace(`${CYPRESS_SCREENSHOTS}/${details.specName}`, CYPRESS_SCREENSHOTS); + await rename(details.path, newPath); + return { path: newPath }; + } + return details; +} diff --git a/packages/javascript/cypress/component/SurfaceLayers.cy.ts b/packages/javascript/cypress/component/SurfaceLayers.cy.ts new file mode 100644 index 0000000..dbc5942 --- /dev/null +++ b/packages/javascript/cypress/component/SurfaceLayers.cy.ts @@ -0,0 +1,61 @@ +import { SurfaceManager } from '../../src/state-based/SurfaceManager'; +const INDIAN_RED = { r: 191, g: 99, b: 96 }; +const ROYAL_BLUE = { r: 75, g: 104, b: 218 }; + +describe('Surface layer tests', () => { + it('can take a known screenshot', () => { + const now = Date.now(); + const manager = new SurfaceManager({ + red: { + file: 'cypress/fixtures/indianred@2560x1440.png', + type: 'image', + fit: 'cover', + keyframes: [[now, { set: { opacity: 1 } }]], + }, + }); + cy.mount(manager); + + // Wait for image to load (naturalWidth is set once loaded) + cy.get('img') + .should('be.visible') + .and(($img) => { + expect($img[0].naturalWidth).to.be.greaterThan(0); + }); + + cy.assertPixelAt(100, 100, INDIAN_RED); + }); + + it('respects z-index', () => { + const now = Date.now(); + const manager = new SurfaceManager({ + red: { + file: 'cypress/fixtures/indianred@2560x1440.png', + type: 'image', + fit: 'cover', + keyframes: [ + [now, { set: { zIndex: 100 } }], + [now + 2000, { set: { zIndex: 300 } }], + ], + }, + blue: { + file: 'cypress/fixtures/royalblue@2560x1440.png', + type: 'image', + fit: 'cover', + keyframes: [[now, { set: { zIndex: 200 } }]], + }, + }); + cy.mount(manager); + + // Wait for images to load (naturalWidth is set once loaded) + cy.get('img') + .should('be.visible') + .and(($img) => { + expect($img[0].naturalWidth).to.be.greaterThan(0); + expect($img[1].naturalWidth).to.be.greaterThan(0); + }); + + cy.assertPixelAt(100, 100, ROYAL_BLUE); + cy.wait(2000); + cy.assertPixelAt(100, 100, INDIAN_RED); + }); +}); diff --git a/packages/javascript/cypress/fixtures/royalblue@2560x1440.png b/packages/javascript/cypress/fixtures/royalblue@2560x1440.png new file mode 100644 index 0000000..98e433d Binary files /dev/null and b/packages/javascript/cypress/fixtures/royalblue@2560x1440.png differ diff --git a/packages/javascript/cypress/support/commands.ts b/packages/javascript/cypress/support/commands.ts index 95857ae..fe9a58a 100644 --- a/packages/javascript/cypress/support/commands.ts +++ b/packages/javascript/cypress/support/commands.ts @@ -8,30 +8,51 @@ // commands please read more here: // https://on.cypress.io/custom-commands // *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -// -// declare global { -// namespace Cypress { -// interface Chainable { -// login(email: string, password: string): Chainable -// drag(subject: string, options?: Partial): Chainable -// dismiss(subject: string, options?: Partial): Chainable -// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable -// } -// } -// } + +import { z } from 'zod'; +type Color = z.infer; +const rgb = z.object({ + r: z.int().gte(0).lte(255), + g: z.int().gte(0).lte(255), + b: z.int().gte(0).lte(255), +}); + +Cypress.Commands.add('getPixelAt', (x: number, y: number) => { + const id = Math.random().toString(16).slice(2); + cy.screenshot(id, { overwrite: true, capture: 'viewport' }); + cy.task('get-pixel-value', { id, x, y }).then(($response) => { + try { + return rgb.parse($response); + } catch (e) { + console.error($response); + console.error(e); + } + }); +}); + +// If all r,g,b are within ε of the expected value the color is expected +// An identical web page will be rendered differently depending on the color profile. +// This will be different on different machines, and displays +const RGB_ε = 15; +Cypress.Commands.add('assertPixelAt', (x: number, y: number, expected: Color) => { + cy.getPixelAt(x, y).then(($actual) => { + const expectedColor = `{r:${expected.r},g:${expected.g},b:${expected.b}}`; + const actualColor = `{r:${$actual.r},g:${$actual.g},b:${$actual.b}}`; + expect($actual.r).to.be.closeTo(expected.r, RGB_ε, `Expected within ${RGB_ε} of color ${expectedColor}, but got ${actualColor}`); + expect($actual.g).to.be.closeTo(expected.g, RGB_ε, `Expected within ${RGB_ε} of color ${expectedColor}, but got ${actualColor}`); + expect($actual.b).to.be.closeTo(expected.b, RGB_ε, `Expected within ${RGB_ε} of color ${expectedColor}, but got ${actualColor}`); + }); +}); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + getPixelAt(x: number, y: number): Chainable; + assertPixelAt(x: number, y: number, color: Color): Chainable; + } + } +} + +// Required to declare global +export {}; diff --git a/packages/javascript/package.json b/packages/javascript/package.json index b74eef1..c3c849b 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -56,10 +56,11 @@ "prettier": "^3.4.2", "react": "^19.1.1", "react-dom": "^19.1.1", + "sharp": "^0.34.5", "typedoc": "^0.27.5", "typescript": "~5.7.2", "typescript-eslint": "^8.18.1", "vite": "^7.1.12", "vitest": "^4.0.6" } -} \ No newline at end of file +} diff --git a/packages/javascript/src/state-based/ImageManager.ts b/packages/javascript/src/state-based/ImageManager.ts index 7f75ea5..3784553 100644 --- a/packages/javascript/src/state-based/ImageManager.ts +++ b/packages/javascript/src/state-based/ImageManager.ts @@ -1,4 +1,4 @@ -import { ImageState } from '../types/MediaSchema'; +import { defaultImageOptions, ImageState } from '../types/MediaSchema'; import { getStateAtTime } from '../utils/getStateAtTime'; import { ClipManager } from './ClipManager'; @@ -39,6 +39,13 @@ export class ImageManager extends ClipManager { if (this.imageElement.style.objectFit !== this._state.fit) { this.imageElement.style.objectFit = this._state.fit; } + if (parseFloat(this.imageElement.style.opacity) !== currentState.opacity) { + this.imageElement.style.opacity = String(currentState.opacity ?? defaultImageOptions.opacity); + } + const z = Math.round(currentState.zIndex ?? defaultImageOptions.zIndex); + if (parseInt(this.imageElement.style.zIndex) !== z) { + this.imageElement.style.zIndex = String(z); + } const { opacity } = currentState; if (typeof opacity === 'string' && opacity !== this.imageElement.style.opacity) { diff --git a/packages/javascript/src/state-based/VideoManager.ts b/packages/javascript/src/state-based/VideoManager.ts index 5fbfdf5..0b95543 100644 --- a/packages/javascript/src/state-based/VideoManager.ts +++ b/packages/javascript/src/state-based/VideoManager.ts @@ -73,6 +73,13 @@ export class VideoManager extends ClipManager { if (this.videoElement.style.objectFit !== this._state.fit) { this.videoElement.style.objectFit = this._state.fit; } + if (parseFloat(this.videoElement.style.opacity) !== currentState.opacity) { + this.videoElement.style.opacity = String(currentState.opacity ?? defaultVideoOptions.opacity); + } + const z = Math.round(currentState.zIndex ?? defaultVideoOptions.zIndex); + if (parseInt(this.videoElement.style.zIndex) !== z) { + this.videoElement.style.zIndex = String(z); + } if (this.videoElement.volume !== volume) { this.videoElement.volume = volume; } diff --git a/packages/javascript/src/types/MediaSchema.ts b/packages/javascript/src/types/MediaSchema.ts index 4d12bb0..f3c00fc 100644 --- a/packages/javascript/src/types/MediaSchema.ts +++ b/packages/javascript/src/types/MediaSchema.ts @@ -8,6 +8,7 @@ const TemporalProperties = z.object({ export type VisualProperties = z.infer; const VisualProperties = z.object({ opacity: z.number().gte(0).lte(1), + zIndex: z.number(), }); export type AudialProperties = z.infer; const AudialProperties = z.object({ @@ -213,6 +214,7 @@ true satisfies UnionsEqual