@cesarvenzac/closeup 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -2
  3. package/dist/actions.d.ts +27 -0
  4. package/dist/actions.js +13 -0
  5. package/dist/capture.d.ts +28 -0
  6. package/dist/capture.js +33 -0
  7. package/dist/compositor.d.ts +62 -0
  8. package/dist/compositor.js +164 -0
  9. package/dist/cursor.d.ts +49 -0
  10. package/dist/cursor.js +152 -0
  11. package/dist/cursors.d.ts +11 -0
  12. package/dist/cursors.js +24 -0
  13. package/dist/frame.d.ts +86 -0
  14. package/dist/frame.js +78 -0
  15. package/dist/hover.d.ts +23 -0
  16. package/dist/hover.js +89 -0
  17. package/dist/index.d.ts +24 -0
  18. package/dist/index.js +37 -0
  19. package/dist/path.d.ts +41 -0
  20. package/dist/path.js +65 -0
  21. package/dist/player.d.ts +39 -0
  22. package/dist/player.js +140 -0
  23. package/dist/pointer.d.ts +30 -0
  24. package/dist/pointer.js +134 -0
  25. package/dist/script.d.ts +75 -0
  26. package/dist/script.js +94 -0
  27. package/dist/selector.d.ts +22 -0
  28. package/dist/selector.js +83 -0
  29. package/dist/settings.d.ts +61 -0
  30. package/dist/settings.js +59 -0
  31. package/dist/shortcut.d.ts +23 -0
  32. package/dist/shortcut.js +48 -0
  33. package/dist/sounds.d.ts +22 -0
  34. package/dist/sounds.js +94 -0
  35. package/dist/stage.d.ts +12 -0
  36. package/dist/stage.js +53 -0
  37. package/dist/store.d.ts +4 -0
  38. package/dist/store.js +22 -0
  39. package/dist/take.d.ts +69 -0
  40. package/dist/take.js +220 -0
  41. package/dist/ui/editor.d.ts +90 -0
  42. package/dist/ui/editor.js +612 -0
  43. package/dist/ui/icons.d.ts +28 -0
  44. package/dist/ui/icons.js +28 -0
  45. package/dist/ui/panel.d.ts +83 -0
  46. package/dist/ui/panel.js +739 -0
  47. package/dist/ui/styles.d.ts +6 -0
  48. package/dist/ui/styles.js +183 -0
  49. package/package.json +36 -4
package/dist/take.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * A take, start to end: share this tab, put the picked area on stage, crop the capture to it,
3
+ * draw the cursor (following your mouse, or played from a script), compose the frames on their
4
+ * background, and record them (with the clicks and the tab's sound) or keep one as a photo.
5
+ * Everything is put back afterwards, in order.
6
+ */
7
+ import { cropTo, recrop, shareTab } from "./capture.js";
8
+ import { Compositor } from "./compositor.js";
9
+ import { DrawnCursor } from "./cursor.js";
10
+ import { fillFor, frameAround, SHAPES, stageFor, toScreen } from "./frame.js";
11
+ import { emulateHover } from "./hover.js";
12
+ import { play } from "./player.js";
13
+ import { SyntheticPointer } from "./pointer.js";
14
+ import { TAIL } from "./script.js";
15
+ import { GRADIENTS } from "./settings.js";
16
+ import { Mixer } from "./sounds.js";
17
+ import { enterStage, nextFrame, wait } from "./stage.js";
18
+ export const toRect = ({ x, y, width, height }) => ({ x, y, width, height });
19
+ /** What a take films: an element with room round it, a drawn region as drawn, either one to the chosen shape. */
20
+ export const frameOf = (rect, element, settings) => frameAround(rect, element ? settings.padding : 0, SHAPES[settings.shape]);
21
+ function lookOf(settings, image) {
22
+ let background = { kind: "none" };
23
+ if (settings.background === "color")
24
+ background = { kind: "color", color: settings.color };
25
+ if (settings.background === "gradient")
26
+ background = { kind: "gradient", ...GRADIENTS[settings.gradient] };
27
+ if (settings.background === "image" && image)
28
+ background = { kind: "image", image };
29
+ return { background, inset: settings.inset, radius: settings.radius, shadow: settings.shadow };
30
+ }
31
+ /** The box the capture is cropped to, and a dim veil round it that shows what's filmed (outside the box, so never in it). */
32
+ export function frameBox(screen, host) {
33
+ const box = document.createElement("div");
34
+ Object.assign(box.style, {
35
+ position: "fixed",
36
+ left: `${screen.x}px`,
37
+ top: `${screen.y}px`,
38
+ width: `${screen.width}px`,
39
+ height: `${screen.height}px`,
40
+ pointerEvents: "none",
41
+ zIndex: "2147483640",
42
+ boxShadow: "0 0 0 100vmax rgb(12 12 14 / 0.42)",
43
+ borderRadius: "2px",
44
+ });
45
+ host.append(box);
46
+ return box;
47
+ }
48
+ /** The page's element at a point, Closeup's own layers left out. */
49
+ export const pageHit = ({ host, ignore }) => (x, y) => document.elementsFromPoint(x, y).find((element) => !(ignore(element) || host.contains(element))) ?? null;
50
+ /**
51
+ * What plays a script, set up over the page: a clear sheet that keeps the real mouse off it (so
52
+ * nothing under it hovers) with the real cursor hidden, the page's hover rules copied for the
53
+ * drawn cursor, the pointer the page is told about, and the cursor. `dispose()` takes it all away,
54
+ * telling the page the pointer left so what it hovers lets go.
55
+ */
56
+ export function createActor(layers, { size, onPress, ring }) {
57
+ const shield = document.createElement("div");
58
+ Object.assign(shield.style, { position: "fixed", inset: "0", zIndex: "2147483641", pointerEvents: "auto" });
59
+ layers.host.append(shield);
60
+ const hover = emulateHover();
61
+ const hit = pageHit(layers);
62
+ const pointer = new SyntheticPointer(hit);
63
+ const cursor = new DrawnCursor(layers.host, {
64
+ size,
65
+ onPress,
66
+ ring,
67
+ hit,
68
+ // Looked for through the sheet, which would otherwise be all there is at any point.
69
+ caretAt: (x, y) => {
70
+ shield.style.pointerEvents = "none";
71
+ try {
72
+ return document.caretRangeFromPoint(x, y);
73
+ }
74
+ finally {
75
+ shield.style.pointerEvents = "auto";
76
+ }
77
+ },
78
+ });
79
+ cursor.show();
80
+ return {
81
+ cursor,
82
+ pointer,
83
+ dispose() {
84
+ pointer.leave();
85
+ cursor.destroy();
86
+ hover.remove();
87
+ shield.remove();
88
+ },
89
+ };
90
+ }
91
+ async function setUp(picked, settings, layers, image, mixer, script) {
92
+ const { host, ignore } = layers;
93
+ const undo = [];
94
+ const hit = pageHit(layers);
95
+ const onPress = mixer && settings.clicks ? (down) => mixer.click(down) : undefined;
96
+ let actor = null;
97
+ const unwind = async () => {
98
+ for (const step of undo.reverse())
99
+ await step();
100
+ };
101
+ try {
102
+ const { stream, video, audio } = await shareTab({ audio: settings.mode === "video" && settings.tabAudio });
103
+ undo.push(() => stream.getTracks().forEach((track) => track.stop()));
104
+ // Measured now: the page may have moved since the pick.
105
+ const rect = picked.element?.isConnected ? toRect(picked.element.getBoundingClientRect()) : picked.rect;
106
+ const frame = frameOf(rect, picked.element, settings);
107
+ const viewport = { width: innerWidth, height: innerHeight };
108
+ const stage = stageFor(frame, viewport, fillFor(viewport), { x: scrollX, y: scrollY });
109
+ const leave = await enterStage(stage);
110
+ undo.push(leave);
111
+ const box = frameBox(stage.screen, host);
112
+ undo.push(() => box.remove());
113
+ await cropTo(video, box);
114
+ if (script) {
115
+ actor = createActor(layers, { size: settings.cursorSize, onPress, ring: settings.ring });
116
+ undo.push(() => actor?.dispose());
117
+ }
118
+ else if (settings.cursor) {
119
+ const cursor = new DrawnCursor(host, { size: settings.cursorSize, onPress, ring: settings.ring, ignore, hit });
120
+ cursor.follow();
121
+ undo.push(() => cursor.destroy());
122
+ }
123
+ const compositor = new Compositor(video, lookOf(settings, image), host);
124
+ // Until the first picture: a single pixel in the frame's corner (where the rounded corners
125
+ // clip it) flickering between 1% and 2% white, so the tab has something to send.
126
+ const pixel = document.createElement("div");
127
+ Object.assign(pixel.style, { position: "absolute", left: "0", top: "0", width: "1px", height: "1px" });
128
+ box.append(pixel);
129
+ let lit = false;
130
+ const composed = await compositor
131
+ .start({
132
+ nudge: () => {
133
+ lit = !lit;
134
+ pixel.style.backgroundColor = `rgb(255 255 255 / ${lit ? 0.02 : 0.01})`;
135
+ },
136
+ wake: () => recrop(video, box),
137
+ })
138
+ .finally(() => pixel.remove());
139
+ undo.push(() => compositor.stop());
140
+ // Unwinding stops sharing first, before the zoom back out.
141
+ undo.push(() => stream.getTracks().forEach((track) => track.stop()));
142
+ if (mixer && audio)
143
+ mixer.add(audio);
144
+ return { stage, compositor, composed, unwind, actor };
145
+ }
146
+ catch (error) {
147
+ await unwind();
148
+ throw error;
149
+ }
150
+ }
151
+ /** A photo of the picked area, on its background, at the stage's resolution. */
152
+ export async function takePhoto(picked, settings, layers, image) {
153
+ const { compositor, unwind } = await setUp(picked, settings, layers, image, null, null);
154
+ try {
155
+ // A moment for the last redraw at the stage's scale to reach the capture.
156
+ await wait(120);
157
+ await nextFrame();
158
+ const blob = await compositor.snapshot();
159
+ return { kind: "photo", blob, type: "image/png", ...compositor.size };
160
+ }
161
+ finally {
162
+ await unwind();
163
+ }
164
+ }
165
+ const TYPES = ["video/mp4;codecs=avc1.640033", "video/mp4;codecs=avc1", "video/mp4", "video/webm;codecs=vp9", "video/webm"];
166
+ /**
167
+ * Starts recording the picked area, the cursor following your mouse or, given a `script`,
168
+ * playing it; `stop()` ends the take and puts the page back.
169
+ */
170
+ export async function startVideo(picked, settings, layers, image, script = null) {
171
+ // Made in the click that started the take: audio started later can be refused.
172
+ const mixer = settings.clicks || settings.tabAudio ? new Mixer() : null;
173
+ let setup;
174
+ try {
175
+ setup = await setUp(picked, settings, layers, image, mixer, script);
176
+ }
177
+ catch (error) {
178
+ mixer?.close();
179
+ throw error;
180
+ }
181
+ const { stage, compositor, composed, unwind, actor } = setup;
182
+ const tracks = [composed, ...(mixer ? [mixer.track] : [])];
183
+ const type = TYPES.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "";
184
+ const recorder = new MediaRecorder(new MediaStream(tracks), {
185
+ mimeType: type,
186
+ videoBitsPerSecond: settings.quality === "high" ? 40_000_000 : 12_000_000,
187
+ });
188
+ const chunks = [];
189
+ recorder.ondataavailable = (event) => event.data.size && chunks.push(event.data);
190
+ recorder.start(500);
191
+ const started = performance.now();
192
+ const playing = new AbortController();
193
+ const finished = actor && script
194
+ ? play(script, {
195
+ ...actor,
196
+ toScreen: (p) => toScreen(stage, { x: scrollX, y: scrollY }, p),
197
+ scale: stage.scale,
198
+ onKey: mixer && settings.clicks ? () => mixer.key() : undefined,
199
+ signal: playing.signal,
200
+ }).then(() => wait(TAIL * 1000))
201
+ : undefined;
202
+ return {
203
+ screen: stage.screen,
204
+ started,
205
+ finished,
206
+ async stop() {
207
+ playing.abort();
208
+ const seconds = (performance.now() - started) / 1000;
209
+ await new Promise((resolve) => {
210
+ recorder.onstop = () => resolve();
211
+ recorder.stop();
212
+ });
213
+ const size = compositor.size;
214
+ await unwind();
215
+ mixer?.close();
216
+ const blobType = type.split(";")[0] || "video/webm";
217
+ return { kind: "video", blob: new Blob(chunks, { type: blobType }), type: blobType, ...size, seconds, ...(script ? { script } : {}) };
218
+ },
219
+ };
220
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The script editor, on the stage at the size the take is filmed: click where the cursor
3
+ * starts and where it goes, Figma's pen way (a click for a gentle arc, a drag to pull the curve's
4
+ * handles), drag points and handles to adjust them, and say what each point does. Points are
5
+ * tied to the element they're placed on (`selector.ts`), so they follow it if the layout moves.
6
+ * The curves drawn are the ones the player travels (`curve` in `player.ts`). Every change can be
7
+ * undone, and each is handed back to be saved.
8
+ */
9
+ import { type Stage } from "../frame.ts";
10
+ import { type Script } from "../script.ts";
11
+ export type EditorOptions = {
12
+ root: ShadowRoot;
13
+ stage: Stage;
14
+ /** The page's element at a point, Closeup's own layers left out. */
15
+ hit: (x: number, y: number) => Element | null;
16
+ script: Script | null;
17
+ onChange: (script: Script | null) => void;
18
+ onPreview: (script: Script) => void;
19
+ onDone: () => void;
20
+ };
21
+ export declare class Editor {
22
+ private readonly options;
23
+ private readonly element;
24
+ private readonly svg;
25
+ private readonly labels;
26
+ private readonly info;
27
+ private readonly panel;
28
+ private readonly bar;
29
+ private readonly file;
30
+ private script;
31
+ private selected;
32
+ private tool;
33
+ private drag;
34
+ private aimed;
35
+ private history;
36
+ private future;
37
+ private message;
38
+ private frame;
39
+ constructor(options: EditorOptions);
40
+ destroy(): void;
41
+ /** Hidden while a preview plays, and shown again after. */
42
+ set hidden(hidden: boolean);
43
+ /** Keys while editing; true when the editor used it. */
44
+ onKey(event: KeyboardEvent): boolean;
45
+ private get count();
46
+ private targetOf;
47
+ private setTarget;
48
+ private outOf;
49
+ private inOf;
50
+ private setHandle;
51
+ private screen;
52
+ /** Where point `k` is on screen now: on its element, as the player will find it. */
53
+ private place;
54
+ /** A target for a point on screen: where it is in the page, and the element it's on. */
55
+ private targetAt;
56
+ /** The element a target follows, if it's still there. */
57
+ private elementOf;
58
+ private change;
59
+ private commit;
60
+ private restore;
61
+ private undo;
62
+ private redo;
63
+ /** Adds a point at a spot on screen, after the selected one or at the end, doing what the tool says. */
64
+ private add;
65
+ /** Removes point `k`; without the start, the first point becomes where the cursor starts. */
66
+ private remove;
67
+ private select;
68
+ private point;
69
+ private onDown;
70
+ private onMove;
71
+ private onUp;
72
+ /** A double click on a point turns its curve from handles to a gentle arc, or from an arc to smooth handles. */
73
+ private onDouble;
74
+ /** Outlines what a point placed here would follow. */
75
+ private aim;
76
+ /** Redraws the curves and the summary on the next frame; the point's panel stays as it is, so its field keeps focus. */
77
+ private schedule;
78
+ private onClick;
79
+ /** Sliders and the text field change the selected point as they move; the change is kept once. */
80
+ private pending;
81
+ private onInput;
82
+ private settle;
83
+ private onFile;
84
+ private export;
85
+ private render;
86
+ private renderPaths;
87
+ private renderInfo;
88
+ private renderPanel;
89
+ private renderBar;
90
+ }