@alpha2-sh/editor 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 (99) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +108 -2
  3. package/dist/browser/bridge.d.ts +44 -0
  4. package/dist/browser/bridge.js +2176 -0
  5. package/dist/browser/extensions-worker-entry.d.ts +1 -0
  6. package/dist/browser/extensions-worker-entry.js +3 -0
  7. package/dist/browser/extensions-worker.d.ts +46 -0
  8. package/dist/browser/extensions-worker.js +149 -0
  9. package/dist/browser/hash-worker-entry.d.ts +1 -0
  10. package/dist/browser/hash-worker-entry.js +3 -0
  11. package/dist/browser/hash-worker.d.ts +24 -0
  12. package/dist/browser/hash-worker.js +87 -0
  13. package/dist/browser/index.d.ts +11 -0
  14. package/dist/browser/index.js +9 -0
  15. package/dist/browser/jobs.d.ts +82 -0
  16. package/dist/browser/jobs.js +291 -0
  17. package/dist/browser/resources.d.ts +40 -0
  18. package/dist/browser/resources.js +83 -0
  19. package/dist/browser/runtime/assets.d.ts +233 -0
  20. package/dist/browser/runtime/assets.js +633 -0
  21. package/dist/browser/runtime/audio.d.ts +60 -0
  22. package/dist/browser/runtime/audio.js +549 -0
  23. package/dist/browser/runtime/budget.d.ts +11 -0
  24. package/dist/browser/runtime/budget.js +26 -0
  25. package/dist/browser/runtime/caption-fonts.d.ts +34 -0
  26. package/dist/browser/runtime/caption-fonts.js +69 -0
  27. package/dist/browser/runtime/color.d.ts +7 -0
  28. package/dist/browser/runtime/color.js +12 -0
  29. package/dist/browser/runtime/engine.d.ts +91 -0
  30. package/dist/browser/runtime/engine.js +352 -0
  31. package/dist/browser/runtime/export-audio.d.ts +23 -0
  32. package/dist/browser/runtime/export-audio.js +75 -0
  33. package/dist/browser/runtime/exporter.d.ts +140 -0
  34. package/dist/browser/runtime/exporter.js +751 -0
  35. package/dist/browser/runtime/extension-import.d.ts +196 -0
  36. package/dist/browser/runtime/extension-import.js +521 -0
  37. package/dist/browser/runtime/fingerprint.d.ts +57 -0
  38. package/dist/browser/runtime/fingerprint.js +157 -0
  39. package/dist/browser/runtime/generators.d.ts +188 -0
  40. package/dist/browser/runtime/generators.js +173 -0
  41. package/dist/browser/runtime/locks.d.ts +32 -0
  42. package/dist/browser/runtime/locks.js +86 -0
  43. package/dist/browser/runtime/output.d.ts +87 -0
  44. package/dist/browser/runtime/output.js +230 -0
  45. package/dist/browser/runtime/preview-utils.d.ts +3 -0
  46. package/dist/browser/runtime/preview-utils.js +49 -0
  47. package/dist/browser/runtime/proxies.d.ts +72 -0
  48. package/dist/browser/runtime/proxies.js +369 -0
  49. package/dist/browser/runtime/proxy-storage.d.ts +59 -0
  50. package/dist/browser/runtime/proxy-storage.js +119 -0
  51. package/dist/browser/runtime/sources.d.ts +82 -0
  52. package/dist/browser/runtime/sources.js +819 -0
  53. package/dist/browser/runtime/staging.d.ts +10 -0
  54. package/dist/browser/runtime/staging.js +32 -0
  55. package/dist/browser/runtime/still.d.ts +21 -0
  56. package/dist/browser/runtime/still.js +52 -0
  57. package/dist/browser/runtime/wasm.d.ts +9 -0
  58. package/dist/browser/runtime/wasm.js +20 -0
  59. package/dist/browser/types.d.ts +549 -0
  60. package/dist/browser/types.js +1 -0
  61. package/dist/browser/worker-entry.d.ts +1 -0
  62. package/dist/browser/worker-entry.js +2 -0
  63. package/dist/browser/worker.d.ts +24 -0
  64. package/dist/browser/worker.js +239 -0
  65. package/dist/core/captions.d.ts +34 -0
  66. package/dist/core/captions.js +108 -0
  67. package/dist/core/clip-time.d.ts +31 -0
  68. package/dist/core/clip-time.js +125 -0
  69. package/dist/core/commands.d.ts +413 -0
  70. package/dist/core/commands.js +1118 -0
  71. package/dist/core/compositions.d.ts +234 -0
  72. package/dist/core/compositions.js +851 -0
  73. package/dist/core/document.d.ts +245 -0
  74. package/dist/core/document.js +683 -0
  75. package/dist/core/keyframes.d.ts +82 -0
  76. package/dist/core/keyframes.js +153 -0
  77. package/dist/core/presets.d.ts +64 -0
  78. package/dist/core/presets.js +241 -0
  79. package/dist/core/session.d.ts +150 -0
  80. package/dist/core/session.js +541 -0
  81. package/dist/core/text.d.ts +17 -0
  82. package/dist/core/text.js +17 -0
  83. package/dist/core/time.d.ts +19 -0
  84. package/dist/core/time.js +39 -0
  85. package/dist/core/timeline-index.d.ts +44 -0
  86. package/dist/core/timeline-index.js +59 -0
  87. package/dist/core/transitions.d.ts +20 -0
  88. package/dist/core/transitions.js +118 -0
  89. package/dist/core/validated.d.ts +23 -0
  90. package/dist/core/validated.js +52 -0
  91. package/dist/index.d.ts +12 -0
  92. package/dist/index.js +11 -0
  93. package/dist/native-client/index.d.ts +131 -0
  94. package/dist/native-client/index.js +354 -0
  95. package/dist/ports/index.d.ts +146 -0
  96. package/dist/ports/index.js +1 -0
  97. package/dist/react/index.d.ts +37 -0
  98. package/dist/react/index.js +94 -0
  99. package/package.json +79 -4
@@ -0,0 +1,157 @@
1
+ // Evidence of an imported file's media, recorded in the project so any host can tell later whether
2
+ // media it finds is the same: the bytes' hash, their length and date, and what the demuxer sees.
3
+ // Whether two fingerprints match is the engine's decision (`alpha2/assets`), never made here.
4
+ import { sha256Blob } from "alpha2/assets";
5
+ import { colorMetadataFromVideoColorSpace, sampleAspectForStream, } from "alpha2/hdr-frames";
6
+ import { TICKS_PER_SECOND, validFrameRate } from "alpha2/project";
7
+ import { ALL_FORMATS, BlobSource, EncodedPacketSink, Input } from "mediabunny";
8
+ /**
9
+ * The fingerprint of `file` as media of `kind`. `size` is an image's decoded size; video and audio
10
+ * are probed. `hash` makes the content hash, `sha256Blob` on this thread unless the caller hashes
11
+ * elsewhere (`hashInWorker`).
12
+ * @param {File} file
13
+ * @param {string} kind
14
+ * @param {{ signal?: AbortSignal, onProgress?: (bytes: number) => void, size?: { width: number, height: number }, hash?: (file: Blob, options: { signal?: AbortSignal, onProgress?: (bytes: number) => void }) => Promise<string> }} [options]
15
+ * @returns {Promise<import("alpha2/project").MediaFingerprint>}
16
+ */
17
+ export async function fingerprintFile(file, kind, options = {}) {
18
+ const hash = options.hash ?? sha256Blob;
19
+ const fingerprint = {
20
+ content_sha256: await hash(file, {
21
+ signal: options.signal,
22
+ onProgress: options.onProgress,
23
+ }),
24
+ byte_length: file.size,
25
+ modified_ms: file.lastModified,
26
+ };
27
+ if (kind === "image" && options.size)
28
+ return {
29
+ ...fingerprint,
30
+ streams: {
31
+ video: { width: options.size.width, height: options.size.height },
32
+ },
33
+ };
34
+ if (kind !== "video" && kind !== "audio")
35
+ return fingerprint;
36
+ return { ...fingerprint, ...(await probeEvidence(file)) };
37
+ }
38
+ /** What the demuxer sees: the primary streams' shapes, the duration, and a declared color. */
39
+ export async function probeEvidence(file) {
40
+ const input = new Input({
41
+ source: new BlobSource(file),
42
+ formats: ALL_FORMATS,
43
+ });
44
+ try {
45
+ const video = await input.getPrimaryVideoTrack();
46
+ const audio = await input.getPrimaryAudioTrack();
47
+ const streams = {};
48
+ let color;
49
+ if (video) {
50
+ const rate = rationalRate(await presentedRate(video).catch(() => null));
51
+ const shown = await displaySize(video);
52
+ streams.video = {
53
+ width: shown.width,
54
+ height: shown.height,
55
+ ...(rate ? { frame_rate: rate } : {}),
56
+ };
57
+ const space = await video.getColorSpace().catch(() => null);
58
+ if (space &&
59
+ (space.primaries || space.transfer || space.matrix || space.fullRange))
60
+ color = colorMetadataFromVideoColorSpace(space);
61
+ }
62
+ if (audio)
63
+ streams.audio = {
64
+ channels: audio.numberOfChannels,
65
+ sample_rate: audio.sampleRate,
66
+ };
67
+ // The latest end of any track: a stream starting at 1.4 s and lasting 2 s ends at 3.4 s.
68
+ const seconds = await input.computeDuration().catch(() => null);
69
+ return {
70
+ ...(video || audio ? { streams } : {}),
71
+ ...(Number.isFinite(seconds)
72
+ ? { duration_ticks: Math.round(seconds * TICKS_PER_SECOND) }
73
+ : {}),
74
+ ...(color ? { color } : {}),
75
+ };
76
+ }
77
+ finally {
78
+ input.dispose();
79
+ }
80
+ }
81
+ /**
82
+ * The size a video track is shown at, in square pixels and turned as it is shown, as every host
83
+ * finds it: the sample aspect ratio is the container's when it declares one that is not square,
84
+ * else the one the H.264 or HEVC parameter sets signal (FFmpeg's `av_guess_sample_aspect_ratio`);
85
+ * mediabunny reads only the container's. The stretch is the engine's
86
+ * (`SampleAspectRatio::square_pixel_size`): the longer side of a sample grows, rounded to the
87
+ * nearest pixel.
88
+ * @param {import("mediabunny").InputVideoTrack} track
89
+ * @returns {Promise<{ width: number, height: number }>}
90
+ */
91
+ export async function displaySize(track) {
92
+ const declared = await track.getPixelAspectRatio().catch(() => null);
93
+ let aspect = declared && declared.num !== declared.den ? declared : undefined;
94
+ if (!aspect) {
95
+ const config = await track.getDecoderConfig().catch(() => null);
96
+ const description = config?.description ?? undefined;
97
+ const first = description === undefined
98
+ ? await new EncodedPacketSink(track).getFirstPacket().catch(() => null)
99
+ : null;
100
+ aspect = sampleAspectForStream(config?.codec ?? track.codec, description, first?.data);
101
+ }
102
+ let width = track.codedWidth;
103
+ let height = track.codedHeight;
104
+ if (aspect && aspect.num > aspect.den)
105
+ width = Math.floor((width * aspect.num * 2 + aspect.den) / (2 * aspect.den));
106
+ else if (aspect && aspect.num < aspect.den)
107
+ height = Math.floor((height * aspect.den * 2 + aspect.num) / (2 * aspect.num));
108
+ return track.rotation % 180 === 0
109
+ ? { width, height }
110
+ : { width: height, height: width };
111
+ }
112
+ /** How many frames the rate is measured over, at least: about four seconds at 30 fps. */
113
+ const RATE_FRAMES = 120;
114
+ /**
115
+ * The rate `track` presents its first frames at, from packet times alone (durations differ between
116
+ * demuxers); past {@link RATE_FRAMES}, packets run up to the first presented after every one
117
+ * taken, so reordered frames stay in the span. `null` with fewer than two frames or no span. Twin
118
+ * of the engine's `alpha2_assets::FrameRateEvidence` (held equal by
119
+ * `tests/cases/media-evidence-corpus.json`); a declared rate is no common ground across hosts.
120
+ * @param {import("mediabunny").InputVideoTrack} track
121
+ * @returns {Promise<number | null>}
122
+ */
123
+ async function presentedRate(track) {
124
+ let count = 0;
125
+ let first = Infinity;
126
+ let last = -Infinity;
127
+ for await (const packet of new EncodedPacketSink(track).packets(undefined, undefined, { metadataOnly: true })) {
128
+ const time = packet.timestamp;
129
+ if (!Number.isFinite(time))
130
+ continue;
131
+ if (count >= RATE_FRAMES && time > last)
132
+ break;
133
+ first = Math.min(first, time);
134
+ last = Math.max(last, time);
135
+ count += 1;
136
+ }
137
+ return count >= 2 && last > first ? (count - 1) / (last - first) : null;
138
+ }
139
+ /**
140
+ * A measured frame rate as the rational a stream declares: the NTSC rates and whole rates exactly,
141
+ * others to a thousandth (the engine's `alpha2_assets::rational_rate` is its twin). `null` when the
142
+ * measure is no rate a project holds (so slow it rounds to no frames, or a frame shorter than a
143
+ * tick, which only broken timestamps give): the fingerprint then records no rate rather than one
144
+ * the engine refuses.
145
+ */
146
+ export function rationalRate(rate) {
147
+ if (!Number.isFinite(rate) || rate <= 0)
148
+ return null;
149
+ for (const base of [24, 30, 48, 60, 120]) {
150
+ if (Math.abs(rate - (base * 1000) / 1001) < 0.005)
151
+ return { num: base * 1000, den: 1001 };
152
+ }
153
+ const rational = Math.abs(rate - Math.round(rate)) < 0.005
154
+ ? { num: Math.round(rate), den: 1 }
155
+ : { num: Math.round(rate * 1000), den: 1000 };
156
+ return validFrameRate(rational) ? rational : null;
157
+ }
@@ -0,0 +1,188 @@
1
+ export function defaultGenerator(kind?: string): {
2
+ kind: string;
3
+ colors: string[];
4
+ angle: number;
5
+ radius: number;
6
+ size: {
7
+ x: number;
8
+ y: number;
9
+ };
10
+ cornerRadius: number;
11
+ rotation: number;
12
+ fill: boolean;
13
+ stroke: {
14
+ enabled: boolean;
15
+ color: string;
16
+ width: number;
17
+ };
18
+ };
19
+ /** The `alpha2_model::Generator` for an editor description. */
20
+ export function encodeGenerator(generator: any): {
21
+ type: string;
22
+ paint: {
23
+ type: string;
24
+ color: {
25
+ red: number;
26
+ green: number;
27
+ blue: number;
28
+ alpha: number;
29
+ };
30
+ start?: undefined;
31
+ end?: undefined;
32
+ stops?: undefined;
33
+ center?: undefined;
34
+ radius?: undefined;
35
+ angle_degrees?: undefined;
36
+ };
37
+ shape?: undefined;
38
+ center?: undefined;
39
+ size?: undefined;
40
+ rotation_degrees?: undefined;
41
+ fill?: undefined;
42
+ stroke?: undefined;
43
+ } | {
44
+ type: string;
45
+ paint: {
46
+ type: string;
47
+ start: {
48
+ x: number;
49
+ y: number;
50
+ };
51
+ end: {
52
+ x: number;
53
+ y: number;
54
+ };
55
+ stops: {
56
+ offset: number;
57
+ color: {
58
+ red: number;
59
+ green: number;
60
+ blue: number;
61
+ alpha: number;
62
+ };
63
+ }[];
64
+ color?: undefined;
65
+ center?: undefined;
66
+ radius?: undefined;
67
+ angle_degrees?: undefined;
68
+ };
69
+ shape?: undefined;
70
+ center?: undefined;
71
+ size?: undefined;
72
+ rotation_degrees?: undefined;
73
+ fill?: undefined;
74
+ stroke?: undefined;
75
+ } | {
76
+ type: string;
77
+ paint: {
78
+ type: string;
79
+ center: {
80
+ x: number;
81
+ y: number;
82
+ };
83
+ radius: number;
84
+ stops: {
85
+ offset: number;
86
+ color: {
87
+ red: number;
88
+ green: number;
89
+ blue: number;
90
+ alpha: number;
91
+ };
92
+ }[];
93
+ color?: undefined;
94
+ start?: undefined;
95
+ end?: undefined;
96
+ angle_degrees?: undefined;
97
+ };
98
+ shape?: undefined;
99
+ center?: undefined;
100
+ size?: undefined;
101
+ rotation_degrees?: undefined;
102
+ fill?: undefined;
103
+ stroke?: undefined;
104
+ } | {
105
+ type: string;
106
+ paint: {
107
+ type: string;
108
+ center: {
109
+ x: number;
110
+ y: number;
111
+ };
112
+ angle_degrees: any;
113
+ stops: {
114
+ offset: number;
115
+ color: {
116
+ red: number;
117
+ green: number;
118
+ blue: number;
119
+ alpha: number;
120
+ };
121
+ }[];
122
+ color?: undefined;
123
+ start?: undefined;
124
+ end?: undefined;
125
+ radius?: undefined;
126
+ };
127
+ shape?: undefined;
128
+ center?: undefined;
129
+ size?: undefined;
130
+ rotation_degrees?: undefined;
131
+ fill?: undefined;
132
+ stroke?: undefined;
133
+ } | {
134
+ type: string;
135
+ shape: {
136
+ type: string;
137
+ corner_radius: number;
138
+ } | {
139
+ type: string;
140
+ corner_radius?: undefined;
141
+ };
142
+ center: {
143
+ x: number;
144
+ y: number;
145
+ };
146
+ size: {
147
+ x: number;
148
+ y: number;
149
+ };
150
+ rotation_degrees: any;
151
+ fill: {
152
+ type: string;
153
+ color: {
154
+ red: number;
155
+ green: number;
156
+ blue: number;
157
+ alpha: number;
158
+ };
159
+ } | null;
160
+ stroke: {
161
+ width: number;
162
+ paint: {
163
+ type: string;
164
+ color: {
165
+ red: number;
166
+ green: number;
167
+ blue: number;
168
+ alpha: number;
169
+ };
170
+ };
171
+ } | null;
172
+ paint?: undefined;
173
+ };
174
+ /**
175
+ * Draws an approximation of the generator for a library thumbnail. Canvas gradients interpolate in
176
+ * sRGB where the engine uses linear light, so the middle of a gradient is a little darker here than
177
+ * in the preview.
178
+ */
179
+ export function drawGenerator(context: any, generator: any, width: any, height: any): void;
180
+ /** Checks a generator read from a project file. */
181
+ export function isGenerator(value: any): boolean;
182
+ export const GENERATOR_KINDS: {
183
+ value: string;
184
+ label: string;
185
+ }[];
186
+ export function isShape(kind: any): boolean;
187
+ export function isGradient(kind: any): boolean;
188
+ export function generatorLabel(kind: any): string;
@@ -0,0 +1,173 @@
1
+ // Generated sources: pictures the engine draws itself (`kind: "generated"`). Positions are
2
+ // normalized ((0, 0) top-left, (1, 1) bottom-right); radii and strokes are fractions of the shorter
3
+ // side.
4
+ import { rgba } from "./color.js";
5
+ export const GENERATOR_KINDS = [
6
+ { value: "solid", label: "Solid color" },
7
+ { value: "linear", label: "Linear gradient" },
8
+ { value: "radial", label: "Radial gradient" },
9
+ { value: "conic", label: "Conic gradient" },
10
+ { value: "rectangle", label: "Rectangle" },
11
+ { value: "ellipse", label: "Ellipse" },
12
+ ];
13
+ export const isShape = (kind) => kind === "rectangle" || kind === "ellipse";
14
+ export const isGradient = (kind) => kind === "linear" || kind === "radial" || kind === "conic";
15
+ export const generatorLabel = (kind) => GENERATOR_KINDS.find((item) => item.value === kind)?.label ?? "Generated";
16
+ export function defaultGenerator(kind = "linear") {
17
+ return {
18
+ kind,
19
+ colors: ["#6d78ff", "#12b5a4"],
20
+ // Degrees clockwise from up, as CSS gradients measure them.
21
+ angle: 135,
22
+ radius: 0.9,
23
+ size: { x: 0.6, y: 0.35 },
24
+ cornerRadius: 0.04,
25
+ rotation: 0,
26
+ fill: true,
27
+ stroke: { enabled: false, color: "#ffffff", width: 0.012 },
28
+ };
29
+ }
30
+ /** The `alpha2_model::Generator` for an editor description. */
31
+ export function encodeGenerator(generator) {
32
+ const [first, second] = generator.colors;
33
+ const stops = [
34
+ { offset: 0, color: rgba(first) },
35
+ { offset: 1, color: rgba(second) },
36
+ ];
37
+ const center = { x: 0.5, y: 0.5 };
38
+ switch (generator.kind) {
39
+ case "solid":
40
+ return { type: "fill", paint: { type: "solid", color: rgba(first) } };
41
+ case "linear": {
42
+ const radians = (generator.angle * Math.PI) / 180;
43
+ const dx = 0.5 * Math.sin(radians);
44
+ const dy = -0.5 * Math.cos(radians);
45
+ return {
46
+ type: "fill",
47
+ paint: {
48
+ type: "linear_gradient",
49
+ start: { x: 0.5 - dx, y: 0.5 - dy },
50
+ end: { x: 0.5 + dx, y: 0.5 + dy },
51
+ stops,
52
+ },
53
+ };
54
+ }
55
+ case "radial":
56
+ return {
57
+ type: "fill",
58
+ paint: {
59
+ type: "radial_gradient",
60
+ center,
61
+ radius: Math.max(0.01, generator.radius),
62
+ stops,
63
+ },
64
+ };
65
+ case "conic":
66
+ return {
67
+ type: "fill",
68
+ paint: {
69
+ type: "conic_gradient",
70
+ center,
71
+ angle_degrees: generator.angle,
72
+ stops,
73
+ },
74
+ };
75
+ default: {
76
+ const stroked = generator.stroke.enabled;
77
+ const filled = generator.fill || !stroked;
78
+ return {
79
+ type: "shape",
80
+ shape: generator.kind === "rectangle"
81
+ ? {
82
+ type: "rectangle",
83
+ corner_radius: Math.max(0, generator.cornerRadius),
84
+ }
85
+ : { type: "ellipse" },
86
+ center,
87
+ size: {
88
+ x: Math.max(0, generator.size.x),
89
+ y: Math.max(0, generator.size.y),
90
+ },
91
+ rotation_degrees: generator.rotation,
92
+ fill: filled ? { type: "solid", color: rgba(first) } : null,
93
+ stroke: stroked
94
+ ? {
95
+ width: Math.max(0.001, generator.stroke.width),
96
+ paint: { type: "solid", color: rgba(generator.stroke.color) },
97
+ }
98
+ : null,
99
+ };
100
+ }
101
+ }
102
+ }
103
+ /**
104
+ * Draws an approximation of the generator for a library thumbnail. Canvas gradients interpolate in
105
+ * sRGB where the engine uses linear light, so the middle of a gradient is a little darker here than
106
+ * in the preview.
107
+ */
108
+ export function drawGenerator(context, generator, width, height) {
109
+ const [first, second] = generator.colors;
110
+ const short = Math.min(width, height);
111
+ const gradient = (paint) => {
112
+ paint.addColorStop(0, first);
113
+ paint.addColorStop(1, second);
114
+ return paint;
115
+ };
116
+ context.clearRect(0, 0, width, height);
117
+ switch (generator.kind) {
118
+ case "solid":
119
+ context.fillStyle = first;
120
+ context.fillRect(0, 0, width, height);
121
+ return;
122
+ case "linear": {
123
+ const radians = (generator.angle * Math.PI) / 180;
124
+ const dx = 0.5 * Math.sin(radians) * width;
125
+ const dy = -0.5 * Math.cos(radians) * height;
126
+ context.fillStyle = gradient(context.createLinearGradient(width / 2 - dx, height / 2 - dy, width / 2 + dx, height / 2 + dy));
127
+ context.fillRect(0, 0, width, height);
128
+ return;
129
+ }
130
+ case "radial":
131
+ context.fillStyle = gradient(context.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, generator.radius * short));
132
+ context.fillRect(0, 0, width, height);
133
+ return;
134
+ case "conic":
135
+ // Canvas measures from the +x axis; the engine, like CSS, from up.
136
+ context.fillStyle = gradient(context.createConicGradient(((generator.angle - 90) * Math.PI) / 180, width / 2, height / 2));
137
+ context.fillRect(0, 0, width, height);
138
+ return;
139
+ default: {
140
+ const w = generator.size.x * width;
141
+ const h = generator.size.y * height;
142
+ context.save();
143
+ context.translate(width / 2, height / 2);
144
+ context.rotate((generator.rotation * Math.PI) / 180);
145
+ context.beginPath();
146
+ if (generator.kind === "rectangle")
147
+ context.roundRect(-w / 2, -h / 2, w, h, Math.min(generator.cornerRadius * short, w / 2, h / 2));
148
+ else
149
+ context.ellipse(0, 0, w / 2, h / 2, 0, 0, Math.PI * 2);
150
+ if (generator.fill || !generator.stroke.enabled) {
151
+ context.fillStyle = first;
152
+ context.fill();
153
+ }
154
+ if (generator.stroke.enabled) {
155
+ context.strokeStyle = generator.stroke.color;
156
+ context.lineWidth = Math.max(1, generator.stroke.width * short);
157
+ context.stroke();
158
+ }
159
+ context.restore();
160
+ }
161
+ }
162
+ }
163
+ /** Checks a generator read from a project file. */
164
+ export function isGenerator(value) {
165
+ return (value !== null &&
166
+ typeof value === "object" &&
167
+ GENERATOR_KINDS.some((item) => item.value === value.kind) &&
168
+ Array.isArray(value.colors) &&
169
+ value.colors.length === 2 &&
170
+ [value.angle, value.radius, value.cornerRadius, value.rotation].every(Number.isFinite) &&
171
+ typeof value.size === "object" &&
172
+ typeof value.stroke === "object");
173
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Holds the shared lock `name` until `release()`. Resolves once held; where the browser has no Web
3
+ * Locks, or refuses the lock, at once and holding nothing. `release()` returns a promise that
4
+ * settles when the lock is let go.
5
+ * @param {string} name
6
+ * @returns {Promise<{ release(): Promise<void> }>}
7
+ */
8
+ export function holdShared(name: string): Promise<{
9
+ release(): Promise<void>;
10
+ }>;
11
+ /**
12
+ * Runs `remove` holding the lock `name` exclusively, only if no page holds it, and resolves to
13
+ * whether it ran. Without Web Locks it runs.
14
+ * @param {string} name
15
+ * @param {() => Promise<void>} remove
16
+ * @returns {Promise<boolean>}
17
+ */
18
+ export function whenUnheld(name: string, remove: () => Promise<void>): Promise<boolean>;
19
+ /**
20
+ * Runs `body` holding the lock `name` exclusively, waiting for any page that holds it, and resolves
21
+ * to what `body` resolves to; `body` is told whether it holds the lock. Where the browser has no
22
+ * Web Locks, or refuses the lock, `body` runs at once, holding nothing. Rejects with
23
+ * `signal.reason` once `signal` aborts while it waits.
24
+ * @template T
25
+ * @param {string} name
26
+ * @param {{ signal?: AbortSignal }} options
27
+ * @param {(held: boolean) => Promise<T>} body
28
+ * @returns {Promise<T>}
29
+ */
30
+ export function withExclusive<T>(name: string, { signal }: {
31
+ signal?: AbortSignal;
32
+ } | undefined, body: (held: boolean) => Promise<T>): Promise<T>;
@@ -0,0 +1,86 @@
1
+ // Web Locks marking OPFS entries in use: users hold the lock shared, removers take it exclusively
2
+ // only if unheld. Closed pages release their locks; without Web Locks nothing is held.
3
+ const locks = () => globalThis.navigator?.locks ?? null;
4
+ /**
5
+ * Holds the shared lock `name` until `release()`. Resolves once held; where the browser has no Web
6
+ * Locks, or refuses the lock, at once and holding nothing. `release()` returns a promise that
7
+ * settles when the lock is let go.
8
+ * @param {string} name
9
+ * @returns {Promise<{ release(): Promise<void> }>}
10
+ */
11
+ export async function holdShared(name) {
12
+ const manager = locks();
13
+ if (!manager)
14
+ return { release: async () => { } };
15
+ let letGo;
16
+ const done = new Promise((resolve) => (letGo = resolve));
17
+ let held;
18
+ const acquired = new Promise((resolve) => (held = resolve));
19
+ const request = manager
20
+ .request(name, { mode: "shared" }, () => {
21
+ held();
22
+ return done;
23
+ })
24
+ .catch(() => held());
25
+ await acquired;
26
+ let released = null;
27
+ return {
28
+ release() {
29
+ if (!released) {
30
+ letGo();
31
+ released = request;
32
+ }
33
+ return released;
34
+ },
35
+ };
36
+ }
37
+ /**
38
+ * Runs `remove` holding the lock `name` exclusively, only if no page holds it, and resolves to
39
+ * whether it ran. Without Web Locks it runs.
40
+ * @param {string} name
41
+ * @param {() => Promise<void>} remove
42
+ * @returns {Promise<boolean>}
43
+ */
44
+ export async function whenUnheld(name, remove) {
45
+ const manager = locks();
46
+ if (!manager) {
47
+ await remove();
48
+ return true;
49
+ }
50
+ return manager.request(name, { mode: "exclusive", ifAvailable: true }, async (lock) => {
51
+ if (!lock)
52
+ return false;
53
+ await remove();
54
+ return true;
55
+ });
56
+ }
57
+ /**
58
+ * Runs `body` holding the lock `name` exclusively, waiting for any page that holds it, and resolves
59
+ * to what `body` resolves to; `body` is told whether it holds the lock. Where the browser has no
60
+ * Web Locks, or refuses the lock, `body` runs at once, holding nothing. Rejects with
61
+ * `signal.reason` once `signal` aborts while it waits.
62
+ * @template T
63
+ * @param {string} name
64
+ * @param {{ signal?: AbortSignal }} options
65
+ * @param {(held: boolean) => Promise<T>} body
66
+ * @returns {Promise<T>}
67
+ */
68
+ export async function withExclusive(name, { signal } = {}, body) {
69
+ signal?.throwIfAborted();
70
+ const manager = locks();
71
+ if (!manager)
72
+ return body(false);
73
+ let started = false;
74
+ try {
75
+ return await manager.request(name, signal ? { mode: "exclusive", signal } : { mode: "exclusive" }, () => {
76
+ started = true;
77
+ return body(true);
78
+ });
79
+ }
80
+ catch (error) {
81
+ if (started)
82
+ throw error;
83
+ signal?.throwIfAborted();
84
+ return body(false);
85
+ }
86
+ }