diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 3409dbdd..3fe482d5 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -47,6 +47,6 @@ // Pinned '@vue/devtools-api', - "typescript" // https://github.com/vuejs/language-tools/issues/5018 + 'typescript' // https://github.com/vuejs/language-tools/issues/5018 ] } diff --git a/packages/daguerreo/package.json b/packages/daguerreo/package.json new file mode 100644 index 00000000..fea6d971 --- /dev/null +++ b/packages/daguerreo/package.json @@ -0,0 +1,32 @@ +{ + "name": "@safelight/daguerreo", + "version": "1.0.0", + "author": "Joery Münninghoff", + "repository": { + "type": "git", + "url": "https://github.com/Joery-M/Safelight" + }, + "contributors": [], + "license": "MIT", + "type": "module", + "exports": { + ".": { + "import": "./src/index.ts" + }, + "./sources/*": { + "import": "./src/sources/*.ts" + }, + "./transformers/*": { + "import": "./src/transformers/*.ts" + } + }, + "devDependencies": { + "type-fest": "^4.30.2" + }, + "dependencies": { + "gifuct-js": "^2.1.2" + }, + "peerDependencies": { + "vue": "^3.5.13" + } +} diff --git a/packages/daguerreo/src/assets/cat.avif b/packages/daguerreo/src/assets/cat.avif new file mode 100644 index 00000000..42ad732d Binary files /dev/null and b/packages/daguerreo/src/assets/cat.avif differ diff --git a/packages/daguerreo/src/assets/fish-fish-eating.gif b/packages/daguerreo/src/assets/fish-fish-eating.gif new file mode 100644 index 00000000..74852496 Binary files /dev/null and b/packages/daguerreo/src/assets/fish-fish-eating.gif differ diff --git a/packages/daguerreo/src/index.ts b/packages/daguerreo/src/index.ts new file mode 100644 index 00000000..358cbdc8 --- /dev/null +++ b/packages/daguerreo/src/index.ts @@ -0,0 +1,164 @@ +import { computed, shallowReactive, shallowRef } from 'vue'; +import type { DGTransformProperties, DGTransformProperty } from './properties'; +import type { DaguerreoSourceEffect, DaguerreoSourcePayload } from './sourceEffect'; +import type { DaguerreoTransformEffect, DaguerreoTransformPayload } from './transformEffect'; + +export class Daguerreo { + private source = shallowRef(undefined); + readonly effects = shallowReactive([]); + private effectsWithSourceInitHooks = computed(() => + this.effects.filter((e) => !!e.sourceInitialized) + ); + private effectsWithTransformHooks = computed(() => this.effects.filter((e) => !!e.transform)); + private ranLoadFunction = false; + + addEffect(effect: DaguerreoTransformEffect) { + this.effects.push(effect); + } + removeEffect(effect: DaguerreoTransformEffect) { + this.effects.slice(this.effects.indexOf(effect) - 1, 1); + } + setSource(source: DaguerreoSourceEffect) { + this.source.value = source; + this.ranLoadFunction = false; + } + + reset() { + this.source.value = undefined; + this.ranLoadFunction = false; + this.effects.splice(0, this.effects.length); + } + + getTransformProps() { + const allProps: DGTransformProperties[] = []; + for (let i = 0; i < this.effects.length; i++) { + const effect = this.effects[i]; + + const props: DGTransformProperties = {}; + if (effect.properties) { + for (const key in effect.properties) { + if (key in effect.properties) { + const prop = effect.properties[key] as DGTransformProperty; + props[key] = prop.value(); + } + } + } + + allProps[i] = props; + } + + return allProps; + } + + async process( + config: DaguerreoSourcePayload, + transformProps: DGTransformProperties[] = this.getTransformProps() + ): Promise { + const effectBase = this.source.value; + if (!effectBase) throw new Error('No source effect defined'); + + if (!this.ranLoadFunction) { + this.ranLoadFunction = true; + await effectBase.load?.(); + } + + const payload: DaguerreoTransformPayload = Object.assign( + // Default values + { + matrix: new DOMMatrix(), + compositeOperation: 'source-over', + opacity: 1, + frame: config.frame, + frameDuration: config.frameDuration, + width: config.width, + height: config.height, + maxWidth: config.width, + maxHeight: config.height, + quality: config.quality + }, + await effectBase.source(config) + ); + + switch (config.quality) { + case 'preview': + payload.ctx.imageSmoothingQuality = 'medium'; + break; + case 'rough': + payload.ctx.imageSmoothingQuality = 'low'; + break; + + default: + payload.ctx.imageSmoothingQuality = 'high'; + break; + } + + // Run initialize methods + const initFunctions = this.effectsWithSourceInitHooks.value.map((e) => + e.sourceInitialized?.({ height: payload.height, width: payload.width }) + ); + await Promise.allSettled(initFunctions); + + // Run effects + for (let i = 0; i < this.effectsWithTransformHooks.value.length; i++) { + const effect = this.effectsWithTransformHooks.value[i]; + + const props: DGTransformProperties = transformProps[i] ?? {}; + if (effect.properties) { + for (const key in effect.properties) { + if (key in effect.properties && !(key in props)) { + const prop = effect.properties[key]; + props[key] = prop.value(); + } + } + } + const result = await effect.transform!({ ...payload, properties: props }); + // Assign props + if (result) Object.assign(payload, result); + } + + return { + width: payload.width, + height: payload.height, + frameDuration: config.frameDuration, + image: payload.ctx.canvas.transferToImageBitmap(), + matrix: payload.matrix, + opacity: payload.opacity, + compositeOperation: payload.compositeOperation + } as DaguerreoResult; + } +} + +export type QualitySetting = 'rough' | 'preview' | 'final'; + +export type DaguerreoEffect = DaguerreoSourceEffect | DaguerreoTransformEffect; + +export interface DaguerreoResult { + /** + * Current frame width + */ + width: number; + /** + * Current frame height + */ + height: number; + /** + * Current frame duration + */ + frameDuration: number; + + image: ImageBitmap; + + matrix: DOMMatrix; + /** + * Number ranging from 0-1 that defines the opacity used for compositing + */ + opacity: number; + /** + * The blend mode used for compositing + */ + compositeOperation: GlobalCompositeOperation; +} + +export * from './properties'; +export * from './sourceEffect'; +export * from './transformEffect'; diff --git a/packages/daguerreo/src/properties.ts b/packages/daguerreo/src/properties.ts new file mode 100644 index 00000000..3d16d5f2 --- /dev/null +++ b/packages/daguerreo/src/properties.ts @@ -0,0 +1,58 @@ +import type { PartialDeep } from 'type-fest'; +import { ref } from 'vue'; + +export function dgNumberProperty( + value: number, + meta?: PartialDeep +): DGTransformProperty> { + const curValue = ref(value); + return { + type: 'number', + value() { + return curValue.value; + }, + displayValue() { + return meta?.transform?.toDisplay + ? meta.transform.toDisplay(curValue.value) + : curValue.value; + }, + setValue(value: number) { + curValue.value = meta?.transform?.toValue ? meta.transform.toValue(value) : value; + }, + meta + }; +} + +export interface NumberPropertyConfig { + min: number; + max: number; + step: number; + slider: boolean; + /** + * @default false + */ + integerOnly: boolean; + transform: { + toDisplay: (value: number) => number; + toValue: (display: number) => number; + }; +} + +export type DGPropertyTypes = 'number'; + +export type DGTransformProperty> = { + type: DGPropertyTypes; + value(): T; + displayValue(): T; + setValue(v: T): void; + meta?: Meta; +}; + +export interface DGTransformProperties { + [key: string]: DGTransformProperty; +} +export type DGComputedProperties

= P extends undefined + ? undefined + : { + [K in keyof P]: ReturnType[K]['value']>; + }; diff --git a/packages/daguerreo/src/sourceEffect.ts b/packages/daguerreo/src/sourceEffect.ts new file mode 100644 index 00000000..2ad43c2c --- /dev/null +++ b/packages/daguerreo/src/sourceEffect.ts @@ -0,0 +1,41 @@ +import type { Promisable, SetRequired } from 'type-fest'; +import type { DaguerreoTransformPayload } from './transformEffect'; +import type { QualitySetting } from '.'; + +export interface DaguerreoSourceEffect { + name: string; + load?: () => Promisable; + source: (config: DaguerreoSourcePayload) => Promisable; +} + +export type DaguerreoSourceResult = SetRequired, 'ctx'>; + +export interface DaguerreoSourcePayload { + /** + * Current frame number + */ + frame: number; + /** + * Frame duration in milliseconds. + */ + frameDuration: number; + /** + * Timeline width + */ + width: number; + /** + * Timeline height + */ + height: number; + /** + * The desired rendering quality. + * + * For if your source effect is able to render with different + * performance characteristics. + */ + quality: QualitySetting; +} + +export function defineSource(def: DaguerreoSourceEffect) { + return def; +} diff --git a/packages/daguerreo/src/sources/TestSource.ts b/packages/daguerreo/src/sources/TestSource.ts new file mode 100644 index 00000000..c2dfbee8 --- /dev/null +++ b/packages/daguerreo/src/sources/TestSource.ts @@ -0,0 +1,160 @@ +import { decompressFrames, parseGIF, type ParsedFrame } from 'gifuct-js'; +import type { DaguerreoSourceEffect } from '..'; + +export function GradientTestSource() { + const canvas = new OffscreenCanvas(1280, 720); + const ctx = canvas.getContext('2d')!; + + return { + name: 'gradient-test-source', + source: ({ frame, width, height }) => { + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + ctx.reset(); + + // Never thought the day would come + const diagonal = Math.sqrt(width ** 2 + height ** 2); + + const gradient = ctx.createLinearGradient(0, 0, width, 0); + + gradient.addColorStop(0, `hsl(${frame % 360}, 100%, 50%)`); + gradient.addColorStop(0.5, `hsl(${(frame % 360) + 180}, 100%, 50%)`); + gradient.addColorStop(1, `hsl(${frame % 360}, 100%, 50%)`); + + ctx.fillStyle = gradient; + + const matrix = ctx + .getTransform() + .translate(width / 2, height / 2) + .rotate(frame % 360) + .translate(width / -2, height / -2); + + ctx.setTransform(matrix); + // idk man, math shit + ctx.fillRect((diagonal - width) / -2, (diagonal - height) / -2, diagonal, diagonal); + ctx.resetTransform(); + + ctx.fillStyle = 'white'; + ctx.font = '84px Arial'; + ctx.fillText(Math.floor((frame / 10) % 100).toString(), 20, 75); + + return { + ctx + }; + } + } as DaguerreoSourceEffect; +} + +export function CatTestSource() { + const canvas = new OffscreenCanvas(1280, 720); + const ctx = canvas.getContext('2d')!; + + let catImage: HTMLImageElement; + + return { + name: 'cat-test-source', + async source() { + ctx.reset(); + + if (catImage) { + ctx.drawImage(catImage, 0, 0); + } else { + const d = await import('../assets/cat.avif'); + const img = document.createElement('img'); + img.src = d.default; + + await new Promise((resolve, reject) => { + img.addEventListener('load', () => { + catImage = img; + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(catImage, 0, 0); + resolve(); + }); + setTimeout(() => { + reject('Timeout'); + }, 10_000); + }); + } + + return { + ctx, + width: catImage.width, + height: catImage.height + }; + } + } as DaguerreoSourceEffect; +} + +export function GifTestSource() { + const canvas = new OffscreenCanvas(1280, 720); + const ctx = canvas.getContext('2d')!; + + const frames: ParsedFrame[] = []; + let gifLength = 0; + let frameTime = 1; + + let frameImageData: ImageData | undefined; + + return { + name: 'gif-test-source', + async load() { + const { default: url } = await import('../assets/fish-fish-eating.gif?url'); + const arrayBuffer = await fetch(url).then((r) => r.arrayBuffer()); + + const gif = parseGIF(arrayBuffer); + frames.push(...decompressFrames(gif, true)); + gifLength = frames.reduce((l, f) => l + f.delay, 0); + frameTime = gifLength / frames.length; + + canvas.width = Math.max(...frames.map(({ dims: { width } }) => width)); + canvas.height = Math.max(...frames.map(({ dims: { height } }) => height)); + }, + source: async ({ frame, frameDuration }) => { + ctx.reset(); + const curTime = (frame * frameDuration) % gifLength; + + if (frames.length > 0) { + let d = 0; + const frame = frames.find((f) => { + d += f.delay; + return d >= curTime; + }); + if (!frame) { + return { + ctx, + width: canvas.width, + height: canvas.height + }; + } + + const dims = frame.dims; + + if ( + !frameImageData || + dims.width != frameImageData.width || + dims.height != frameImageData.height + ) { + canvas.width = dims.width; + canvas.height = dims.height; + frameImageData = ctx.createImageData(dims.width, dims.height); + } + + // set the patch data as an override + frameImageData.data.set(frame.patch); + + // draw the patch back over the canvas + ctx.putImageData(frameImageData, 0, 0); + } + + return { + ctx, + frameDuration: 1000 / frameTime, + width: canvas.width, + height: canvas.height + }; + } + } as DaguerreoSourceEffect; +} diff --git a/packages/daguerreo/src/transformEffect.ts b/packages/daguerreo/src/transformEffect.ts new file mode 100644 index 00000000..e138a6f3 --- /dev/null +++ b/packages/daguerreo/src/transformEffect.ts @@ -0,0 +1,85 @@ +import type { PartialDeep, Promisable } from 'type-fest'; +import type { DGComputedProperties, DGTransformProperties } from './properties'; +import type { QualitySetting } from '.'; + +export interface DaguerreoTransformEffect< + Properties extends DGTransformProperties = DGTransformProperties +> { + name: string; + properties?: Properties; + load?: () => Promisable; + sourceInitialized?: ( + config: Pick + ) => Promisable; + transform?: ( + config: DaguerreoTransformPayload & { properties: DGComputedProperties } + ) => Promisable | void>; +} + +export interface DaguerreoTransformPayload { + /** + * Current frame number + */ + frame: number; + /** + * How long the current frame will last for in milliseconds. + */ + frameDuration: number; + /** + * Source width + */ + width: number; + /** + * Source height + */ + height: number; + /** + * Viewport/timeline width + */ + maxWidth: number; + /** + * Viewport/timeline height + */ + maxHeight: number; + /** + * Number ranging from 0-1 that defines the opacity used for compositing + */ + opacity: number; + /** + * The blend mode used for compositing + */ + compositeOperation: GlobalCompositeOperation; + /** + * The desired rendering quality. + * + * For if your effect is able to render with different + * performance characteristics. + */ + quality: QualitySetting; + + ctx: OffscreenCanvasRenderingContext2D; + + matrix: DOMMatrix; +} + +export interface DaguerreoTransformResult { + /** + * Number ranging from 0-1 that defines the opacity used for compositing + */ + opacity: number; + /** + * The blend mode used for compositing + */ + compositeOperation: GlobalCompositeOperation; +} + +export function defineEffect( + def: Omit & { + properties?: Properties; + transform?: ( + config: DaguerreoTransformPayload & { properties: DGComputedProperties } + ) => Promisable | void>; + } +) { + return def as DaguerreoTransformEffect; +} diff --git a/packages/daguerreo/src/transformers/TranslateTransform.ts b/packages/daguerreo/src/transformers/TranslateTransform.ts new file mode 100644 index 00000000..a4fa0aec --- /dev/null +++ b/packages/daguerreo/src/transformers/TranslateTransform.ts @@ -0,0 +1,114 @@ +import { defineEffect } from '..'; +import { dgNumberProperty } from '../properties'; + +export function TranslateTransform() { + return defineEffect({ + name: 'dg-translate-transform', + properties: { + distance: dgNumberProperty(20), + speed: dgNumberProperty(30) + }, + transform: ({ frame, matrix, properties }) => { + const distance = properties.distance * 10; + matrix.translateSelf( + Math.sin(frame / properties.speed) * distance, + Math.cos(frame / properties.speed) * distance + ); + } + }); +} + +export function RotateTransform() { + return defineEffect({ + name: 'dg-rotate-transform', + transform: ({ ctx, frame, matrix }) => { + matrix.translateSelf(ctx.canvas.width / 2, ctx.canvas.height / 2); + matrix.rotateSelf(frame); + matrix.translateSelf(ctx.canvas.width / -2, ctx.canvas.height / -2); + } + }); +} + +export function ScaleTransform() { + return defineEffect({ + name: 'dg-scale-transform', + transform: ({ ctx, frame, matrix }) => { + matrix.translateSelf(ctx.canvas.width / 2, ctx.canvas.height / 2); + matrix.scaleSelf(1 - (Math.sin(frame / 30) + 1) / 5); + matrix.translateSelf(ctx.canvas.width / -2, ctx.canvas.height / -2); + } + }); +} + +export function FlipTransform() { + return defineEffect({ + name: 'dg-flip-transform', + transform: ({ ctx, frame, matrix }) => { + matrix.translateSelf(ctx.canvas.width / 2, ctx.canvas.height / 2); + const scaleY = Math.sin(frame / 30); + matrix.scaleSelf(1, scaleY); + if (scaleY < 0) { + ctx.fillStyle = '#000000D0'; + ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); + } + matrix.translateSelf(ctx.canvas.width / -2, ctx.canvas.height / -2); + } + }); +} + +export function GenericTransform() { + return defineEffect({ + name: 'dg-transform', + properties: { + x: dgNumberProperty(0), + y: dgNumberProperty(0), + scaleX: dgNumberProperty(1, { + transform: { toDisplay: (v) => v * 100, toValue: (v) => v / 100 } + }), + scaleY: dgNumberProperty(1, { + transform: { toDisplay: (v) => v * 100, toValue: (v) => v / 100 } + }), + originX: dgNumberProperty(0.5, { + transform: { toDisplay: (v) => v * 100, toValue: (v) => v / 100 } + }), + originY: dgNumberProperty(0.5, { + transform: { toDisplay: (v) => v * 100, toValue: (v) => v / 100 } + }), + rotation: dgNumberProperty(0, { + transform: { toValue: (v) => (v < 0 ? v + 360 : v % 360) } + }), + opacity: dgNumberProperty(1, { + integerOnly: true, + min: 0, + max: 100, + slider: true, + transform: { toDisplay: (v) => v * 100, toValue: (v) => v / 100 } + }) + }, + transform({ matrix, properties, width, height, opacity }) { + if (properties.x !== 0 || properties.y !== 0) { + matrix.translateSelf(properties.x, properties.y); + } + if (properties.scaleX !== 1 || properties.scaleY !== 1) { + matrix.scaleSelf( + properties.scaleX, + properties.scaleY, + 1, + width * properties.originX, + height * properties.originY + ); + } + if (properties.rotation !== 0) { + matrix + .translateSelf(width * properties.originX, height * properties.originY) + .rotateSelf(properties.rotation) + .translateSelf(width * -properties.originX, height * -properties.originY); + } + + return { + matrix, + opacity: opacity * properties.opacity + }; + } + }); +} diff --git a/packages/safelight/buildscripts/generatePackageList.ts b/packages/safelight/buildscripts/generatePackageList.ts index 3f3e7daf..8a12e9fc 100644 --- a/packages/safelight/buildscripts/generatePackageList.ts +++ b/packages/safelight/buildscripts/generatePackageList.ts @@ -4,6 +4,10 @@ import { mkdir, writeFile } from 'fs/promises'; import { join } from 'path'; const projects = [ + { + projectPath: 'packages/daguerreo', + outputFileName: 'packages-daguerreo.json' + }, { projectPath: 'packages/darkroom', outputFileName: 'packages-darkroom.json' diff --git a/packages/safelight/package.json b/packages/safelight/package.json index da625149..5e00b4d0 100644 --- a/packages/safelight/package.json +++ b/packages/safelight/package.json @@ -21,6 +21,7 @@ "@phosphor-icons/vue": "^2.2.1", "@phosphor-icons/web": "^2.1.1", "@primevue/themes": "^4.2.5", + "@safelight/daguerreo": "workspace:^", "@safelight/shared": "workspace:*", "@safelight/timeline": "workspace:*", "@vueuse/core": "^12.1.0", diff --git a/packages/safelight/src/views/dev/Daguerreo.vue b/packages/safelight/src/views/dev/Daguerreo.vue new file mode 100644 index 00000000..e6469823 --- /dev/null +++ b/packages/safelight/src/views/dev/Daguerreo.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/packages/safelight/src/views/dev/Packages.vue b/packages/safelight/src/views/dev/Packages.vue index 951f5c09..d912975c 100644 --- a/packages/safelight/src/views/dev/Packages.vue +++ b/packages/safelight/src/views/dev/Packages.vue @@ -56,7 +56,10 @@ style="border-width: 1px" >