diff options
Diffstat (limited to 'apps/web/src/services/renderer/resolve.ts')
| -rw-r--r-- | apps/web/src/services/renderer/resolve.ts | 468 |
1 files changed, 468 insertions, 0 deletions
diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts new file mode 100644 index 00000000..3f30f0d5 --- /dev/null +++ b/apps/web/src/services/renderer/resolve.ts @@ -0,0 +1,468 @@ +import { mediaTimeToSeconds } from "opencut-wasm"; +import { + getElementLocalTime, + resolveColorAtTime, + resolveGraphicParamsAtTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "@/lib/animation"; +import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel"; +import { + buildGaussianBlurPasses, + intensityToSigma, +} from "@/lib/effects/definitions/blur"; +import { effectsRegistry, resolveEffectPasses } from "@/lib/effects"; +import type { Effect, EffectPass } from "@/lib/effects/types"; +import { getSourceTimeAtClipTime } from "@/lib/retime"; +import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics"; +import { + getTextMeasurementContext, + measureTextElement, +} from "@/lib/text/measure-element"; +import { videoCache } from "@/services/video-cache/service"; +import type { CanvasRenderer } from "./canvas-renderer"; +import type { AnyBaseNode } from "./nodes/base-node"; +import { + BlurBackgroundNode, + type BackdropSource, + type ResolvedBlurBackgroundNodeState, +} from "./nodes/blur-background-node"; +import { EffectLayerNode, type ResolvedEffectLayerNodeState } from "./nodes/effect-layer-node"; +import { + GraphicNode, + type ResolvedGraphicNodeState, +} from "./nodes/graphic-node"; +import { ImageNode, loadImageSource } from "./nodes/image-node"; +import { StickerNode, loadStickerSource } from "./nodes/sticker-node"; +import { TextNode, type ResolvedTextNodeState } from "./nodes/text-node"; +import { VideoNode } from "./nodes/video-node"; +import type { + ResolvedVisualNodeState, + ResolvedVisualSourceNodeState, + VisualNodeParams, +} from "./nodes/visual-node"; + +type ResolveContext = { + renderer: CanvasRenderer; + time: number; +}; + +export async function resolveRenderTree({ + node, + renderer, + time, +}: { + node: AnyBaseNode; + renderer: CanvasRenderer; + time: number; +}): Promise<void> { + await resolveNode({ + node, + context: { + renderer, + time, + }, + }); +} + +async function resolveNode({ + node, + context, +}: { + node: AnyBaseNode; + context: ResolveContext; +}): Promise<void> { + if (node instanceof VideoNode) { + node.resolved = await resolveVideoNode({ node, context }); + } else if (node instanceof ImageNode) { + node.resolved = await resolveImageNode({ node, context }); + } else if (node instanceof StickerNode) { + node.resolved = await resolveStickerNode({ node, context }); + } else if (node instanceof GraphicNode) { + node.resolved = resolveGraphicNode({ node, context }); + } else if (node instanceof TextNode) { + node.resolved = resolveTextNode({ node, context }); + } else if (node instanceof BlurBackgroundNode) { + node.resolved = await resolveBlurBackgroundNode({ node, context }); + } else if (node instanceof EffectLayerNode) { + node.resolved = resolveEffectLayerNode({ node, context }); + } + + await Promise.all( + node.children.map((child) => resolveNode({ node: child, context })), + ); +} + +function resolveEffectPassGroups({ + effects, + animations, + localTime, + width, + height, +}: { + effects: Effect[] | undefined; + animations: VisualNodeParams["animations"]; + localTime: number; + width: number; + height: number; +}): EffectPass[][] { + return (effects ?? []) + .filter((effect) => effect.enabled) + .map((effect) => { + const resolvedParams = resolveEffectParamsAtTime({ + effect, + animations, + localTime, + }); + const definition = effectsRegistry.get(effect.type); + return resolveEffectPasses({ + definition, + effectParams: resolvedParams, + width, + height, + }); + }); +} + +function resolveVisualState({ + params, + context, + sourceWidth, + sourceHeight, +}: { + params: VisualNodeParams; + context: ResolveContext; + sourceWidth: number; + sourceHeight: number; +}): ResolvedVisualNodeState | null { + const clipTime = context.time - params.timeOffset; + if (clipTime < 0 || clipTime >= params.duration) { + return null; + } + + const localTime = getElementLocalTime({ + timelineTime: context.time, + elementStartTime: params.timeOffset, + elementDuration: params.duration, + }); + const transform = resolveTransformAtTime({ + baseTransform: params.transform, + animations: params.animations, + localTime, + }); + const opacity = resolveOpacityAtTime({ + baseOpacity: params.opacity, + animations: params.animations, + localTime, + }); + const containScale = Math.min( + context.renderer.width / sourceWidth, + context.renderer.height / sourceHeight, + ); + const effectWidth = Math.round( + Math.abs(sourceWidth * containScale * transform.scaleX), + ); + const effectHeight = Math.round( + Math.abs(sourceHeight * containScale * transform.scaleY), + ); + + return { + localTime, + transform, + opacity, + effectPasses: resolveEffectPassGroups({ + effects: params.effects, + animations: params.animations, + localTime, + width: effectWidth, + height: effectHeight, + }), + }; +} + +async function resolveVideoNode({ + node, + context, +}: { + node: VideoNode; + context: ResolveContext; +}): Promise<ResolvedVisualSourceNodeState | null> { + const clipTime = context.time - node.params.timeOffset; + if (clipTime < 0 || clipTime >= node.params.duration) { + return null; + } + + const sourceTimeTicks = + node.params.trimStart + + getSourceTimeAtClipTime({ + clipTime, + retime: node.params.retime, + }); + const frame = await videoCache.getFrameAt({ + mediaId: node.params.mediaId, + file: node.params.file, + time: mediaTimeToSeconds({ time: sourceTimeTicks }), + }); + if (!frame) { + return null; + } + + const visualState = resolveVisualState({ + params: node.params, + context, + sourceWidth: frame.canvas.width, + sourceHeight: frame.canvas.height, + }); + if (!visualState) { + return null; + } + + return { + ...visualState, + source: frame.canvas, + sourceWidth: frame.canvas.width, + sourceHeight: frame.canvas.height, + }; +} + +async function resolveImageNode({ + node, + context, +}: { + node: ImageNode; + context: ResolveContext; +}): Promise<ResolvedVisualSourceNodeState | null> { + const source = await loadImageSource(node.params.url, node.params.maxSourceSize); + const visualState = resolveVisualState({ + params: node.params, + context, + sourceWidth: source.width, + sourceHeight: source.height, + }); + if (!visualState) { + return null; + } + + return { + ...visualState, + source: source.source, + sourceWidth: source.width, + sourceHeight: source.height, + }; +} + +async function resolveStickerNode({ + node, + context, +}: { + node: StickerNode; + context: ResolveContext; +}): Promise<ResolvedVisualSourceNodeState | null> { + const source = await loadStickerSource({ stickerId: node.params.stickerId }); + const sourceWidth = node.params.intrinsicWidth ?? source.width; + const sourceHeight = node.params.intrinsicHeight ?? source.height; + const visualState = resolveVisualState({ + params: node.params, + context, + sourceWidth, + sourceHeight, + }); + if (!visualState) { + return null; + } + + return { + ...visualState, + source: source.source, + sourceWidth, + sourceHeight, + }; +} + +function resolveGraphicNode({ + node, + context, +}: { + node: GraphicNode; + context: ResolveContext; +}): ResolvedGraphicNodeState | null { + const visualState = resolveVisualState({ + params: node.params, + context, + sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, + sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, + }); + if (!visualState) { + return null; + } + + return { + ...visualState, + resolvedParams: resolveGraphicParamsAtTime({ + element: node.params, + localTime: visualState.localTime, + }), + }; +} + +function resolveTextNode({ + node, + context, +}: { + node: TextNode; + context: ResolveContext; +}): ResolvedTextNodeState | null { + if ( + context.time < node.params.startTime || + context.time >= node.params.startTime + node.params.duration + ) { + return null; + } + + const localTime = getElementLocalTime({ + timelineTime: context.time, + elementStartTime: node.params.startTime, + elementDuration: node.params.duration, + }); + + return { + transform: resolveTransformAtTime({ + baseTransform: node.params.transform, + animations: node.params.animations, + localTime, + }), + opacity: resolveOpacityAtTime({ + baseOpacity: node.params.opacity, + animations: node.params.animations, + localTime, + }), + textColor: resolveColorAtTime({ + baseColor: node.params.color, + animations: node.params.animations, + propertyPath: "color", + localTime, + }), + backgroundColor: resolveColorAtTime({ + baseColor: node.params.background.color, + animations: node.params.animations, + propertyPath: "background.color", + localTime, + }), + effectPasses: resolveEffectPassGroups({ + effects: node.params.effects, + animations: node.params.animations, + localTime, + width: context.renderer.width, + height: context.renderer.height, + }), + measuredText: measureTextElement({ + element: node.params, + canvasHeight: node.params.canvasHeight, + localTime, + ctx: getTextMeasurementContext(), + }), + }; +} + +async function resolveBlurBackgroundNode({ + node, + context, +}: { + node: BlurBackgroundNode; + context: ResolveContext; +}): Promise<ResolvedBlurBackgroundNodeState | null> { + const clipTime = context.time - node.params.timeOffset; + if (clipTime < 0 || clipTime >= node.params.duration) { + return null; + } + + const backdropSource = await resolveBackdropSource({ node, clipTime }); + if (!backdropSource) { + return null; + } + + return { + backdropSource, + passes: buildGaussianBlurPasses({ + sigmaX: intensityToSigma({ + intensity: node.params.blurIntensity, + resolution: context.renderer.width, + reference: 1920, + }), + sigmaY: intensityToSigma({ + intensity: node.params.blurIntensity, + resolution: context.renderer.height, + reference: 1080, + }), + }), + }; +} + +async function resolveBackdropSource({ + node, + clipTime, +}: { + node: BlurBackgroundNode; + clipTime: number; +}): Promise<BackdropSource | null> { + if (node.params.mediaType === "video") { + const sourceTimeTicks = + node.params.trimStart + + getSourceTimeAtClipTime({ + clipTime, + retime: node.params.retime, + }); + const frame = await videoCache.getFrameAt({ + mediaId: node.params.mediaId, + file: node.params.file, + time: mediaTimeToSeconds({ time: sourceTimeTicks }), + }); + if (!frame) { + return null; + } + + return { + source: frame.canvas, + width: frame.canvas.width, + height: frame.canvas.height, + }; + } + + const source = await loadImageSource(node.params.url); + return { + source: source.source, + width: source.width, + height: source.height, + }; +} + +function resolveEffectLayerNode({ + node, + context, +}: { + node: EffectLayerNode; + context: ResolveContext; +}): ResolvedEffectLayerNodeState | null { + const time = context.time; + if ( + time < node.params.timeOffset - 1e-6 || + time >= node.params.timeOffset + node.params.duration + 1e-6 + ) { + return null; + } + + const definition = effectsRegistry.get(node.params.effectType); + const passes = resolveEffectPasses({ + definition, + effectParams: node.params.effectParams, + width: context.renderer.width, + height: context.renderer.height, + }); + if (passes.length === 0) { + return null; + } + + return { + passes, + }; +} |
