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