1import type { DesktopPreviewRecordingInput } from "@t3tools/contracts";
2
3import { readPreviewAnnotationTheme } from "./annotationTheme";
4
5interface RecordingDecorationOptions {
6 readonly showKeyPresses: boolean;
7 readonly showMousePresses: boolean;
8 readonly frameRate: number;
9}
10
11/** Decorates a detached canvas; no recording UI is inserted into the preview page. */
12export async function createRecordingCompositor(
13 source: MediaStream,
14 options: RecordingDecorationOptions,
15 subscribe: (listener: (input: DesktopPreviewRecordingInput) => void) => () => void,
16) {
17 if (!options.showKeyPresses && !options.showMousePresses) return null;
18 const canvas = document.createElement("canvas");
19 const context = canvas.getContext("2d", { alpha: false });
20 if (!context) throw new Error("Recording canvas is unavailable.");
21 const video = document.createElement("video");
22 video.muted = true;
23 video.playsInline = true;
24 video.srcObject = source;
25 const settings = source.getVideoTracks()[0]?.getSettings();
26 canvas.width = settings?.width ?? 1920;
27 canvas.height = settings?.height ?? 1080;
28 const decorations = new RecordingDecorations(options, readPreviewAnnotationTheme().primary);
29 let disposed = false;
30 let frameId: number | undefined;
31 let timer: number | undefined;
32 const draw = () => {
33 if (disposed || video.readyState < 2) return;
34 const width = video.videoWidth || canvas.width;
35 const height = video.videoHeight || canvas.height;
36 if (canvas.width !== width) canvas.width = width;
37 if (canvas.height !== height) canvas.height = height;
38 context.drawImage(video, 0, 0, width, height);
39 const now = performance.now();
40 decorations.draw(context, width, height, now);
41 window.clearTimeout(timer);
42 const next = decorations.nextRedraw(now);
43 if (next !== null) timer = window.setTimeout(draw, next);
44 };
45 const frame = () => {
46 if (disposed) return;
47 draw();
48 frameId = video.requestVideoFrameCallback(frame);
49 };
50 const output = canvas.captureStream(options.frameRate);
51 let unsubscribe: (() => void) | undefined;
52 const dispose = () => {
53 if (disposed) return;
54 disposed = true;
55 unsubscribe?.();
56 window.clearTimeout(timer);
57 if (frameId !== undefined) video.cancelVideoFrameCallback(frameId);
58 video.pause();
59 video.srcObject = null;
60 for (const track of output.getTracks()) track.stop();
61 };
62 try {
63 unsubscribe = subscribe((input) => {
64 decorations.apply(input, performance.now());
65 draw();
66 });
67 frameId = video.requestVideoFrameCallback(frame);
68 await video.play();
69 draw();
70 return { stream: output, dispose };
71 } catch (error) {
72 dispose();
73 throw error;
74 }
75}
76
77/** Keeps input timing and coordinates independent of native video frame delivery. */
78export class RecordingDecorations {
79 private ring: {
80 x: number;
81 y: number;
82 width: number;
83 height: number;
84 held: boolean;
85 releasedAt: number | null;
86 } | null = null;
87 private key: { label: string; width: number; expiresAt: number | null } | null = null;
88
89 constructor(
90 private readonly options: RecordingDecorationOptions,
91 private readonly primaryColor: string,
92 ) {}
93
94 apply(input: DesktopPreviewRecordingInput, now: number) {
95 if (input.type === "clear") {
96 this.ring = null;
97 this.key = null;
98 } else if (input.type === "key" && this.options.showKeyPresses) {
99 this.key = input.label
100 ? { label: input.label, width: input.width, expiresAt: input.held ? null : now + 900 }
101 : null;
102 } else if (input.type === "pointer" && this.options.showMousePresses) {
103 if (input.phase === "down" || input.phase === "click") {
104 this.ring = {
105 x: input.x,
106 y: input.y,
107 width: input.width,
108 height: input.height,
109 held: input.phase === "down",
110 releasedAt: input.phase === "click" ? now : null,
111 };
112 } else if (this.ring?.held) {
113 this.ring = {
114 ...this.ring,
115 x: input.x,
116 y: input.y,
117 width: input.width,
118 height: input.height,
119 held: input.phase !== "up",
120 releasedAt: input.phase === "up" ? now : null,
121 };
122 }
123 }
124 }
125
126 nextRedraw(now: number): number | null {
127 if (
128 this.ring?.releasedAt !== null &&
129 this.ring?.releasedAt !== undefined &&
130 now < this.ring.releasedAt + 600
131 ) {
132 return 1000 / this.options.frameRate;
133 }
134 return this.key?.expiresAt !== null &&
135 this.key?.expiresAt !== undefined &&
136 now < this.key.expiresAt
137 ? this.key.expiresAt - now
138 : null;
139 }
140
141 draw(context: CanvasRenderingContext2D, width: number, height: number, now: number) {
142 // Guest coordinates are CSS pixels; native frames include zoom and display scale.
143 const scale = width / (this.key?.width ?? this.ring?.width ?? 1280);
144 const ring = this.ring;
145 if (ring && (ring.held || (ring.releasedAt !== null && now < ring.releasedAt + 600))) {
146 const progress = ring.releasedAt === null ? 0 : Math.min(1, (now - ring.releasedAt) / 600);
147 context.save();
148 const opacity = 0.9 * (1 - progress);
149 context.strokeStyle = this.primaryColor;
150 context.fillStyle = this.primaryColor;
151 context.lineWidth = 2 * scale;
152 context.beginPath();
153 context.ellipse(
154 (ring.x * width) / ring.width,
155 (ring.y * height) / ring.height,
156 ((20 * width) / ring.width) * (1 + progress * 0.5),
157 ((20 * height) / ring.height) * (1 + progress * 0.5),
158 0,
159 0,
160 Math.PI * 2,
161 );
162 context.globalAlpha = opacity * 0.15;
163 context.fill();
164 context.globalAlpha = opacity;
165 context.stroke();
166 context.restore();
167 }
168 const key = this.key;
169 if (key && (key.expiresAt === null || now < key.expiresAt)) {
170 context.save();
171 context.font = `500 ${26 * scale}px system-ui, sans-serif`;
172 const badgeWidth = Math.min(
173 width - 32 * scale,
174 context.measureText(key.label).width + 36 * scale,
175 );
176 const badgeHeight = 54 * scale;
177 const left = (width - badgeWidth) / 2;
178 const top = height - 24 * scale - badgeHeight;
179 context.fillStyle = "rgba(32,32,34,.86)";
180 context.beginPath();
181 context.roundRect(left, top, badgeWidth, badgeHeight, 14 * scale);
182 context.fill();
183 context.fillStyle = "white";
184 context.textAlign = "center";
185 context.textBaseline = "middle";
186 context.fillText(key.label, width / 2, top + badgeHeight / 2, badgeWidth - 24 * scale);
187 context.restore();
188 }
189 }
190}