summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services/renderer')
-rw-r--r--apps/web/src/services/renderer/__tests__/scene-builder-transition.test.ts99
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts2
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts3
-rw-r--r--apps/web/src/services/renderer/resolve.ts67
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts46
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 ?? [],
}),