fragment-tools 0.2.11 → 0.2.13
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.
- package/package.json +12 -11
- package/src/cli/build.js +1 -0
- package/src/cli/create.js +22 -4
- package/src/cli/createConfig.js +2 -2
- package/src/cli/getEntries.js +10 -1
- package/src/cli/plugins/hot-shader-replacement.js +54 -16
- package/src/cli/plugins/save.js +97 -38
- package/src/cli/prompts.js +89 -36
- package/src/cli/run.js +1 -1
- package/src/cli/templates/blank/index.ts +1 -1
- package/src/cli/templates/default/index.js +10 -2
- package/src/cli/templates/default/index.ts +5 -2
- package/src/cli/templates/fragment-gl/index.ts +1 -1
- package/src/cli/templates/p5/index.ts +1 -1
- package/src/cli/templates/p5-webgl/index.ts +1 -1
- package/src/cli/templates/three-fragment/index.js +5 -3
- package/src/cli/templates/three-fragment/index.ts +5 -4
- package/src/cli/templates/three-orthographic/index.js +6 -1
- package/src/cli/templates/three-orthographic/index.ts +6 -2
- package/src/cli/templates/three-perspective/index.js +6 -1
- package/src/cli/templates/three-perspective/index.ts +6 -2
- package/src/client/app/actions/resize.js +8 -1
- package/src/client/app/attachments/draggable.js +93 -0
- package/src/client/app/client.js +90 -18
- package/src/client/app/components/IconFlip.svelte +46 -0
- package/src/client/app/hooks.js +25 -1
- package/src/client/app/lib/canvas-recorder/CanvasRecorder.js +95 -3
- package/src/client/app/lib/canvas-recorder/FrameRecorder.js +45 -3
- package/src/client/app/lib/canvas-recorder/GIFRecorder.js +72 -13
- package/src/client/app/lib/canvas-recorder/MediaBunnyRecorder.js +43 -9
- package/src/client/app/lib/canvas-recorder/utils.js +18 -9
- package/src/client/app/modules/Params.svelte +1 -0
- package/src/client/app/renderers/2DRenderer.js +20 -16
- package/src/client/app/renderers/P5GLRenderer.js +13 -5
- package/src/client/app/renderers/P5Renderer.js +9 -1
- package/src/client/app/renderers/THREERenderer.js +63 -48
- package/src/client/app/state/Sketch.svelte.js +150 -10
- package/src/client/app/state/errors.svelte.js +19 -0
- package/src/client/app/state/exports.svelte.js +14 -1
- package/src/client/app/state/rendering.svelte.js +90 -13
- package/src/client/app/state/sketches.svelte.js +43 -7
- package/src/client/app/state/utils.svelte.js +49 -0
- package/src/client/app/ui/Field.svelte +63 -16
- package/src/client/app/ui/FieldSection.svelte +4 -4
- package/src/client/app/ui/ParamsOutput.svelte +7 -5
- package/src/client/app/ui/SketchRenderer.svelte +21 -0
- package/src/client/app/ui/fields/ButtonInput.svelte +2 -0
- package/src/client/app/ui/fields/CheckboxInput.svelte +13 -11
- package/src/client/app/ui/fields/ColorInput.svelte +16 -11
- package/src/client/app/ui/fields/GradientInput.svelte +607 -0
- package/src/client/app/ui/fields/Input.svelte +10 -6
- package/src/client/app/ui/fields/IntervalInput.svelte +27 -35
- package/src/client/app/ui/fields/NumberInput.svelte +51 -13
- package/src/client/app/ui/fields/PaletteInput.svelte +181 -0
- package/src/client/app/ui/fields/ProgressInput.svelte +44 -16
- package/src/client/app/ui/fields/TextareaInput.svelte +93 -0
- package/src/client/app/utils/canvas.utils.js +105 -28
- package/src/client/app/utils/color.utils.js +74 -17
- package/src/client/app/utils/fields.utils.js +70 -17
- package/src/client/app/utils/file.utils.js +86 -31
- package/src/client/app/utils/glsl.utils.js +11 -2
- package/src/client/app/utils/glslErrors.js +31 -21
- package/src/client/app/utils/index.js +28 -12
- package/src/client/main.js +7 -1
- package/src/types/global.d.ts +143 -0
- package/src/types/props.d.ts +41 -15
- package/tsconfig.json +1 -1
package/src/client/app/client.js
CHANGED
|
@@ -1,9 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} MessagePayload
|
|
3
|
+
* @property {string} event - The event name
|
|
4
|
+
* @property {any} [data] - Optional event data
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} ShaderWarning
|
|
9
|
+
* @property {string} type - Warning type
|
|
10
|
+
* @property {string} importer - File that imported the shader
|
|
11
|
+
* @property {string} message - Warning message
|
|
12
|
+
* @property {Object} location - Location of the warning
|
|
13
|
+
* @property {string} location.lineText - The line of code with the warning
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} ShaderUpdate
|
|
18
|
+
* @property {ShaderWarning[]} [warnings] - Array of shader warnings
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} SketchUpdate
|
|
23
|
+
* @property {string} filepath
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @callback EventCallback
|
|
28
|
+
* @param {any} data - Event data
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @callback UnsubscribeFunction
|
|
34
|
+
* @returns {void}
|
|
35
|
+
*/
|
|
36
|
+
|
|
1
37
|
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
2
38
|
const socketHost = `${location.hostname}:${__FRAGMENT_PORT__}`;
|
|
3
39
|
|
|
4
|
-
|
|
5
|
-
|
|
40
|
+
/** @type {WebSocket | undefined} */
|
|
41
|
+
let socket;
|
|
42
|
+
/** @type {Record<string, EventCallback[]>} */
|
|
43
|
+
let listeners = {};
|
|
44
|
+
let opened = false;
|
|
6
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Handle incoming WebSocket message
|
|
48
|
+
* @param {MessagePayload} payload - The message payload
|
|
49
|
+
* @returns {void}
|
|
50
|
+
*/
|
|
7
51
|
function handleMessage(payload) {
|
|
8
52
|
const { event, data = {} } = payload;
|
|
9
53
|
const callbacks = listeners[event];
|
|
@@ -13,6 +57,12 @@ function handleMessage(payload) {
|
|
|
13
57
|
}
|
|
14
58
|
}
|
|
15
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Subscribe to an event
|
|
62
|
+
* @param {string} event - The event name to listen for
|
|
63
|
+
* @param {EventCallback} cb - The callback function
|
|
64
|
+
* @returns {UnsubscribeFunction} Function to unsubscribe
|
|
65
|
+
*/
|
|
16
66
|
function on(event, cb) {
|
|
17
67
|
if (!listeners[event]) {
|
|
18
68
|
listeners[event] = [];
|
|
@@ -25,6 +75,12 @@ function on(event, cb) {
|
|
|
25
75
|
};
|
|
26
76
|
}
|
|
27
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Unsubscribe from an event
|
|
80
|
+
* @param {string} event - The event name
|
|
81
|
+
* @param {EventCallback} cb - The callback function to remove
|
|
82
|
+
* @returns {void}
|
|
83
|
+
*/
|
|
28
84
|
function off(event, cb) {
|
|
29
85
|
const callbacks = listeners[event];
|
|
30
86
|
|
|
@@ -35,9 +91,14 @@ function off(event, cb) {
|
|
|
35
91
|
}
|
|
36
92
|
}
|
|
37
93
|
|
|
38
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Emit an event to the server
|
|
96
|
+
* @param {string} event - The event name
|
|
97
|
+
* @param {any} data - The data to send
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
39
100
|
function emit(event, data) {
|
|
40
|
-
if (opened) {
|
|
101
|
+
if (socket && opened) {
|
|
41
102
|
socket.send(
|
|
42
103
|
JSON.stringify({
|
|
43
104
|
event,
|
|
@@ -52,7 +113,7 @@ if (import.meta.hot) {
|
|
|
52
113
|
|
|
53
114
|
socket = new WebSocket(`${socketProtocol}://${socketHost}`);
|
|
54
115
|
|
|
55
|
-
socket.addEventListener('message',
|
|
116
|
+
socket.addEventListener('message', (message) => {
|
|
56
117
|
const { data } = message;
|
|
57
118
|
|
|
58
119
|
handleMessage(JSON.parse(data));
|
|
@@ -63,22 +124,33 @@ if (import.meta.hot) {
|
|
|
63
124
|
opened = true;
|
|
64
125
|
});
|
|
65
126
|
|
|
66
|
-
import.meta.hot.on(
|
|
67
|
-
|
|
68
|
-
|
|
127
|
+
import.meta.hot.on(
|
|
128
|
+
'sketch-update',
|
|
129
|
+
/** @param {SketchUpdate} sketchUpdate */
|
|
130
|
+
(sketchUpdate) => {
|
|
131
|
+
console.log(`[fragment] hmr update /${sketchUpdate.filepath}`);
|
|
132
|
+
},
|
|
133
|
+
);
|
|
69
134
|
}
|
|
70
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Client API for WebSocket communication
|
|
138
|
+
* @type {{ on: typeof on, off: typeof off, emit: typeof emit }}
|
|
139
|
+
*/
|
|
71
140
|
export const client = { on, off, emit };
|
|
72
141
|
|
|
73
142
|
client.on('shader-update', (shaderUpdates) => {
|
|
74
|
-
shaderUpdates.forEach(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
143
|
+
shaderUpdates.forEach(
|
|
144
|
+
/** @param {ShaderUpdate} shaderUpdate */
|
|
145
|
+
({ warnings = [] } = {}) => {
|
|
146
|
+
if (warnings.length > 0) {
|
|
147
|
+
warnings.forEach((warning) => {
|
|
148
|
+
const { location } = warning;
|
|
149
|
+
console.warn(
|
|
150
|
+
`[fragment-plugin-hsr] ${warning.type} ${warning.importer}\n\n ${location.lineText}\n\n${warning.message}`,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
);
|
|
84
156
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
let { angle = 0 } = $props();
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<svg
|
|
6
|
+
width="16"
|
|
7
|
+
height="16"
|
|
8
|
+
fill="none"
|
|
9
|
+
viewBox="0 0 24 24"
|
|
10
|
+
style="--angle: {angle}"
|
|
11
|
+
>
|
|
12
|
+
<path
|
|
13
|
+
stroke="currentColor"
|
|
14
|
+
stroke-linecap="round"
|
|
15
|
+
stroke-linejoin="round"
|
|
16
|
+
stroke-width="1.5"
|
|
17
|
+
d="M18 8H6"
|
|
18
|
+
></path>
|
|
19
|
+
<path
|
|
20
|
+
stroke="currentColor"
|
|
21
|
+
stroke-linecap="round"
|
|
22
|
+
stroke-linejoin="round"
|
|
23
|
+
stroke-width="1.5"
|
|
24
|
+
d="M10 5L6 8L10 11"
|
|
25
|
+
></path>
|
|
26
|
+
<path
|
|
27
|
+
stroke="currentColor"
|
|
28
|
+
stroke-linecap="round"
|
|
29
|
+
stroke-linejoin="round"
|
|
30
|
+
stroke-width="1.5"
|
|
31
|
+
d="M6 16H18"
|
|
32
|
+
></path>
|
|
33
|
+
<path
|
|
34
|
+
stroke="currentColor"
|
|
35
|
+
stroke-linecap="round"
|
|
36
|
+
stroke-linejoin="round"
|
|
37
|
+
stroke-width="1.5"
|
|
38
|
+
d="M14 13L18 16L14 19"
|
|
39
|
+
></path>
|
|
40
|
+
</svg>
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
svg {
|
|
44
|
+
transform: rotate(var(--angle));
|
|
45
|
+
}
|
|
46
|
+
</style>
|
package/src/client/app/hooks.js
CHANGED
|
@@ -2,18 +2,42 @@ import { rendering } from './state/rendering.svelte';
|
|
|
2
2
|
import { sketchesManager } from './state/sketches.svelte';
|
|
3
3
|
import { getContext } from './triggers/shared';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Register a callback to be called before capturing
|
|
7
|
+
* @param {Function} listener - The callback function to execute before capture
|
|
8
|
+
* @param {string} [context] - The sketch context (defaults to current context)
|
|
9
|
+
* @returns {void}
|
|
10
|
+
*/
|
|
5
11
|
export const onBeforeCapture = (listener, context = getContext()) => {
|
|
6
12
|
sketchesManager.sketches[context]?.onBeforeCapture(listener);
|
|
7
13
|
};
|
|
8
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Register a callback to be called after capturing
|
|
17
|
+
* @param {Function} listener - The callback function to execute after capture
|
|
18
|
+
* @param {string} [context] - The sketch context (defaults to current context)
|
|
19
|
+
* @returns {void}
|
|
20
|
+
*/
|
|
9
21
|
export const onAfterCapture = (listener, context = getContext()) => {
|
|
10
22
|
sketchesManager.sketches[context]?.onAfterCapture(listener);
|
|
11
23
|
};
|
|
12
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Register a callback to be called before recording
|
|
27
|
+
* @param {Function} listener - The callback function to execute before recording
|
|
28
|
+
* @param {string} [context] - The sketch context (defaults to current context)
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
13
31
|
export const onBeforeRecord = (listener, context = getContext()) => {
|
|
14
32
|
sketchesManager.sketches[context]?.onBeforeRecord(listener);
|
|
15
33
|
};
|
|
16
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Register a callback to be called after recording
|
|
37
|
+
* @param {Function} listener - The callback function to execute after recording
|
|
38
|
+
* @param {string} [context] - The sketch context (defaults to current context)
|
|
39
|
+
* @returns {void}
|
|
40
|
+
*/
|
|
17
41
|
export const onAfterRecord = (listener, context = getContext()) => {
|
|
18
42
|
sketchesManager.sketches[context]?.onAfterRecord(listener);
|
|
19
43
|
};
|
|
@@ -23,7 +47,7 @@ export const onAfterRecord = (listener, context = getContext()) => {
|
|
|
23
47
|
* @param {object} options
|
|
24
48
|
* @param {string} [options.filename]
|
|
25
49
|
* @param {function} [options.pattern]
|
|
26
|
-
* @param {
|
|
50
|
+
* @param {string} [options.exportDir]
|
|
27
51
|
*/
|
|
28
52
|
export async function screenshot({ filename, pattern, exportDir } = {}) {
|
|
29
53
|
const context = getContext();
|
|
@@ -1,6 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @callback CanvasRecorderStartCallback
|
|
3
|
+
* @returns {void}
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @callback CanvasRecorderTickCallback
|
|
8
|
+
* @param {TickData} data - Tick data
|
|
9
|
+
* @returns {void}
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @callback CanvasRecorderCompleteCallback
|
|
14
|
+
* @param {Blob | Blob[] | null} result
|
|
15
|
+
* @returns {void}
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} TickData
|
|
20
|
+
* @property {number} time - Current time in milliseconds
|
|
21
|
+
* @property {number} deltaTime - Time since last frame in milliseconds
|
|
22
|
+
* @property {number} frameCount - Current frame count
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} CanvasRecorderOptions
|
|
27
|
+
* @property {number} [duration=Infinity] - Recording duration in seconds
|
|
28
|
+
* @property {number} [framerate=25] - Frames per second
|
|
29
|
+
* @property {number} [quality=100] - Recording quality (1-100)
|
|
30
|
+
* @property {string} format - Output format
|
|
31
|
+
* @property {CanvasRecorderStartCallback} [onStart] - Callback when recording starts
|
|
32
|
+
* @property {CanvasRecorderTickCallback} [onTick] - Callback on each frame
|
|
33
|
+
* @property {CanvasRecorderCompleteCallback} [onComplete] - Callback when recording completes
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** @type {CanvasRecorderStartCallback} */
|
|
1
37
|
let noop = () => {};
|
|
2
38
|
|
|
3
39
|
class CanvasRecorder {
|
|
40
|
+
/**
|
|
41
|
+
* Create a canvas recorder
|
|
42
|
+
* @param {HTMLCanvasElement} canvas - The canvas to record
|
|
43
|
+
* @param {CanvasRecorderOptions} options - Recording options
|
|
44
|
+
*/
|
|
4
45
|
constructor(
|
|
5
46
|
canvas,
|
|
6
47
|
{
|
|
@@ -13,30 +54,62 @@ class CanvasRecorder {
|
|
|
13
54
|
onComplete = noop,
|
|
14
55
|
},
|
|
15
56
|
) {
|
|
57
|
+
/** @type {HTMLCanvasElement} */
|
|
16
58
|
this.canvas = canvas;
|
|
59
|
+
/** @type {number} */
|
|
17
60
|
this.framerate = framerate;
|
|
61
|
+
/** @type {number} */
|
|
18
62
|
this.duration = duration;
|
|
63
|
+
/** @type {number} */
|
|
19
64
|
this.quality = quality;
|
|
65
|
+
/** @type {string} */
|
|
20
66
|
this.format = format;
|
|
67
|
+
/** @type {CanvasRecorderStartCallback} */
|
|
21
68
|
this.onStart = onStart;
|
|
69
|
+
/** @type {CanvasRecorderTickCallback} */
|
|
22
70
|
this.onTick = onTick;
|
|
71
|
+
/** @type {Function} */
|
|
23
72
|
this.onComplete = onComplete;
|
|
24
|
-
|
|
73
|
+
/** @type {number} */
|
|
25
74
|
this.time = 0;
|
|
75
|
+
|
|
76
|
+
/** @type {number} */
|
|
26
77
|
this.deltaTime = 1000 / this.framerate;
|
|
27
78
|
|
|
79
|
+
/** @type {number} */
|
|
28
80
|
this.frameDuration = 1000 / this.framerate;
|
|
81
|
+
|
|
82
|
+
/** @type {number} */
|
|
29
83
|
this.frameTotal = isFinite(this.duration)
|
|
30
84
|
? this.duration * this.framerate
|
|
31
85
|
: Infinity;
|
|
86
|
+
|
|
87
|
+
/** @type {boolean} */
|
|
32
88
|
this.started = false;
|
|
89
|
+
|
|
90
|
+
/** @type {boolean} */
|
|
33
91
|
this.stopped = false;
|
|
34
92
|
|
|
93
|
+
/** @type {number} */
|
|
35
94
|
this.startTime = 0;
|
|
95
|
+
|
|
96
|
+
/** @type {number} */
|
|
97
|
+
this.frameCount = 0;
|
|
98
|
+
|
|
99
|
+
/** @type {Blob | Blob[] | null} */
|
|
100
|
+
this.result = null;
|
|
36
101
|
}
|
|
37
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Load resources before recording (override in subclass)
|
|
105
|
+
* @returns {Promise<void>}
|
|
106
|
+
*/
|
|
38
107
|
async load() {}
|
|
39
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Start the recording
|
|
111
|
+
* @returns {Promise<void>}
|
|
112
|
+
*/
|
|
40
113
|
async start() {
|
|
41
114
|
this.startTime = performance.now();
|
|
42
115
|
this.onStart();
|
|
@@ -65,11 +138,17 @@ class CanvasRecorder {
|
|
|
65
138
|
this._tick();
|
|
66
139
|
}
|
|
67
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Internal tick handler
|
|
143
|
+
* @private
|
|
144
|
+
* @returns {Promise<void>}
|
|
145
|
+
*/
|
|
68
146
|
async _tick() {
|
|
69
147
|
console.log(`CanvasRecorder - render frame ${this.frameCount + 1}`);
|
|
70
148
|
this.onTick({
|
|
71
149
|
time: this.time,
|
|
72
150
|
deltaTime: this.deltaTime,
|
|
151
|
+
frameCount: this.frameCount,
|
|
73
152
|
});
|
|
74
153
|
|
|
75
154
|
await this.tick({
|
|
@@ -98,8 +177,17 @@ class CanvasRecorder {
|
|
|
98
177
|
}
|
|
99
178
|
}
|
|
100
179
|
|
|
101
|
-
|
|
102
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Process a single frame (override in subclass)
|
|
182
|
+
* @param {TickData} _data - Frame data
|
|
183
|
+
* @returns {Promise<void>}
|
|
184
|
+
*/
|
|
185
|
+
async tick(_data) {}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* End the recording and compile result
|
|
189
|
+
* @returns {void}
|
|
190
|
+
*/
|
|
103
191
|
end() {
|
|
104
192
|
console.log(
|
|
105
193
|
`CanvasRecorder - compiled ${this.frameCount + 1} frames in ${(performance.now() - this.startTime) / 1000}s`,
|
|
@@ -107,6 +195,10 @@ class CanvasRecorder {
|
|
|
107
195
|
this.onComplete(this.result);
|
|
108
196
|
}
|
|
109
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Stop the recording
|
|
200
|
+
* @returns {void}
|
|
201
|
+
*/
|
|
110
202
|
stop() {
|
|
111
203
|
this.stopped = true;
|
|
112
204
|
}
|
|
@@ -3,24 +3,62 @@ import { map } from '../../utils/math.utils';
|
|
|
3
3
|
import CanvasRecorder from './CanvasRecorder';
|
|
4
4
|
import { exportCanvas } from './utils';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} FrameRecorderOptions
|
|
8
|
+
* @property {string} [imageEncoding='png'] - Image encoding format (png, jpeg, webp)
|
|
9
|
+
* @property {number} [duration] - Recording duration in seconds
|
|
10
|
+
* @property {number} [framerate] - Frames per second
|
|
11
|
+
* @property {number} [quality] - Recording quality (1-100)
|
|
12
|
+
* @property {string} format - Output format
|
|
13
|
+
* @property {import('./CanvasRecorder').CanvasRecorderStartCallback} [onStart] - Callback when recording starts
|
|
14
|
+
* @property {import('./CanvasRecorder').CanvasRecorderTickCallback} [onTick] - Callback on each frame
|
|
15
|
+
* @property {import('./CanvasRecorder').CanvasRecorderCompleteCallback} [onComplete] - Callback when recording completes
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Recorder that captures individual frames as images
|
|
20
|
+
* @extends CanvasRecorder
|
|
21
|
+
*/
|
|
6
22
|
class FrameRecorder extends CanvasRecorder {
|
|
23
|
+
/**
|
|
24
|
+
* Create a frame recorder
|
|
25
|
+
* @param {HTMLCanvasElement} canvas - The canvas to record
|
|
26
|
+
* @param {FrameRecorderOptions} options - Recording options
|
|
27
|
+
*/
|
|
7
28
|
constructor(canvas, options) {
|
|
8
29
|
super(canvas, options);
|
|
9
30
|
|
|
10
31
|
const { imageEncoding = 'png' } = options;
|
|
11
32
|
|
|
33
|
+
/** @type {string} */
|
|
12
34
|
this.imageEncoding = imageEncoding;
|
|
13
35
|
|
|
36
|
+
/** @type {number} */
|
|
14
37
|
this.imageQuality = map(this.quality, 1, 100, 0, 1);
|
|
38
|
+
|
|
39
|
+
/** @type {string[]} */
|
|
40
|
+
this.frames = [];
|
|
41
|
+
|
|
42
|
+
/** @type {Blob[]} */
|
|
43
|
+
this.result = [];
|
|
15
44
|
}
|
|
16
45
|
|
|
17
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Start recording frames
|
|
48
|
+
* @returns {Promise<void>}
|
|
49
|
+
*/
|
|
50
|
+
async start() {
|
|
18
51
|
this.frames = [];
|
|
19
52
|
|
|
20
|
-
super.start();
|
|
53
|
+
await super.start();
|
|
21
54
|
}
|
|
22
55
|
|
|
23
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Capture a single frame
|
|
58
|
+
* @param {import('./CanvasRecorder').TickData} _data - Frame data (unused)
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
async tick(_data) {
|
|
24
62
|
let { dataURL } = exportCanvas(this.canvas, {
|
|
25
63
|
encoding: `image/${this.imageEncoding}`,
|
|
26
64
|
encodingQuality: this.imageQuality,
|
|
@@ -29,6 +67,10 @@ class FrameRecorder extends CanvasRecorder {
|
|
|
29
67
|
this.frames[this.frameCount] = dataURL;
|
|
30
68
|
}
|
|
31
69
|
|
|
70
|
+
/**
|
|
71
|
+
* End recording and convert frames to blobs
|
|
72
|
+
* @returns {Promise<void>}
|
|
73
|
+
*/
|
|
32
74
|
async end() {
|
|
33
75
|
this.result = await Promise.all(
|
|
34
76
|
this.frames.map((dataURL) => createBlobFromDataURL(dataURL)),
|
|
@@ -2,8 +2,41 @@ import { map } from '../../utils/math.utils';
|
|
|
2
2
|
import { GIFEncoder, quantize, applyPalette } from 'gifenc';
|
|
3
3
|
import CanvasRecorder from './CanvasRecorder';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import('./CanvasRecorder').CanvasRecorderOptions} GIFRecorderOptions
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Recorder that captures frames and encodes them as an animated GIF
|
|
11
|
+
* @extends CanvasRecorder
|
|
12
|
+
*/
|
|
5
13
|
class GIFRecorder extends CanvasRecorder {
|
|
6
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Create a GIF recorder
|
|
16
|
+
* @param {HTMLCanvasElement} canvas - The canvas to record
|
|
17
|
+
* @param {GIFRecorderOptions} options - Recording options
|
|
18
|
+
*/
|
|
19
|
+
constructor(canvas, options) {
|
|
20
|
+
super(canvas, options);
|
|
21
|
+
|
|
22
|
+
/** @type {ReturnType<typeof GIFEncoder> | null} */
|
|
23
|
+
this.encoder = null;
|
|
24
|
+
|
|
25
|
+
/** @type {HTMLCanvasElement} */
|
|
26
|
+
this.tmpCanvas = document.createElement('canvas');
|
|
27
|
+
|
|
28
|
+
/** @type {CanvasRenderingContext2D | null} */
|
|
29
|
+
this.tmpContext = this.tmpCanvas.getContext('2d');
|
|
30
|
+
|
|
31
|
+
/** @type {number} */
|
|
32
|
+
this.maxColors = 256;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start GIF recording
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async start() {
|
|
7
40
|
this.encoder = GIFEncoder();
|
|
8
41
|
|
|
9
42
|
this.tmpCanvas = document.createElement('canvas');
|
|
@@ -21,34 +54,60 @@ class GIFRecorder extends CanvasRecorder {
|
|
|
21
54
|
: Infinity;
|
|
22
55
|
}
|
|
23
56
|
|
|
24
|
-
super.start();
|
|
57
|
+
await super.start();
|
|
25
58
|
}
|
|
26
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Get RGBA pixel data from a bitmap
|
|
62
|
+
* @param {HTMLCanvasElement | ImageBitmap} bitmap - The bitmap to extract pixels from
|
|
63
|
+
* @param {number} [width=bitmap.width] - Target width
|
|
64
|
+
* @param {number} [height=bitmap.height] - Target height
|
|
65
|
+
* @returns {Uint8ClampedArray} RGBA pixel data
|
|
66
|
+
*/
|
|
27
67
|
getBitmapRGBA(bitmap, width = bitmap.width, height = bitmap.height) {
|
|
28
68
|
this.tmpCanvas.width = width;
|
|
29
69
|
this.tmpCanvas.height = height;
|
|
30
|
-
|
|
31
|
-
this.tmpContext
|
|
32
|
-
|
|
70
|
+
|
|
71
|
+
if (this.tmpContext) {
|
|
72
|
+
this.tmpContext.clearRect(0, 0, width, height);
|
|
73
|
+
this.tmpContext.drawImage(bitmap, 0, 0, width, height);
|
|
74
|
+
return this.tmpContext.getImageData(0, 0, width, height).data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return new Uint8ClampedArray();
|
|
33
78
|
}
|
|
34
79
|
|
|
35
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Capture and encode a single frame
|
|
82
|
+
* @param {import('./CanvasRecorder').TickData} _data - Frame data (unused)
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
*/
|
|
85
|
+
async tick(_data) {
|
|
36
86
|
const { width, height } = this.canvas;
|
|
37
87
|
|
|
38
88
|
const pixels = this.getBitmapRGBA(this.canvas, width, height);
|
|
39
89
|
const palette = quantize(pixels, this.maxColors);
|
|
40
90
|
const index = applyPalette(pixels, palette);
|
|
41
91
|
|
|
42
|
-
this.encoder
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
92
|
+
if (this.encoder) {
|
|
93
|
+
this.encoder.writeFrame(index, width, height, {
|
|
94
|
+
palette: palette,
|
|
95
|
+
delay: this.frameDuration,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
46
98
|
}
|
|
47
99
|
|
|
100
|
+
/**
|
|
101
|
+
* End recording and create GIF blob
|
|
102
|
+
* @returns {void}
|
|
103
|
+
*/
|
|
48
104
|
end() {
|
|
49
|
-
this.encoder
|
|
50
|
-
|
|
51
|
-
|
|
105
|
+
if (this.encoder) {
|
|
106
|
+
this.encoder.finish();
|
|
107
|
+
this.result = new Blob([this.encoder.bytes()], {
|
|
108
|
+
type: 'image/gif',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
52
111
|
|
|
53
112
|
super.end();
|
|
54
113
|
}
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
Mp4OutputFormat,
|
|
5
5
|
MkvOutputFormat,
|
|
6
6
|
MovOutputFormat,
|
|
7
|
-
WebMInputFormat,
|
|
8
7
|
BufferTarget,
|
|
9
8
|
CanvasSource,
|
|
10
9
|
Quality,
|
|
@@ -18,6 +17,26 @@ import {
|
|
|
18
17
|
import { map } from '@fragment/utils/math.utils.js';
|
|
19
18
|
import { VIDEO_FORMATS } from '@fragment/state/exports.svelte.js';
|
|
20
19
|
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {'avc' | 'hevc' | 'vp9' | 'av1' | 'vp8'} VideoCodec
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} MediaBunnyRecorderOptions
|
|
26
|
+
* @property {VideoCodec} codec - Video codec to use
|
|
27
|
+
* @property {number} [duration] - Recording duration in seconds
|
|
28
|
+
* @property {number} [framerate] - Frames per second
|
|
29
|
+
* @property {number} [quality] - Recording quality (1-100)
|
|
30
|
+
* @property {string} format - Output format
|
|
31
|
+
* @property {import('./CanvasRecorder').CanvasRecorderStartCallback} [onStart] - Callback when recording starts
|
|
32
|
+
* @property {import('./CanvasRecorder').CanvasRecorderTickCallback} [onTick] - Callback on each frame
|
|
33
|
+
* @property {import('./CanvasRecorder').CanvasRecorderCompleteCallback} [onComplete] - Callback when recording completes
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Recorder that uses MediaBunny to encode video
|
|
38
|
+
* @extends CanvasRecorder
|
|
39
|
+
*/
|
|
21
40
|
class MediaBunnyRecorder extends CanvasRecorder {
|
|
22
41
|
/** @type Quality[] */
|
|
23
42
|
static BITRATES = [
|
|
@@ -29,15 +48,14 @@ class MediaBunnyRecorder extends CanvasRecorder {
|
|
|
29
48
|
];
|
|
30
49
|
|
|
31
50
|
/**
|
|
32
|
-
*
|
|
33
|
-
* @param {HTMLCanvasElement} canvas
|
|
34
|
-
* @param {
|
|
35
|
-
* @param {codec} options.string
|
|
51
|
+
* Create a MediaBunny recorder
|
|
52
|
+
* @param {HTMLCanvasElement} canvas - The canvas to record
|
|
53
|
+
* @param {MediaBunnyRecorderOptions} options - Recording options
|
|
36
54
|
*/
|
|
37
55
|
constructor(canvas, { codec, ...options }) {
|
|
38
56
|
super(canvas, options);
|
|
39
57
|
|
|
40
|
-
/** @type {
|
|
58
|
+
/** @type {VideoCodec} */
|
|
41
59
|
this.codec = codec;
|
|
42
60
|
|
|
43
61
|
const outputFormats = new Map();
|
|
@@ -72,23 +90,39 @@ class MediaBunnyRecorder extends CanvasRecorder {
|
|
|
72
90
|
});
|
|
73
91
|
}
|
|
74
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Load and start the output
|
|
95
|
+
* @returns {Promise<void>}
|
|
96
|
+
*/
|
|
75
97
|
async load() {
|
|
76
98
|
await this.output.start();
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Process a single frame
|
|
103
|
+
* @param {import('./CanvasRecorder').TickData} tickData - Frame data
|
|
104
|
+
* @returns {Promise<void>}
|
|
105
|
+
*/
|
|
106
|
+
async tick({ frameCount }) {
|
|
80
107
|
const timestamp = frameCount / this.framerate;
|
|
81
108
|
|
|
82
109
|
this.videoSource.add(timestamp, this.frameDuration / 1000);
|
|
83
110
|
}
|
|
84
111
|
|
|
112
|
+
/**
|
|
113
|
+
* End recording and create video blob
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
85
116
|
async end() {
|
|
86
117
|
await this.output.finalize();
|
|
87
118
|
|
|
88
119
|
const { mimeType } = this.output.format;
|
|
89
|
-
const
|
|
120
|
+
const target = /** @type {BufferTarget} */ (this.output.target);
|
|
121
|
+
const buffer = target.buffer;
|
|
90
122
|
|
|
91
|
-
|
|
123
|
+
if (buffer) {
|
|
124
|
+
this.result = new Blob([buffer], { type: mimeType });
|
|
125
|
+
}
|
|
92
126
|
|
|
93
127
|
super.end();
|
|
94
128
|
}
|