diff options
Diffstat (limited to 'apps/web/src/services')
5 files changed, 192 insertions, 25 deletions
diff --git a/apps/web/src/services/renderer/__tests__/scene-builder-transition.test.ts b/apps/web/src/services/renderer/__tests__/scene-builder-transition.test.ts new file mode 100644 index 00000000..dd644f2a --- /dev/null +++ b/apps/web/src/services/renderer/__tests__/scene-builder-transition.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { buildScene } from "@/services/renderer/scene-builder"; +import { ImageNode } from "@/services/renderer/nodes/image-node"; +import type { ImageElement, SceneTracks } from "@/timeline"; +import type { MediaAsset } from "@/media/types"; +import { + mediaTime, + type MediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; + +function imageElement({ + id, + mediaId, + startTime, + transitionDuration = 0, +}: { + id: string; + mediaId: string; + startTime: MediaTime; + transitionDuration?: number; +}): ImageElement { + return { + id, + name: id, + type: "image", + mediaId, + startTime, + duration: mediaTime({ ticks: 2 * TICKS_PER_SECOND }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + blendMode: "normal", + "transition.kind": "fade", + "transition.inDuration": transitionDuration, + "transition.outDuration": 0, + }, + }; +} + +function imageAsset(id: string): MediaAsset { + return { + id, + name: id, + type: "image", + file: new File([], `${id}.png`, { type: "image/png" }), + url: `blob:${id}`, + }; +} + +describe("scene transition boundaries", () => { + test("holds the outgoing slide through the incoming transition", () => { + const first = imageElement({ + id: "first", + mediaId: "media-first", + startTime: ZERO_MEDIA_TIME, + }); + const second = imageElement({ + id: "second", + mediaId: "media-second", + startTime: mediaTime({ ticks: 3 * TICKS_PER_SECOND }), + transitionDuration: 1, + }); + const tracks: SceneTracks = { + overlay: [], + main: { + id: "main", + name: "Main Track", + type: "video", + elements: [first, second], + muted: false, + hidden: false, + }, + audio: [], + }; + const scene = buildScene({ + canvasSize: { width: 1920, height: 1080 }, + tracks, + mediaAssets: [imageAsset("media-first"), imageAsset("media-second")], + duration: 5 * TICKS_PER_SECOND, + background: { type: "color", color: "transparent" }, + }); + const imageNodes = scene.children.filter( + (node): node is ImageNode => node instanceof ImageNode, + ); + + expect(imageNodes).toHaveLength(2); + expect(imageNodes[0].params.contentDuration).toBe(2 * TICKS_PER_SECOND); + expect(imageNodes[0].params.duration).toBe(4 * TICKS_PER_SECOND); + expect(imageNodes[1].params.transition?.inDuration).toBe(TICKS_PER_SECOND); + }); +}); diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 6635a031..79ed0272 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -2,6 +2,7 @@ import { BaseNode } from "./base-node"; import type { TextElement } from "@/timeline"; import type { EffectPass } from "@/effects/types"; import type { BlendMode, Transform } from "@/rendering"; +import type { VisualTransition } from "@/rendering/transition"; import { drawMeasuredTextLayout } from "@/text/primitives"; import type { MeasuredTextElement } from "@/text/measure-element"; @@ -9,6 +10,7 @@ export type TextNodeParams = TextElement & { transform: Transform; opacity: number; blendMode?: BlendMode; + transition?: VisualTransition; canvasCenter: { x: number; y: number }; canvasHeight: number; textBaseline?: CanvasTextBaseline; diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index b9bf1709..e3f9b42f 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -2,10 +2,12 @@ import { BaseNode } from "./base-node"; import type { Effect, EffectPass } from "@/effects/types"; import type { Mask } from "@/masks/types"; import type { BlendMode, Transform } from "@/rendering"; +import type { VisualTransition } from "@/rendering/transition"; import type { RetimeConfig, VisualElement } from "@/timeline"; export interface VisualNodeParams { duration: number; + contentDuration?: number; timeOffset: number; trimStart: number; trimEnd: number; @@ -14,6 +16,7 @@ export interface VisualNodeParams { animations?: VisualElement["animations"]; opacity: number; blendMode?: BlendMode; + transition?: VisualTransition; effects?: Effect[]; masks?: Mask[]; } diff --git a/apps/web/src/services/renderer/resolve.ts b/apps/web/src/services/renderer/resolve.ts index 12f57e30..54a6eca4 100644 --- a/apps/web/src/services/renderer/resolve.ts +++ b/apps/web/src/services/renderer/resolve.ts @@ -22,6 +22,7 @@ import { } from "@/text/measure-element"; import { resolveColorAtTime, resolveOpacityAtTime } from "@/animation/values"; import { resolveTransformAtTime } from "@/rendering/animation-values"; +import { applyVisualTransition } from "@/rendering/transition"; import { videoCache } from "@/services/video-cache/service"; import type { CanvasRenderer } from "./canvas-renderer"; import type { AnyBaseNode } from "./nodes/base-node"; @@ -148,12 +149,13 @@ function resolveVisualState({ return null; } + const contentDuration = params.contentDuration ?? params.duration; const localTime = getElementLocalTime({ timelineTime: context.time, elementStartTime: params.timeOffset, - elementDuration: params.duration, + elementDuration: contentDuration, }); - const transform = applyMotionEffects({ + const baseTransform = applyMotionEffects({ baseTransform: resolveTransformAtTime({ baseTransform: params.transform, animations: params.animations, @@ -162,15 +164,24 @@ function resolveVisualState({ effects: params.effects, animations: params.animations, localTime, - duration: params.duration, + duration: contentDuration, canvasWidth: context.renderer.width, canvasHeight: context.renderer.height, }); - const opacity = resolveOpacityAtTime({ + const baseOpacity = resolveOpacityAtTime({ baseOpacity: params.opacity, animations: params.animations, localTime, }); + const transitionState = applyVisualTransition({ + baseTransform, + transition: params.transition, + localTime, + duration: contentDuration, + canvasWidth: context.renderer.width, + }); + const transform = transitionState.transform; + const opacity = baseOpacity * transitionState.opacityMultiplier; const containScale = Math.min( context.renderer.width / sourceWidth, context.renderer.height / sourceHeight, @@ -207,17 +218,21 @@ async function resolveVideoNode({ if (clipTime < 0 || clipTime >= node.params.duration) { return null; } + const contentDuration = node.params.contentDuration ?? node.params.duration; + const sourceClipTime = Math.min(clipTime, Math.max(0, contentDuration - 1)); const sourceTimeTicks = node.params.trimStart + getSourceTimeAtClipTime({ - clipTime, + clipTime: sourceClipTime, retime: node.params.retime, }); const frame = await videoCache.getFrameAt({ mediaId: node.params.mediaId, file: node.params.file, - time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }), + time: mediaTimeToSeconds({ + time: roundMediaTime({ time: sourceTimeTicks }), + }), }); if (!frame) { return null; @@ -344,18 +359,27 @@ function resolveTextNode({ elementDuration: node.params.duration, }); const background = buildTextBackgroundFromElement({ element: node.params }); + const baseTransform = resolveTransformAtTime({ + baseTransform: node.params.transform, + animations: node.params.animations, + localTime, + }); + const baseOpacity = resolveOpacityAtTime({ + baseOpacity: node.params.opacity, + animations: node.params.animations, + localTime, + }); + const transitionState = applyVisualTransition({ + baseTransform, + transition: node.params.transition, + localTime, + duration: node.params.duration, + canvasWidth: context.renderer.width, + }); return { - transform: resolveTransformAtTime({ - baseTransform: node.params.transform, - animations: node.params.animations, - localTime, - }), - opacity: resolveOpacityAtTime({ - baseOpacity: node.params.opacity, - animations: node.params.animations, - localTime, - }), + transform: transitionState.transform, + opacity: baseOpacity * transitionState.opacityMultiplier, textColor: resolveColorAtTime({ baseColor: typeof node.params.params.color === "string" @@ -438,7 +462,9 @@ async function resolveBackdropSource({ const frame = await videoCache.getFrameAt({ mediaId: node.params.mediaId, file: node.params.file, - time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }), + time: mediaTimeToSeconds({ + time: roundMediaTime({ time: sourceTimeTicks }), + }), }); if (!frame) { return null; @@ -485,10 +511,9 @@ function resolveEffectLayerNode({ const motion = definition.motion?.evaluate({ effectParams: node.params.effectParams, progress: sampleEasingBezier({ - bezier: - BUILTIN_PRESETS.find( - (entry) => entry.id === node.params.effectParams.easing, - )?.value ?? [0.25, 0.1, 0.25, 1], + bezier: BUILTIN_PRESETS.find( + (entry) => entry.id === node.params.effectParams.easing, + )?.value ?? [0.25, 0.1, 0.25, 1], x: clamp({ value: (time - node.params.timeOffset) / node.params.duration, min: 0, diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 431b8269..9df154ef 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -1,4 +1,4 @@ -import type { SceneTracks, TimelineTrack } from "@/timeline"; +import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline"; import type { MediaAsset } from "@/media/types"; import { RootNode } from "./nodes/root-node"; import { VideoNode } from "./nodes/video-node"; @@ -17,6 +17,7 @@ import { readBlendModeFromParams, readOpacityFromParams, } from "@/rendering"; +import { buildTransitionFromParams } from "@/rendering/transition"; const PREVIEW_MAX_IMAGE_SIZE = 2048; @@ -29,6 +30,32 @@ function getVisibleSortedElements({ track }: { track: TimelineTrack }) { return a.id.localeCompare(b.id); }); } +function getTailHoldDuration({ + elements, + elementIndex, +}: { + elements: TimelineElement[]; + elementIndex: number; +}): number { + const element = elements[elementIndex]; + const nextElement = elements[elementIndex + 1]; + if ( + !element || + !nextElement || + (element.type !== "video" && element.type !== "image") || + (nextElement.type !== "video" && nextElement.type !== "image") + ) { + return 0; + } + const gap = nextElement.startTime - (element.startTime + element.duration); + if (gap < 0) { + return 0; + } + const transitionDuration = buildTransitionFromParams({ + params: nextElement.params, + }).inDuration; + return transitionDuration > 0 ? gap + transitionDuration : 0; +} function buildTrackNodes({ tracks, @@ -46,7 +73,11 @@ function buildTrackNodes({ for (const track of tracks) { const elements = getVisibleSortedElements({ track }); - for (const element of elements) { + for (const [elementIndex, element] of elements.entries()) { + const tailHoldDuration = getTailHoldDuration({ + elements, + elementIndex, + }); if (element.type === "effect") { nodes.push( new EffectLayerNode({ @@ -71,7 +102,8 @@ function buildTrackNodes({ mediaId: mediaAsset.id, url: mediaAsset.url, file: mediaAsset.file, - duration: element.duration, + duration: element.duration + tailHoldDuration, + contentDuration: element.duration, timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, @@ -80,6 +112,7 @@ function buildTrackNodes({ animations: element.animations, opacity: readOpacityFromParams({ params: element.params }), blendMode: readBlendModeFromParams({ params: element.params }), + transition: buildTransitionFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], }), @@ -89,7 +122,8 @@ function buildTrackNodes({ nodes.push( new ImageNode({ url: mediaAsset.url, - duration: element.duration, + duration: element.duration + tailHoldDuration, + contentDuration: element.duration, timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, @@ -97,6 +131,7 @@ function buildTrackNodes({ animations: element.animations, opacity: readOpacityFromParams({ params: element.params }), blendMode: readBlendModeFromParams({ params: element.params }), + transition: buildTransitionFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], ...(isPreview && { @@ -114,6 +149,7 @@ function buildTrackNodes({ transform: buildTransformFromParams({ params: element.params }), opacity: readOpacityFromParams({ params: element.params }), blendMode: readBlendModeFromParams({ params: element.params }), + transition: buildTransitionFromParams({ params: element.params }), canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, canvasHeight: canvasSize.height, textBaseline: "middle", @@ -136,6 +172,7 @@ function buildTrackNodes({ animations: element.animations, opacity: readOpacityFromParams({ params: element.params }), blendMode: readBlendModeFromParams({ params: element.params }), + transition: buildTransitionFromParams({ params: element.params }), effects: element.effects ?? [], }), ); @@ -154,6 +191,7 @@ function buildTrackNodes({ animations: element.animations, opacity: readOpacityFromParams({ params: element.params }), blendMode: readBlendModeFromParams({ params: element.params }), + transition: buildTransitionFromParams({ params: element.params }), effects: element.effects ?? [], masks: element.masks ?? [], }), |
