import type { PossibleVector2, SerializedVector2, SignalValue, SimpleSignal, } from '@revideo/core'; import { BBox, Color, DependencyContext, DetailedError, Vector2, useLogger, } from '@revideo/core'; import {computed, initial, nodeName, signal} from '../decorators'; import type {DesiredLength} from '../partials'; import {drawImage} from '../utils'; import type {RectProps} from './Rect'; import {Rect} from './Rect'; const imageWithoutSource = ` The image won't be visible unless you specify a source: \`\`\`tsx import myImage from './example.png'; // ... ; \`\`\` If you did this intentionally, and don't want to see this warning, set the \`src\` property to \`null\`: \`\`\`tsx \`\`\` [Learn more](https://motioncanvas.io/docs/media#images) about working with images. `; export interface ImgProps extends RectProps { /** * {@inheritDoc Img.src} */ src?: SignalValue; /** * {@inheritDoc Img.alpha} */ alpha?: SignalValue; /** * {@inheritDoc Img.smoothing} */ smoothing?: SignalValue; } /** * A node for displaying images. * * @preview * ```tsx editor * import {Img} from '@revideo/2d'; * import {all, waitFor} from '@revideo/core'; * import {createRef} from '@revideo/core'; * import {makeScene2D} from '@revideo/2d'; * * export default makeScene2D(function* (view) { * const ref = createRef(); * yield view.add( * , * ); * * // set the background using the color sampled from the image: * ref().fill(ref().getColorAtPoint(0)); * * yield* all( * ref().size([100, 100], 1).to([300, null], 1), * ref().radius(50, 1).to(20, 1), * ref().alpha(0, 1).to(1, 1), * ); * yield* waitFor(0.5); * }); * ``` */ @nodeName('Img') export class Img extends Rect { private static pool: Record = {}; static { if (import.meta.hot) { import.meta.hot.on('revideo:assets', ({urls}) => { for (const url of urls) { if (Img.pool[url]) { delete Img.pool[url]; } } }); } } /** * The source of this image. * * @example * Using a local image: * ```tsx * import image from './example.png'; * // ... * view.add() * ``` * Loading an image from the internet: * ```tsx * view.add() * ``` */ @signal() public declare readonly src: SimpleSignal; /** * The alpha value of this image. * * @remarks * Unlike opacity, the alpha value affects only the image itself, leaving the * fill, stroke, and children intact. */ @initial(1) @signal() public declare readonly alpha: SimpleSignal; /** * Whether the image should be smoothed. * * @remarks * When disabled, the image will be scaled using the nearest neighbor * interpolation with no smoothing. The resulting image will appear pixelated. * * @defaultValue true */ @initial(true) @signal() public declare readonly smoothing: SimpleSignal; public constructor(props: ImgProps) { super(props); if (!('src' in props)) { useLogger().warn({ message: 'No source specified for the image', remarks: imageWithoutSource, inspect: this.key, }); } } protected override desiredSize(): SerializedVector2 { const custom = super.desiredSize(); if (custom.x === null && custom.y === null) { const image = this.image(); return { x: image.naturalWidth, y: image.naturalHeight, }; } return custom; } @computed() protected image(): HTMLImageElement { const src = this.src(); const url = new URL(src, window.location.origin); if (url.origin === window.location.origin) { const hash = this.view().assetHash(); url.searchParams.set('asset-hash', hash); } let image = Img.pool[src]; if (!image) { image = document.createElement('img'); image.crossOrigin = 'anonymous'; image.src = src; Img.pool[src] = image; } if (!image.complete) { DependencyContext.collectPromise( new Promise((resolve, reject) => { image.addEventListener('load', resolve); image.addEventListener('error', () => // TODO: example for error handling inside DependencyContext (this shouldn't be UI specific) reject( new DetailedError({ message: `Failed to load an image`, remarks: `\ The src property was set to:
${src}
Make sure that source is correct and that the image exists.
Learn more about working with images.`, inspect: this.key, }), ), ); }), ); } return image; } @computed() protected imageCanvas(): CanvasRenderingContext2D { const canvas = document .createElement('canvas') .getContext('2d', {willReadFrequently: true}); if (!canvas) { throw new Error('Could not create an image canvas'); } return canvas; } @computed() protected filledImageCanvas() { const context = this.imageCanvas(); const image = this.image(); context.canvas.width = image.naturalWidth; context.canvas.height = image.naturalHeight; context.imageSmoothingEnabled = this.smoothing(); context.drawImage(image, 0, 0); return context; } protected override async draw(context: CanvasRenderingContext2D) { this.drawShape(context); const alpha = this.alpha(); if (alpha > 0) { const box = BBox.fromSizeCentered(this.computedSize()); context.save(); context.clip(this.getPath()); if (alpha < 1) { context.globalAlpha *= alpha; } context.imageSmoothingEnabled = this.smoothing(); drawImage(context, this.image(), box); context.restore(); } if (this.clip()) { context.clip(this.getPath()); } await this.drawChildren(context); } protected override applyFlex() { super.applyFlex(); const image = this.image(); this.element.style.aspectRatio = ( this.ratio() ?? image.naturalWidth / image.naturalHeight ).toString(); } /** * Get color of the image at the given position. * * @param position - The position in local space at which to sample the color. */ public getColorAtPoint(position: PossibleVector2): Color { const size = this.computedSize(); const naturalSize = this.naturalSize(); const pixelPosition = new Vector2(position) .add(this.computedSize().scale(0.5)) .mul(naturalSize.div(size).safe); return this.getPixelColor(pixelPosition); } /** * The natural size of this image. * * @remarks * The natural size is the size of the source image unaffected by the size * and scale properties. */ @computed() public naturalSize() { const image = this.image(); return new Vector2(image.naturalWidth, image.naturalHeight); } /** * Get color of the image at the given pixel. * * @param position - The pixel's position. */ public getPixelColor(position: PossibleVector2): Color { const context = this.filledImageCanvas(); const vector = new Vector2(position); const data = context.getImageData(vector.x, vector.y, 1, 1).data; return new Color({ r: data[0], g: data[1], b: data[2], a: data[3] / 255, }); } protected override collectAsyncResources() { super.collectAsyncResources(); this.image(); } }