Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion .github/workflows/docker-publish-multi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ jobs:
VERSION=$(python3 -c "import json; print(json.load(open('versions.json'))['docker'].split('+', 1)[0])")
git config user.name "frameos-bot"
git config user.email "git@frameos.net"
git add versions.json
# update_versions.py syncs the wasm package to the FrameOS runtime
# version and the editor package to its independent frontend hash.
git add versions.json frameos/wasm/package.json frameos/editor/package.json
git commit -m "chore: version ${VERSION}"
git push origin HEAD:main

Expand All @@ -66,6 +68,79 @@ jobs:
echo "docker_version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=v$VERSION" >> "$GITHUB_OUTPUT"

publish-npm:
name: Publish FrameOS npm packages
needs: update-versions
runs-on: ubuntu-latest
steps:
- name: Checkout release commit
uses: actions/checkout@v6
with:
ref: ${{ needs.update-versions.outputs.release_sha }}

- uses: pnpm/action-setup@v4
with:
version: 10.27.0
run_install: false

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
registry-url: 'https://registry.npmjs.org'

- uses: jiro4989/setup-nim-action@v1
with:
nim-version: 2.2.4
repo-token: ${{ secrets.GITHUB_TOKEN }}

- uses: mymindstorm/setup-emsdk@v14
with:
version: 6.0.2

- name: Install dependencies
run: pnpm install --frozen-lockfile

# The runtime assets (frameos.js/frameos.wasm) are build outputs, not
# committed — compile them like the Dockerfile does.
- name: Build the wasm runtime
run: |
cd frameos
nimble install -d -y
./tools/build_wasm.sh

- name: Build frameos-wasm
run: pnpm --dir frameos/wasm run build

# The embedded editor bundle needs the full frontend build (its build
# config lives in frontend/build.mjs).
- name: Build the frontend and frameos-editor
run: |
pnpm --dir frontend run build
pnpm --dir frameos/editor run build

- name: Publish frameos-wasm and frameos-editor
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "NPM_TOKEN secret is not set — skipping npm publish."
exit 0
fi
for package in frameos/wasm frameos/editor; do
name=$(node -p "require('./${package}/package.json').name")
version=$(node -p "require('./${package}/package.json').version")
# Each package version follows its own content-owning component;
# skip only when that exact component version already exists.
if npm view "${name}@${version}" version >/dev/null 2>&1; then
echo "${name}@${version} already on npm — skipping."
continue
fi
(cd "$package" && npm publish --access public)
done

release-notes:
name: Generate Release Notes
needs: update-versions
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,5 @@ release-assets/
# wasm live-preview bundle (built by frameos/tools/build_wasm.sh)
frontend/public/frameos-wasm/
frameos/build/wasm/
dist-editor
frontend/editor-smoke.png
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@
- Build pipeline orchestrated by `build.mjs` using esbuild, with Tailwind/PostCSS for styling and optional bundle analysis via `vite-bundle-visualizer`.
- Development: `pnpm install` followed by `pnpm --dir frontend run dev` (spawns kea typegen watch and esbuild dev build concurrently).
- Repo-level local development runner: `pnpm dev` starts `mprocs` with panes for backend API, ARQ worker, the main frontend dev server, and the frame-local frontend watcher. `redis`, `frameos`, and `backend-docker` panes are available but do not autostart. The `backend-docker` pane runs `scripts/backend-docker.sh`, which persists a generated Docker `SECRET_KEY` in the gitignored `.env.docker.local`. `mprocs.yaml` defines the process list.
- Production build: `pnpm --dir frontend run build` which chains kea codegen, schema generation (`ts-json-schema-generator`), TypeScript type-checking, and final bundling to `dist/`. 【F:frontend/package.json†L6-L66】- Output folder is consumed by the backend’s static file mounts; ensure `frontend/dist` exists (e.g., via `pnpm --dir frontend run build`) before running the Python app outside of test mode. 【F:backend/app/fastapi.py†L38-L86】
- Production build: `pnpm --dir frontend run build` which chains kea codegen, schema generation (`ts-json-schema-generator`), TypeScript type-checking, and final bundling to `dist/`. 【F:frontend/package.json†L6-L66】
- The published `frameos-editor` package has its own `editor` component hash/version in `versions.json`; `project-folders.json` includes shared frontend sources so frontend-only editor changes receive a new npm version during release.
- Output folder is consumed by the backend’s static file mounts; ensure `frontend/dist` exists (e.g., via `pnpm --dir frontend run build`) before running the Python app outside of test mode. 【F:backend/app/fastapi.py†L38-L86】
- ALWAYS prefer writing frontend business logic in kea logic files over using effects like `useState` or `useEffect`.
- This includes small functions and callbacks inside components. Prefer to keep as much code as possible in logic files, treating React as a templating layer.
- When adding a frame model key that is tracked for deploy changes in `frontend/src/scenes/frame/frameLogic.ts`, also add a marker to `FRAME_KEY_INTRODUCED_FRAMEOS_VERSION`. For unreleased work, use the next patch after the current `versions.json` FrameOS base version.
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml versions.json ./
COPY frontend/package.json frontend/package.json
COPY frameos/frontend/package.json frameos/frontend/package.json
COPY frameos/wasm/package.json frameos/wasm/package.json
COPY frameos/editor/package.json frameos/editor/package.json
RUN pnpm install --frozen-lockfile

COPY frameos/frameos.nimble frameos/nimble.lock frameos/nim.cfg frameos/config.nims frameos/
Expand Down
70 changes: 70 additions & 0 deletions frameos/editor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frameos-editor

The [FrameOS](https://frameos.net) visual scene editor — the same node-graph editor the FrameOS
backend ships — as an embeddable static bundle. No backend needed: the app catalog and app sources
are embedded at build time, scenes go in and come back out as JSON over `postMessage`. The editor
carries the full scene workspace: the node diagram plus side panels for scene settings, state
variables, the app catalog, events and raw scene JSON; app sources open in a Monaco editor modal.
When the [`frameos-wasm`](https://www.npmjs.com/package/frameos-wasm) package's assets are served
next to the bundle (at `./frameos-wasm/` relative to `dist/`), the Preview panel runs the edited
scenes live through the FrameOS WebAssembly runtime — canvas, scene state and runtime logs included.

The package version always equals the FrameOS release it was built from.

**License: AGPL-3.0-only.** The editor is FrameOS code. The intended embedding model is an iframe
served from your own host, talking to your page over the documented `postMessage` protocol — the
editor stays a separate program at arm's length, whatever the license of the embedding page. If you
modify the bundle itself, AGPL terms apply to those modifications.

## Usage

Serve this package's `dist/` directory from your host (e.g. copy it to `/frameos-editor/`), then:

```js
import { createFrameOSEditor } from 'frameos-editor'

const editor = createFrameOSEditor({
container: document.getElementById('editor'),
url: '/frameos-editor/index.html',
scenes, // parsed scenes.json
width: 800,
height: 480,
onScenesChanged: (scenes) => console.log('edited', scenes),
})

const edited = await editor.getScenes()
editor.destroy()
```

## postMessage protocol

Parent → editor:

- `{type: 'frameos-editor:init', scenes, sceneId?, mode?, width?, height?, interval?, theme?, previewProxyUrl?, description?}` —
`theme` is `'light' | 'dark'`; `previewProxyUrl` is an optional same-origin endpoint the in-editor wasm live
preview routes CORS-blocked HTTP requests through; `description` is the embedding page's description of the
scene, shown in the Scene settings panel
- `{type: 'frameos-editor:get-scenes'}` — replies with a `:scenes` message
- `{type: 'frameos-editor:select-scene', sceneId}`

Editor → parent:

- `{type: 'frameos-editor:ready'}` — once listening (the helper auto-sends `init` on this)
- `{type: 'frameos-editor:scenes', scenes}` — after every edit (debounced) and as the
`:get-scenes` reply
- `{type: 'frameos-editor:save-screenshot', dataUrl, sceneId}` — the Preview panel captured a frame;
reply with `{type: 'frameos-editor:screenshot-saved', ok, error?, fallbackDownload?}` to store it
yourself, or stay silent and the editor downloads the PNG locally after a short timeout

## Demo

`demo.html` shows the scene list, the editor, and (when the [`frameos-wasm`](https://www.npmjs.com/package/frameos-wasm)
package's assets are served next to it at `./frameos-wasm/`) a live WebAssembly preview of the
edited scenes — everything running in the browser.

## Development (FrameOS repo)

The bundle is built by `frontend/build.mjs` ("FrameOS Embedded Editor" config: the regular editor
code with `frameLogic`/`logsLogic` swapped for in-memory shims, see `frontend/src/embed/`) into
`frontend/dist-editor/`, and copied into this package's `dist/` by `npm run build`. Smoke test:
`node frontend/scripts/smokeEditorEmbed.mjs`.
118 changes: 118 additions & 0 deletions frameos/editor/demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FrameOS scene editor — demo</title>
<style>
body { margin: 0; font: 14px/1.5 system-ui, sans-serif; color: #0f172a; }
.demo { display: grid; grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr); gap: 12px; height: 100vh; padding: 12px; box-sizing: border-box; }
.demo__editor { border: 1px solid #cbd5e1; border-radius: 12px; overflow: hidden; min-height: 0; }
.demo__side { display: flex; flex-direction: column; gap: 10px; min-height: 0; overflow: auto; }
.demo__side h2 { margin: 0; font-size: 15px; }
.demo__scenes button { display: block; width: 100%; text-align: left; padding: 6px 10px; margin-bottom: 4px; border: 1px solid #cbd5e1; border-radius: 8px; background: #fff; cursor: pointer; }
.demo__scenes button.active { background: #e2e8f0; font-weight: 600; }
.demo__preview { border: 1px solid #cbd5e1; border-radius: 12px; padding: 10px; }
.hint { color: #64748b; font-size: 12px; }
</style>
</head>
<body>
<div class="demo">
<div class="demo__editor" id="editor"></div>
<div class="demo__side">
<h2>Scenes</h2>
<div class="demo__scenes" id="scenes"></div>
<h2>Live preview</h2>
<div class="demo__preview" id="preview">
<p class="hint" id="preview-hint">
Serve the <code>frameos-wasm</code> package assets next to this page (at <code>./frameos-wasm/</code>)
to run the edited scenes live in WebAssembly.
</p>
</div>
<p class="hint">
Everything here runs in your browser: the editor (this package, AGPL) edits the scenes JSON,
and the wasm runtime renders it — the same code a FrameOS frame runs.
</p>
</div>
</div>
<script type="module">
import { createFrameOSEditor } from './embed.js'

const demoScenes = [
{
id: 'demo-scene',
name: 'Demo scene',
default: true,
nodes: [
{ id: 'ev-render', type: 'event', position: { x: 0, y: 60 }, data: { keyword: 'render' } },
{
id: 'app-color',
type: 'app',
position: { x: 320, y: 60 },
data: { keyword: 'render/color', config: { color: '#1d4ed8' } },
},
{
id: 'app-text',
type: 'app',
position: { x: 640, y: 60 },
data: { keyword: 'render/text', config: { text: 'Hello from the FrameOS editor!' } },
},
],
edges: [
{ id: 'e-1', source: 'ev-render', target: 'app-color', sourceHandle: 'next', targetHandle: 'prev' },
{ id: 'e-2', source: 'app-color', target: 'app-text', sourceHandle: 'next', targetHandle: 'prev' },
],
fields: [],
},
]

const sceneList = document.getElementById('scenes')
let preview = null
let previewTimer = null

const editor = createFrameOSEditor({
container: document.getElementById('editor'),
url: './dist/index.html',
scenes: demoScenes,
width: 800,
height: 480,
onScenesChanged: (scenes) => {
renderSceneList(scenes)
// Debounce full wasm reloads while dragging things around.
clearTimeout(previewTimer)
previewTimer = setTimeout(() => startPreview(scenes), 1000)
},
})
renderSceneList(demoScenes)
startPreview(demoScenes)

function renderSceneList(scenes) {
sceneList.innerHTML = ''
for (const scene of scenes) {
const button = document.createElement('button')
button.textContent = scene.name || scene.id
button.onclick = () => editor.selectScene(scene.id)
sceneList.appendChild(button)
}
}

async function startPreview(scenes) {
let mountFrameOSManager
try {
;({ mountFrameOSManager } = await import('./frameos-wasm/index.js'))
} catch {
return // wasm package not served next to the demo; keep the hint
}
document.getElementById('preview-hint')?.remove()
preview?.destroy()
preview = mountFrameOSManager(document.getElementById('preview'), {
workerUrl: new URL('./frameos-wasm/assets/preview-worker.js', location.href),
width: 800,
height: 480,
scenes,
showLogs: false,
})
}
</script>
</body>
</html>
43 changes: 43 additions & 0 deletions frameos/editor/embed.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export interface FrameOSEditorOptions {
/** Element the editor iframe is appended to. */
container: HTMLElement
/** URL of the editor bundle's index.html (serve this package's dist/). */
url: string
/** The scenes to edit — the parsed contents of a scenes.json. */
scenes: Record<string, unknown>[]
/** Scene to open initially; defaults to the default scene. */
sceneId?: string
/** Frame mode; decides code-node language (default 'rpios'). */
mode?: string
width?: number
height?: number
interval?: number
/** Editor color theme; defaults to the browser's preferred color scheme. */
theme?: 'light' | 'dark'
/**
* Same-origin endpoint the in-editor wasm live preview routes CORS-blocked
* HTTP requests through (appended as `?url=`-style proxy by the runtime).
* Without it the preview still runs, but scenes fetching external data may
* render incompletely.
*/
previewProxyUrl?: string
/**
* The embedding page's description of the scene (scenes.json doesn't carry
* one); shown in the editor's Scene settings panel.
*/
description?: string
/** Fires (debounced) after every edit with the full scenes array. */
onScenesChanged?: (scenes: Record<string, unknown>[]) => void
onReady?: () => void
}

export interface FrameOSEditorHandle {
iframe: HTMLIFrameElement
getScenesSync: () => Record<string, unknown>[]
getScenes: () => Promise<Record<string, unknown>[]>
setScenes: (scenes: Record<string, unknown>[], sceneId?: string) => void
selectScene: (sceneId: string) => void
destroy: () => void
}

export function createFrameOSEditor(options: FrameOSEditorOptions): FrameOSEditorHandle
Loading
Loading