frameos-wasm 2026.7.3
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/LICENSE +661 -0
- package/README.md +72 -0
- package/dist/assets/frameos.js +4208 -0
- package/dist/assets/frameos.wasm +0 -0
- package/dist/assets/preview-worker.js +198 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +10 -0
- package/dist/manager.d.ts +16 -0
- package/dist/manager.js +258 -0
- package/dist/preview.d.ts +60 -0
- package/dist/preview.js +116 -0
- package/dist/showIf.d.ts +18 -0
- package/dist/showIf.js +99 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +30 -0
- package/index.html +66 -0
- package/package.json +43 -0
|
Binary file
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// FrameOS live-preview worker.
|
|
2
|
+
//
|
|
3
|
+
// Loads the emscripten-built scene runtime (frameos.js/frameos.wasm, built by
|
|
4
|
+
// frameos/tools/build_wasm.sh) and drives it for the frontend's live-preview
|
|
5
|
+
// modal. Runs in a Web Worker so the runtime's synchronous HTTP hook (sync
|
|
6
|
+
// XHR) is allowed and long renders never block the page.
|
|
7
|
+
//
|
|
8
|
+
// Messages in:
|
|
9
|
+
// {type: 'init', width, height, timeZone, scenesJson, sceneId, settingsJson, proxyUrl}
|
|
10
|
+
// {type: 'render'} force a render now
|
|
11
|
+
// {type: 'event', name, payload} dispatch a scene event
|
|
12
|
+
// {type: 'selectScene', sceneId}
|
|
13
|
+
// Messages out:
|
|
14
|
+
// {type: 'ready', sceneInfo}
|
|
15
|
+
// {type: 'frame', width, height, buffer, renderMs} buffer: transferred ArrayBuffer (RGBA)
|
|
16
|
+
// {type: 'state', state}
|
|
17
|
+
// {type: 'log', message}
|
|
18
|
+
// {type: 'sceneEvent', name, payload}
|
|
19
|
+
// {type: 'error', message}
|
|
20
|
+
|
|
21
|
+
let Module = null
|
|
22
|
+
let renderTimer = null
|
|
23
|
+
let rendering = false
|
|
24
|
+
|
|
25
|
+
function post(msg, transfer) {
|
|
26
|
+
self.postMessage(msg, transfer || [])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function call(name, ret, argTypes, args) {
|
|
30
|
+
return Module.ccall(name, ret, argTypes, args)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function lastError() {
|
|
34
|
+
try {
|
|
35
|
+
return call('frameos_wasm_last_error', 'string', [], [])
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return String(e)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function postState() {
|
|
42
|
+
try {
|
|
43
|
+
const state = call('frameos_wasm_scene_state', 'string', [], [])
|
|
44
|
+
post({ type: 'state', state: JSON.parse(state) })
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// state is informational; ignore
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderNow() {
|
|
51
|
+
if (!Module || rendering) {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
rendering = true
|
|
55
|
+
try {
|
|
56
|
+
const started = Date.now()
|
|
57
|
+
const rc = call('frameos_wasm_render', 'number', [], [])
|
|
58
|
+
const width = call('frameos_wasm_width', 'number', [], [])
|
|
59
|
+
const height = call('frameos_wasm_height', 'number', [], [])
|
|
60
|
+
const ptr = call('frameos_wasm_buffer', 'number', [], [])
|
|
61
|
+
const len = call('frameos_wasm_buffer_len', 'number', [], [])
|
|
62
|
+
if (rc === 2 || !ptr || !len) {
|
|
63
|
+
post({ type: 'error', message: 'render failed: ' + lastError() })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
// Copy out of the wasm heap; the buffer is transferred to the page.
|
|
67
|
+
const buffer = Module.HEAPU8.buffer.slice(ptr, ptr + len)
|
|
68
|
+
post({ type: 'frame', width, height, buffer, renderMs: Date.now() - started }, [buffer])
|
|
69
|
+
postState()
|
|
70
|
+
} catch (e) {
|
|
71
|
+
post({ type: 'error', message: 'render crashed: ' + e })
|
|
72
|
+
} finally {
|
|
73
|
+
rendering = false
|
|
74
|
+
scheduleNextRender()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function scheduleNextRender() {
|
|
79
|
+
if (!Module) {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
if (renderTimer) {
|
|
83
|
+
clearTimeout(renderTimer)
|
|
84
|
+
renderTimer = null
|
|
85
|
+
}
|
|
86
|
+
let seconds = 0
|
|
87
|
+
try {
|
|
88
|
+
const interval = call('frameos_wasm_scene_interval', 'number', [], [])
|
|
89
|
+
const nextSleep = call('frameos_wasm_next_sleep', 'number', [], [])
|
|
90
|
+
seconds = interval > 0 ? interval : 300
|
|
91
|
+
if (nextSleep >= 0 && nextSleep < seconds) {
|
|
92
|
+
seconds = nextSleep
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
seconds = 300
|
|
96
|
+
}
|
|
97
|
+
// Keep the preview responsive but sane: at least 1 fps worth of delay,
|
|
98
|
+
// at most 15 minutes between refreshes.
|
|
99
|
+
const delayMs = Math.min(Math.max(seconds * 1000, 1000), 15 * 60 * 1000)
|
|
100
|
+
renderTimer = setTimeout(renderNow, delayMs)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderSoonIfRequested() {
|
|
104
|
+
try {
|
|
105
|
+
if (call('frameos_wasm_render_requested', 'boolean', [], [])) {
|
|
106
|
+
renderNow()
|
|
107
|
+
} else {
|
|
108
|
+
postState()
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
post({ type: 'error', message: String(e) })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function init(msg) {
|
|
116
|
+
try {
|
|
117
|
+
const createFrameOS = (await import('./frameos.js')).default
|
|
118
|
+
Module = await createFrameOS({
|
|
119
|
+
locateFile: (path) => new URL(path, import.meta.url).href,
|
|
120
|
+
onFrameosLog: (message) => post({ type: 'log', message }),
|
|
121
|
+
onFrameosEvent: (name, payload) => {
|
|
122
|
+
let parsed = {}
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(payload)
|
|
125
|
+
} catch (e) {}
|
|
126
|
+
post({ type: 'sceneEvent', name, payload: parsed })
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
// Route the runtime's HTTP requests through the backend proxy (same-origin,
|
|
130
|
+
// no CORS) so data apps that fetch external URLs work like on the device.
|
|
131
|
+
if (msg.proxyUrl) {
|
|
132
|
+
Module['frameosProxyUrl'] = msg.proxyUrl
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ok = call(
|
|
136
|
+
'frameos_wasm_init',
|
|
137
|
+
'boolean',
|
|
138
|
+
['number', 'number', 'string', 'string', 'string'],
|
|
139
|
+
[
|
|
140
|
+
msg.width,
|
|
141
|
+
msg.height,
|
|
142
|
+
msg.name || 'live preview',
|
|
143
|
+
msg.timeZone || 'UTC',
|
|
144
|
+
msg.settingsJson || '{}',
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
if (!ok) {
|
|
148
|
+
throw new Error('init failed: ' + lastError())
|
|
149
|
+
}
|
|
150
|
+
const loaded = call('frameos_wasm_load_scenes', 'number', ['string'], [msg.scenesJson])
|
|
151
|
+
if (!loaded) {
|
|
152
|
+
throw new Error('no scenes loaded: ' + lastError())
|
|
153
|
+
}
|
|
154
|
+
if (msg.sceneId) {
|
|
155
|
+
call('frameos_wasm_select_scene', 'boolean', ['string'], [msg.sceneId])
|
|
156
|
+
}
|
|
157
|
+
const sceneInfo = JSON.parse(call('frameos_wasm_scene_info', 'string', [], []))
|
|
158
|
+
post({ type: 'ready', sceneInfo })
|
|
159
|
+
renderNow()
|
|
160
|
+
} catch (e) {
|
|
161
|
+
post({ type: 'error', message: String(e && e.message ? e.message : e) })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
self.onmessage = (ev) => {
|
|
166
|
+
const msg = ev.data || {}
|
|
167
|
+
switch (msg.type) {
|
|
168
|
+
case 'init':
|
|
169
|
+
init(msg)
|
|
170
|
+
break
|
|
171
|
+
case 'render':
|
|
172
|
+
renderNow()
|
|
173
|
+
break
|
|
174
|
+
case 'event':
|
|
175
|
+
if (Module) {
|
|
176
|
+
try {
|
|
177
|
+
call(
|
|
178
|
+
'frameos_wasm_event',
|
|
179
|
+
'boolean',
|
|
180
|
+
['string', 'string'],
|
|
181
|
+
[msg.name, JSON.stringify(msg.payload || {})]
|
|
182
|
+
)
|
|
183
|
+
} catch (e) {
|
|
184
|
+
post({ type: 'error', message: 'event failed: ' + e })
|
|
185
|
+
}
|
|
186
|
+
renderSoonIfRequested()
|
|
187
|
+
}
|
|
188
|
+
break
|
|
189
|
+
case 'selectScene':
|
|
190
|
+
if (Module) {
|
|
191
|
+
call('frameos_wasm_select_scene', 'boolean', ['string'], [msg.sceneId])
|
|
192
|
+
renderNow()
|
|
193
|
+
}
|
|
194
|
+
break
|
|
195
|
+
default:
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { FrameOSPreview, createFrameOSPreview, type FrameOSPreviewOptions } from './preview';
|
|
2
|
+
export { mountFrameOSManager, type FrameOSManagerHandle, type FrameOSManagerOptions } from './manager';
|
|
3
|
+
export { coerceStateFieldValue, evaluateShowIf, stateFieldShowIfValues, visiblePublicStateFields, } from './showIf';
|
|
4
|
+
export { LIFECYCLE_EVENTS, sceneEventButtons, type ConfigFieldCondition, type ConfigFieldConditionAnd, type FrameOSScene, type PreviewFrame, type SceneEventButton, type SceneInfo, type SceneNode, type ShowIfCondition, type StateField, } from './types';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// frameos-wasm: run FrameOS scenes in the browser via WebAssembly.
|
|
2
|
+
//
|
|
3
|
+
// The runtime assets (frameos.js, frameos.wasm, preview-worker.js) ship in
|
|
4
|
+
// this package under ./assets — serve that directory same-origin and pass
|
|
5
|
+
// `workerUrl: '<mount>/preview-worker.js'`. Version tracks the FrameOS
|
|
6
|
+
// release the runtime was built from (versions.json in the FrameOS repo).
|
|
7
|
+
export { FrameOSPreview, createFrameOSPreview } from './preview';
|
|
8
|
+
export { mountFrameOSManager } from './manager';
|
|
9
|
+
export { coerceStateFieldValue, evaluateShowIf, stateFieldShowIfValues, visiblePublicStateFields, } from './showIf';
|
|
10
|
+
export { LIFECYCLE_EVENTS, sceneEventButtons, } from './types';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { FrameOSPreview, type FrameOSPreviewOptions } from './preview';
|
|
2
|
+
export interface FrameOSManagerOptions extends Omit<FrameOSPreviewOptions, 'canvas'> {
|
|
3
|
+
/** Show the log pane (default true). */
|
|
4
|
+
showLogs?: boolean;
|
|
5
|
+
/** Max log lines kept (default 200). */
|
|
6
|
+
maxLogLines?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface FrameOSManagerHandle {
|
|
9
|
+
preview: FrameOSPreview;
|
|
10
|
+
destroy: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Mount a self-contained preview + control surface into `container`.
|
|
14
|
+
* Returns the underlying FrameOSPreview for programmatic control.
|
|
15
|
+
*/
|
|
16
|
+
export declare function mountFrameOSManager(container: HTMLElement, options: FrameOSManagerOptions): FrameOSManagerHandle;
|
package/dist/manager.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// A minimal, dependency-free management interface for a FrameOS preview:
|
|
2
|
+
// canvas, scene switcher, showIf-aware state fields, event buttons, and a log
|
|
3
|
+
// pane. Mirrors the device's own /control page (frameos/assets/web/control.html)
|
|
4
|
+
// but runs entirely in the browser against the wasm runtime.
|
|
5
|
+
import { FrameOSPreview } from './preview';
|
|
6
|
+
import { coerceStateFieldValue, evaluateShowIf, stateFieldShowIfValues } from './showIf';
|
|
7
|
+
import { sceneEventButtons } from './types';
|
|
8
|
+
/**
|
|
9
|
+
* Mount a self-contained preview + control surface into `container`.
|
|
10
|
+
* Returns the underlying FrameOSPreview for programmatic control.
|
|
11
|
+
*/
|
|
12
|
+
export function mountFrameOSManager(container, options) {
|
|
13
|
+
const showLogs = options.showLogs !== false;
|
|
14
|
+
const maxLogLines = options.maxLogLines ?? 200;
|
|
15
|
+
container.classList.add('frameos-manager');
|
|
16
|
+
container.innerHTML = '';
|
|
17
|
+
injectStyles();
|
|
18
|
+
const stage = el('div', 'frameos-manager__stage');
|
|
19
|
+
const canvas = document.createElement('canvas');
|
|
20
|
+
canvas.className = 'frameos-manager__canvas';
|
|
21
|
+
canvas.width = options.width;
|
|
22
|
+
canvas.height = options.height;
|
|
23
|
+
stage.appendChild(canvas);
|
|
24
|
+
const status = el('div', 'frameos-manager__status', 'Loading FrameOS runtime…');
|
|
25
|
+
const controls = el('div', 'frameos-manager__controls');
|
|
26
|
+
const logPane = el('pre', 'frameos-manager__logs');
|
|
27
|
+
logPane.style.display = showLogs ? '' : 'none';
|
|
28
|
+
container.appendChild(stage);
|
|
29
|
+
container.appendChild(status);
|
|
30
|
+
container.appendChild(controls);
|
|
31
|
+
container.appendChild(logPane);
|
|
32
|
+
// Values the user typed, overriding the runtime's reported state until the
|
|
33
|
+
// runtime confirms them via a state message.
|
|
34
|
+
let editedValues = {};
|
|
35
|
+
let destroyed = false;
|
|
36
|
+
const logLines = [];
|
|
37
|
+
const appendLog = (line) => {
|
|
38
|
+
logLines.push(line);
|
|
39
|
+
if (logLines.length > maxLogLines) {
|
|
40
|
+
logLines.splice(0, logLines.length - maxLogLines);
|
|
41
|
+
}
|
|
42
|
+
logPane.textContent = logLines.join('\n');
|
|
43
|
+
logPane.scrollTop = logPane.scrollHeight;
|
|
44
|
+
};
|
|
45
|
+
const sceneById = new Map();
|
|
46
|
+
for (const scene of options.scenes) {
|
|
47
|
+
sceneById.set(scene.id, scene);
|
|
48
|
+
}
|
|
49
|
+
const preview = new FrameOSPreview({
|
|
50
|
+
...options,
|
|
51
|
+
canvas,
|
|
52
|
+
onReady: (sceneInfo) => {
|
|
53
|
+
status.textContent = '';
|
|
54
|
+
renderControls();
|
|
55
|
+
options.onReady?.(sceneInfo);
|
|
56
|
+
},
|
|
57
|
+
onFrame: (frame) => {
|
|
58
|
+
status.textContent = `Rendered ${frame.width}×${frame.height} in ${frame.renderMs} ms`;
|
|
59
|
+
options.onFrame?.(frame);
|
|
60
|
+
},
|
|
61
|
+
onState: (state) => {
|
|
62
|
+
editedValues = {};
|
|
63
|
+
renderControls();
|
|
64
|
+
options.onState?.(state);
|
|
65
|
+
},
|
|
66
|
+
onLog: (message) => {
|
|
67
|
+
appendLog(message);
|
|
68
|
+
options.onLog?.(message);
|
|
69
|
+
},
|
|
70
|
+
onSceneEvent: (name, payload) => {
|
|
71
|
+
appendLog(`event: ${name} ${JSON.stringify(payload)}`);
|
|
72
|
+
options.onSceneEvent?.(name, payload);
|
|
73
|
+
},
|
|
74
|
+
onError: (message) => {
|
|
75
|
+
status.textContent = message;
|
|
76
|
+
status.classList.add('frameos-manager__status--error');
|
|
77
|
+
appendLog(`error: ${message}`);
|
|
78
|
+
options.onError?.(message);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
function currentScene() {
|
|
82
|
+
return (preview.currentSceneId && sceneById.get(preview.currentSceneId)) || options.scenes[0];
|
|
83
|
+
}
|
|
84
|
+
function publicFields(scene) {
|
|
85
|
+
return (scene?.fields ?? []).filter((field) => field.access === 'public' && field.name);
|
|
86
|
+
}
|
|
87
|
+
function fieldValues(scene) {
|
|
88
|
+
return stateFieldShowIfValues(publicFields(scene), preview.state, editedValues);
|
|
89
|
+
}
|
|
90
|
+
function renderControls() {
|
|
91
|
+
if (destroyed) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
controls.innerHTML = '';
|
|
95
|
+
const scene = currentScene();
|
|
96
|
+
// Scene switcher, when more than one scene is loaded.
|
|
97
|
+
const scenes = preview.sceneInfo?.scenes ?? [];
|
|
98
|
+
if (scenes.length > 1) {
|
|
99
|
+
const row = el('div', 'frameos-manager__row');
|
|
100
|
+
row.appendChild(el('label', 'frameos-manager__label', 'Scene'));
|
|
101
|
+
const select = document.createElement('select');
|
|
102
|
+
select.className = 'frameos-manager__input';
|
|
103
|
+
for (const item of scenes) {
|
|
104
|
+
const option = document.createElement('option');
|
|
105
|
+
option.value = item.id;
|
|
106
|
+
option.textContent = item.name || item.id;
|
|
107
|
+
option.selected = item.id === preview.currentSceneId;
|
|
108
|
+
select.appendChild(option);
|
|
109
|
+
}
|
|
110
|
+
select.onchange = () => {
|
|
111
|
+
editedValues = {};
|
|
112
|
+
preview.selectScene(select.value);
|
|
113
|
+
renderControls();
|
|
114
|
+
};
|
|
115
|
+
row.appendChild(select);
|
|
116
|
+
controls.appendChild(row);
|
|
117
|
+
}
|
|
118
|
+
// State fields, filtered by showIf against current + edited values.
|
|
119
|
+
const fields = publicFields(scene);
|
|
120
|
+
const values = fieldValues(scene);
|
|
121
|
+
const visible = fields.filter((field) => evaluateShowIf(field.showIf, values, field.name));
|
|
122
|
+
for (const field of visible) {
|
|
123
|
+
controls.appendChild(fieldRow(field, values[field.name]));
|
|
124
|
+
}
|
|
125
|
+
if (visible.length > 0) {
|
|
126
|
+
const apply = document.createElement('button');
|
|
127
|
+
apply.type = 'button';
|
|
128
|
+
apply.className = 'frameos-manager__button frameos-manager__button--primary';
|
|
129
|
+
apply.textContent = 'Apply & render';
|
|
130
|
+
apply.onclick = () => {
|
|
131
|
+
const state = {};
|
|
132
|
+
for (const field of visible) {
|
|
133
|
+
const value = field.name in editedValues ? editedValues[field.name] : values[field.name];
|
|
134
|
+
if (value !== undefined) {
|
|
135
|
+
state[field.name] = coerceStateFieldValue(field, value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
preview.setSceneState(state);
|
|
139
|
+
};
|
|
140
|
+
controls.appendChild(apply);
|
|
141
|
+
}
|
|
142
|
+
// Custom event nodes as buttons.
|
|
143
|
+
const buttonRow = el('div', 'frameos-manager__row frameos-manager__row--buttons');
|
|
144
|
+
for (const event of sceneEventButtons(scene)) {
|
|
145
|
+
const button = document.createElement('button');
|
|
146
|
+
button.type = 'button';
|
|
147
|
+
button.className = 'frameos-manager__button';
|
|
148
|
+
button.textContent = event.label || event.keyword;
|
|
149
|
+
button.onclick = () => preview.sendEvent(event.keyword, {});
|
|
150
|
+
buttonRow.appendChild(button);
|
|
151
|
+
}
|
|
152
|
+
const render = document.createElement('button');
|
|
153
|
+
render.type = 'button';
|
|
154
|
+
render.className = 'frameos-manager__button';
|
|
155
|
+
render.textContent = 'Render';
|
|
156
|
+
render.onclick = () => preview.render();
|
|
157
|
+
buttonRow.appendChild(render);
|
|
158
|
+
controls.appendChild(buttonRow);
|
|
159
|
+
}
|
|
160
|
+
function fieldRow(field, value) {
|
|
161
|
+
const row = el('div', 'frameos-manager__row');
|
|
162
|
+
row.appendChild(el('label', 'frameos-manager__label', field.label || field.name));
|
|
163
|
+
const update = (next) => {
|
|
164
|
+
editedValues = { ...editedValues, [field.name]: next };
|
|
165
|
+
// showIf conditions may depend on this field: re-render the form (a
|
|
166
|
+
// fresh element keeps focus handling simple — apply happens on click).
|
|
167
|
+
renderControls();
|
|
168
|
+
};
|
|
169
|
+
if (field.type === 'select' || field.type === 'boolean' || field.type === 'font') {
|
|
170
|
+
const select = document.createElement('select');
|
|
171
|
+
select.className = 'frameos-manager__input';
|
|
172
|
+
const opts = field.type === 'boolean' ? ['true', 'false'] : field.options ?? [];
|
|
173
|
+
for (const opt of opts) {
|
|
174
|
+
const option = document.createElement('option');
|
|
175
|
+
option.value = opt;
|
|
176
|
+
option.textContent = opt;
|
|
177
|
+
option.selected = String(value ?? '') === opt;
|
|
178
|
+
select.appendChild(option);
|
|
179
|
+
}
|
|
180
|
+
select.onchange = () => update(select.value);
|
|
181
|
+
row.appendChild(select);
|
|
182
|
+
}
|
|
183
|
+
else if (field.type === 'text') {
|
|
184
|
+
const textarea = document.createElement('textarea');
|
|
185
|
+
textarea.className = 'frameos-manager__input';
|
|
186
|
+
textarea.rows = 3;
|
|
187
|
+
textarea.value = value === undefined || value === null ? '' : String(value);
|
|
188
|
+
textarea.onchange = () => update(textarea.value);
|
|
189
|
+
row.appendChild(textarea);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const input = document.createElement('input');
|
|
193
|
+
input.className = 'frameos-manager__input';
|
|
194
|
+
input.type =
|
|
195
|
+
field.type === 'integer' || field.type === 'float'
|
|
196
|
+
? 'number'
|
|
197
|
+
: field.type === 'date'
|
|
198
|
+
? 'date'
|
|
199
|
+
: field.type === 'color'
|
|
200
|
+
? 'color'
|
|
201
|
+
: 'text';
|
|
202
|
+
if (field.type === 'float') {
|
|
203
|
+
input.step = 'any';
|
|
204
|
+
}
|
|
205
|
+
if (field.placeholder) {
|
|
206
|
+
input.placeholder = field.placeholder;
|
|
207
|
+
}
|
|
208
|
+
input.value = value === undefined || value === null ? '' : String(value);
|
|
209
|
+
input.onchange = () => update(input.value);
|
|
210
|
+
row.appendChild(input);
|
|
211
|
+
}
|
|
212
|
+
return row;
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
preview,
|
|
216
|
+
destroy: () => {
|
|
217
|
+
destroyed = true;
|
|
218
|
+
preview.destroy();
|
|
219
|
+
container.innerHTML = '';
|
|
220
|
+
container.classList.remove('frameos-manager');
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function el(tag, className, text) {
|
|
225
|
+
const node = document.createElement(tag);
|
|
226
|
+
node.className = className;
|
|
227
|
+
if (text) {
|
|
228
|
+
node.textContent = text;
|
|
229
|
+
}
|
|
230
|
+
return node;
|
|
231
|
+
}
|
|
232
|
+
let stylesInjected = false;
|
|
233
|
+
function injectStyles() {
|
|
234
|
+
if (stylesInjected || typeof document === 'undefined') {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
stylesInjected = true;
|
|
238
|
+
const style = document.createElement('style');
|
|
239
|
+
style.setAttribute('data-frameos-manager', '');
|
|
240
|
+
style.textContent = `
|
|
241
|
+
.frameos-manager { display: flex; flex-direction: column; gap: 12px; font: 14px/1.4 system-ui, sans-serif; }
|
|
242
|
+
.frameos-manager__stage { display: flex; justify-content: center; background: #0f172a; border-radius: 12px; padding: 12px; }
|
|
243
|
+
.frameos-manager__canvas { max-width: 100%; max-height: min(60vh, 480px); width: auto; height: auto; border-radius: 6px; background: #fff; }
|
|
244
|
+
.frameos-manager__status { min-height: 1.2em; font-size: 12px; opacity: 0.7; }
|
|
245
|
+
.frameos-manager__status--error { color: #dc2626; opacity: 1; }
|
|
246
|
+
.frameos-manager__controls { display: flex; flex-direction: column; gap: 8px; }
|
|
247
|
+
.frameos-manager__row { display: flex; align-items: center; gap: 8px; }
|
|
248
|
+
.frameos-manager__row--buttons { flex-wrap: wrap; }
|
|
249
|
+
.frameos-manager__label { min-width: 120px; font-weight: 600; }
|
|
250
|
+
.frameos-manager__input { flex: 1; padding: 6px 8px; border: 1px solid #cbd5e1; border-radius: 8px; background: inherit; color: inherit; }
|
|
251
|
+
.frameos-manager__button { padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 8px; background: transparent; color: inherit; cursor: pointer; }
|
|
252
|
+
.frameos-manager__button:hover { background: rgba(100, 116, 139, 0.1); }
|
|
253
|
+
.frameos-manager__button--primary { background: #2563eb; border-color: #2563eb; color: #fff; align-self: flex-start; }
|
|
254
|
+
.frameos-manager__button--primary:hover { background: #1d4ed8; }
|
|
255
|
+
.frameos-manager__logs { max-height: 200px; overflow: auto; margin: 0; padding: 8px 10px; background: rgba(15, 23, 42, 0.05); border-radius: 8px; font-size: 11px; }
|
|
256
|
+
`;
|
|
257
|
+
document.head.appendChild(style);
|
|
258
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { FrameOSScene, PreviewFrame, SceneInfo } from './types';
|
|
2
|
+
export interface FrameOSPreviewOptions {
|
|
3
|
+
/** URL of the module worker script: `<assets>/preview-worker.js`. The
|
|
4
|
+
* frameos.js/frameos.wasm files must live next to it (same directory) —
|
|
5
|
+
* copy the package's `dist/assets/` folder somewhere same-origin. */
|
|
6
|
+
workerUrl: string | URL;
|
|
7
|
+
/** Render width/height in pixels (the frame's dimensions). */
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
/** The scenes to load — the parsed contents of a scenes.json. */
|
|
11
|
+
scenes: FrameOSScene[];
|
|
12
|
+
/** Scene to select initially; defaults to the runtime's default scene. */
|
|
13
|
+
sceneId?: string;
|
|
14
|
+
/** Frame name shown in logs. */
|
|
15
|
+
name?: string;
|
|
16
|
+
/** IANA time zone for the simulated frame; defaults to the browser's. */
|
|
17
|
+
timeZone?: string;
|
|
18
|
+
/** Frame settings (app API keys etc.); most previews run fine without. */
|
|
19
|
+
settings?: Record<string, unknown>;
|
|
20
|
+
/** Same-origin proxy endpoint for the runtime's HTTP requests. Without it,
|
|
21
|
+
* scenes fetching external data hit browser CORS limits. */
|
|
22
|
+
proxyUrl?: string;
|
|
23
|
+
/** Canvas to paint frames onto; can also be attached later. */
|
|
24
|
+
canvas?: HTMLCanvasElement | null;
|
|
25
|
+
onReady?: (sceneInfo: SceneInfo) => void;
|
|
26
|
+
onFrame?: (frame: PreviewFrame) => void;
|
|
27
|
+
onState?: (state: Record<string, unknown>) => void;
|
|
28
|
+
onLog?: (message: string) => void;
|
|
29
|
+
onSceneEvent?: (name: string, payload: Record<string, unknown>) => void;
|
|
30
|
+
onError?: (message: string) => void;
|
|
31
|
+
}
|
|
32
|
+
export declare class FrameOSPreview {
|
|
33
|
+
readonly options: FrameOSPreviewOptions;
|
|
34
|
+
private worker;
|
|
35
|
+
private canvas;
|
|
36
|
+
private pendingFrame;
|
|
37
|
+
private destroyed;
|
|
38
|
+
/** Latest scene info from the runtime (set once `ready` fires). */
|
|
39
|
+
sceneInfo: SceneInfo | null;
|
|
40
|
+
/** Latest public state of the current scene. */
|
|
41
|
+
state: Record<string, unknown>;
|
|
42
|
+
/** The scene currently selected in the runtime. */
|
|
43
|
+
currentSceneId: string | null;
|
|
44
|
+
constructor(options: FrameOSPreviewOptions);
|
|
45
|
+
private handleMessage;
|
|
46
|
+
/** Attach (or replace) the canvas frames are painted onto. */
|
|
47
|
+
attachCanvas(canvas: HTMLCanvasElement | null): void;
|
|
48
|
+
private paint;
|
|
49
|
+
/** Force a render now. */
|
|
50
|
+
render(): void;
|
|
51
|
+
/** Dispatch a scene event (a custom event node's keyword, "button", ...). */
|
|
52
|
+
sendEvent(name: string, payload?: Record<string, unknown>): void;
|
|
53
|
+
/** Update the current scene's state fields; renders by default. */
|
|
54
|
+
setSceneState(state: Record<string, unknown>, render?: boolean): void;
|
|
55
|
+
/** Switch the runtime to another loaded scene. */
|
|
56
|
+
selectScene(sceneId: string): void;
|
|
57
|
+
/** Terminate the worker; the instance cannot be reused afterwards. */
|
|
58
|
+
destroy(): void;
|
|
59
|
+
}
|
|
60
|
+
export declare function createFrameOSPreview(options: FrameOSPreviewOptions): FrameOSPreview;
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export class FrameOSPreview {
|
|
2
|
+
constructor(options) {
|
|
3
|
+
this.worker = null;
|
|
4
|
+
this.canvas = null;
|
|
5
|
+
this.pendingFrame = null;
|
|
6
|
+
this.destroyed = false;
|
|
7
|
+
/** Latest scene info from the runtime (set once `ready` fires). */
|
|
8
|
+
this.sceneInfo = null;
|
|
9
|
+
/** Latest public state of the current scene. */
|
|
10
|
+
this.state = {};
|
|
11
|
+
/** The scene currently selected in the runtime. */
|
|
12
|
+
this.currentSceneId = null;
|
|
13
|
+
this.options = options;
|
|
14
|
+
this.canvas = options.canvas ?? null;
|
|
15
|
+
this.currentSceneId = options.sceneId ?? null;
|
|
16
|
+
this.worker = new Worker(options.workerUrl, { type: 'module' });
|
|
17
|
+
this.worker.onerror = (event) => {
|
|
18
|
+
options.onError?.(event.message || 'FrameOS preview worker failed to load');
|
|
19
|
+
};
|
|
20
|
+
this.worker.onmessage = (event) => this.handleMessage(event.data ?? {});
|
|
21
|
+
this.worker.postMessage({
|
|
22
|
+
type: 'init',
|
|
23
|
+
width: options.width,
|
|
24
|
+
height: options.height,
|
|
25
|
+
name: options.name || 'frameos-wasm preview',
|
|
26
|
+
timeZone: options.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
|
27
|
+
scenesJson: JSON.stringify(options.scenes),
|
|
28
|
+
settingsJson: JSON.stringify(options.settings ?? {}),
|
|
29
|
+
proxyUrl: options.proxyUrl || '',
|
|
30
|
+
sceneId: options.sceneId || '',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
handleMessage(msg) {
|
|
34
|
+
if (this.destroyed) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
switch (msg.type) {
|
|
38
|
+
case 'ready':
|
|
39
|
+
this.sceneInfo = msg.sceneInfo ?? null;
|
|
40
|
+
if (this.sceneInfo?.currentSceneId) {
|
|
41
|
+
this.currentSceneId = this.sceneInfo.currentSceneId;
|
|
42
|
+
}
|
|
43
|
+
this.options.onReady?.(msg.sceneInfo);
|
|
44
|
+
break;
|
|
45
|
+
case 'frame':
|
|
46
|
+
this.pendingFrame = { width: msg.width, height: msg.height, buffer: msg.buffer };
|
|
47
|
+
this.paint();
|
|
48
|
+
this.options.onFrame?.({ width: msg.width, height: msg.height, renderMs: msg.renderMs });
|
|
49
|
+
break;
|
|
50
|
+
case 'state':
|
|
51
|
+
this.state = msg.state ?? {};
|
|
52
|
+
this.options.onState?.(this.state);
|
|
53
|
+
break;
|
|
54
|
+
case 'log':
|
|
55
|
+
this.options.onLog?.(String(msg.message ?? ''));
|
|
56
|
+
break;
|
|
57
|
+
case 'sceneEvent':
|
|
58
|
+
this.options.onSceneEvent?.(String(msg.name ?? ''), msg.payload ?? {});
|
|
59
|
+
break;
|
|
60
|
+
case 'error':
|
|
61
|
+
this.options.onError?.(String(msg.message ?? 'Unknown FrameOS preview error'));
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Attach (or replace) the canvas frames are painted onto. */
|
|
66
|
+
attachCanvas(canvas) {
|
|
67
|
+
this.canvas = canvas;
|
|
68
|
+
this.paint();
|
|
69
|
+
}
|
|
70
|
+
paint() {
|
|
71
|
+
const canvas = this.canvas;
|
|
72
|
+
const frame = this.pendingFrame;
|
|
73
|
+
if (!canvas || !frame || !frame.buffer.byteLength) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (canvas.width !== frame.width) {
|
|
77
|
+
canvas.width = frame.width;
|
|
78
|
+
}
|
|
79
|
+
if (canvas.height !== frame.height) {
|
|
80
|
+
canvas.height = frame.height;
|
|
81
|
+
}
|
|
82
|
+
const context = canvas.getContext('2d');
|
|
83
|
+
if (!context) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
context.putImageData(new ImageData(new Uint8ClampedArray(frame.buffer), frame.width, frame.height), 0, 0);
|
|
87
|
+
}
|
|
88
|
+
/** Force a render now. */
|
|
89
|
+
render() {
|
|
90
|
+
this.worker?.postMessage({ type: 'render' });
|
|
91
|
+
}
|
|
92
|
+
/** Dispatch a scene event (a custom event node's keyword, "button", ...). */
|
|
93
|
+
sendEvent(name, payload = {}) {
|
|
94
|
+
this.worker?.postMessage({ type: 'event', name, payload });
|
|
95
|
+
}
|
|
96
|
+
/** Update the current scene's state fields; renders by default. */
|
|
97
|
+
setSceneState(state, render = true) {
|
|
98
|
+
this.sendEvent('setSceneState', { state, render });
|
|
99
|
+
}
|
|
100
|
+
/** Switch the runtime to another loaded scene. */
|
|
101
|
+
selectScene(sceneId) {
|
|
102
|
+
this.currentSceneId = sceneId;
|
|
103
|
+
this.worker?.postMessage({ type: 'selectScene', sceneId });
|
|
104
|
+
}
|
|
105
|
+
/** Terminate the worker; the instance cannot be reused afterwards. */
|
|
106
|
+
destroy() {
|
|
107
|
+
this.destroyed = true;
|
|
108
|
+
this.worker?.terminate();
|
|
109
|
+
this.worker = null;
|
|
110
|
+
this.pendingFrame = null;
|
|
111
|
+
this.canvas = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function createFrameOSPreview(options) {
|
|
115
|
+
return new FrameOSPreview(options);
|
|
116
|
+
}
|