diff --git a/.github/workflows/docker-publish-multi.yml b/.github/workflows/docker-publish-multi.yml index 82d0cd1cb..834a72df4 100644 --- a/.github/workflows/docker-publish-multi.yml +++ b/.github/workflows/docker-publish-multi.yml @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index 95ed1e2d1..5f7e4634d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 268a6aee0..5525d37b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile index 964bf02f2..52258873c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ diff --git a/frameos/editor/README.md b/frameos/editor/README.md new file mode 100644 index 000000000..280f4fce6 --- /dev/null +++ b/frameos/editor/README.md @@ -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`. diff --git a/frameos/editor/demo.html b/frameos/editor/demo.html new file mode 100644 index 000000000..ed423af35 --- /dev/null +++ b/frameos/editor/demo.html @@ -0,0 +1,118 @@ + + + + + + FrameOS scene editor — demo + + + +
+
+
+

Scenes

+
+

Live preview

+
+

+ Serve the frameos-wasm package assets next to this page (at ./frameos-wasm/) + to run the edited scenes live in WebAssembly. +

+
+

+ 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. +

+
+
+ + + diff --git a/frameos/editor/embed.d.ts b/frameos/editor/embed.d.ts new file mode 100644 index 000000000..882325d8e --- /dev/null +++ b/frameos/editor/embed.d.ts @@ -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[] + /** 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[]) => void + onReady?: () => void +} + +export interface FrameOSEditorHandle { + iframe: HTMLIFrameElement + getScenesSync: () => Record[] + getScenes: () => Promise[]> + setScenes: (scenes: Record[], sceneId?: string) => void + selectScene: (sceneId: string) => void + destroy: () => void +} + +export function createFrameOSEditor(options: FrameOSEditorOptions): FrameOSEditorHandle diff --git a/frameos/editor/embed.js b/frameos/editor/embed.js new file mode 100644 index 000000000..f70fc1c6a --- /dev/null +++ b/frameos/editor/embed.js @@ -0,0 +1,118 @@ +// Parent-side helper for embedding the FrameOS scene editor. Creates an +// iframe pointing at the editor bundle (this package's dist/index.html, +// served from your own host) and speaks its postMessage protocol: +// in: {type: 'frameos-editor:init', scenes, sceneId?, mode?, width?, height?, interval?, theme?, previewProxyUrl?, description?} +// {type: 'frameos-editor:get-scenes'} · {type: 'frameos-editor:select-scene', sceneId} +// out: {type: 'frameos-editor:ready'} · {type: 'frameos-editor:scenes', scenes} +// {type: 'frameos-editor:save-screenshot', dataUrl, sceneId} (ack with 'frameos-editor:screenshot-saved') +// +// The editor bundle is AGPL-licensed FrameOS code running at arm's length in +// its own frame; this helper is part of the same package. + +export function createFrameOSEditor({ + container, + url, + scenes, + sceneId, + mode = 'rpios', + width = 800, + height = 480, + interval = 300, + theme, + previewProxyUrl, + description, + onScenesChanged, + onReady, +}) { + const iframe = document.createElement('iframe') + iframe.src = url + iframe.style.width = '100%' + iframe.style.height = '100%' + iframe.style.border = '0' + container.appendChild(iframe) + + const editorOrigin = new URL(url, location.href).origin + let latestScenes = scenes + let latestSceneId = sceneId + let ready = false + const sceneWaiters = [] + + const onMessage = (event) => { + if (event.source !== iframe.contentWindow || event.origin !== editorOrigin) { + return + } + const message = event.data + if (!message || typeof message !== 'object') { + return + } + if (message.type === 'frameos-editor:ready') { + ready = true + post({ + type: 'frameos-editor:init', + scenes: latestScenes, + sceneId: latestSceneId, + mode, + width, + height, + interval, + theme, + previewProxyUrl, + description, + }) + onReady?.() + } else if (message.type === 'frameos-editor:scenes' && Array.isArray(message.scenes)) { + latestScenes = message.scenes + while (sceneWaiters.length > 0) { + sceneWaiters.shift()(message.scenes) + } + onScenesChanged?.(message.scenes) + } + } + + function post(message) { + iframe.contentWindow?.postMessage(message, editorOrigin) + } + + window.addEventListener('message', onMessage) + + return { + iframe, + /** Latest scenes as reported by the editor (kept current on every edit). */ + getScenesSync: () => latestScenes, + /** Ask the editor for its current scenes. */ + getScenes: () => + new Promise((resolve) => { + if (!ready) { + resolve(latestScenes) + return + } + sceneWaiters.push(resolve) + post({ type: 'frameos-editor:get-scenes' }) + }), + /** Replace the loaded scenes (re-initializes the editor). */ + setScenes: (nextScenes, nextSceneId) => { + latestScenes = nextScenes + latestSceneId = nextSceneId + post({ + type: 'frameos-editor:init', + scenes: nextScenes, + sceneId: nextSceneId, + mode, + width, + height, + interval, + theme, + previewProxyUrl, + description, + }) + }, + selectScene: (nextSceneId) => { + latestSceneId = nextSceneId + post({ type: 'frameos-editor:select-scene', sceneId: nextSceneId }) + }, + destroy: () => { + window.removeEventListener('message', onMessage) + iframe.remove() + }, + } +} diff --git a/frameos/editor/embed.test.mjs b/frameos/editor/embed.test.mjs new file mode 100644 index 000000000..b4b6a59a0 --- /dev/null +++ b/frameos/editor/embed.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createFrameOSEditor } from './embed.js' + +test('setScenes keeps its selected scene until the iframe is ready', () => { + const messages = [] + const listeners = new Map() + const contentWindow = { + postMessage: (message, origin) => messages.push({ message, origin }), + } + const iframe = { + contentWindow, + remove: () => {}, + style: {}, + } + globalThis.document = { createElement: () => iframe } + globalThis.location = new URL('https://host.example/page') + globalThis.window = { + addEventListener: (type, listener) => listeners.set(type, listener), + removeEventListener: (type) => listeners.delete(type), + } + + const editor = createFrameOSEditor({ + container: { appendChild: () => {} }, + url: 'https://host.example/editor/', + scenes: [{ id: 'old' }], + sceneId: 'old', + }) + editor.setScenes([{ id: 'replacement' }], 'replacement') + + listeners.get('message')({ + source: contentWindow, + origin: 'https://host.example', + data: { type: 'frameos-editor:ready' }, + }) + + assert.deepEqual(messages.at(-1).message, { + type: 'frameos-editor:init', + scenes: [{ id: 'replacement' }], + sceneId: 'replacement', + mode: 'rpios', + width: 800, + height: 480, + interval: 300, + theme: undefined, + previewProxyUrl: undefined, + description: undefined, + }) + editor.destroy() +}) diff --git a/frameos/editor/package.json b/frameos/editor/package.json new file mode 100644 index 000000000..2eb6fa5df --- /dev/null +++ b/frameos/editor/package.json @@ -0,0 +1,31 @@ +{ + "name": "frameos-editor", + "version": "2026.7.3", + "description": "The FrameOS visual scene editor as an embeddable static bundle: serve it from any host, embed it in an iframe, and exchange scenes JSON over postMessage.", + "keywords": ["frameos", "editor", "scenes", "node-editor", "e-ink", "smart-frame"], + "homepage": "https://frameos.net", + "repository": { + "type": "git", + "url": "https://github.com/FrameOS/frameos.git", + "directory": "frameos/editor" + }, + "license": "AGPL-3.0-only", + "type": "module", + "main": "./embed.js", + "types": "./embed.d.ts", + "exports": { + ".": { + "types": "./embed.d.ts", + "import": "./embed.js" + }, + "./dist/*": "./dist/*", + "./package.json": "./package.json" + }, + "files": ["dist", "!dist/**/*.map", "embed.js", "embed.d.ts", "demo.html", "README.md"], + "scripts": { + "build": "node scripts/build.mjs", + "test": "node --test embed.test.mjs", + "sync-version": "node scripts/sync-version.mjs", + "prepublishOnly": "node scripts/sync-version.mjs --check && npm run build" + } +} diff --git a/frameos/editor/scripts/build.mjs b/frameos/editor/scripts/build.mjs new file mode 100644 index 000000000..1b5b039f0 --- /dev/null +++ b/frameos/editor/scripts/build.mjs @@ -0,0 +1,19 @@ +// Package the embedded editor bundle: frontend/dist-editor (built by the +// frontend's build.mjs "FrameOS Embedded Editor" config) is copied into +// dist/. Run the frontend build first; the release workflow does. +import { cpSync, existsSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))) +const bundleSource = join(packageDir, '..', '..', 'frontend', 'dist-editor') +const bundleTarget = join(packageDir, 'dist') + +if (!existsSync(join(bundleSource, 'index.html'))) { + console.error(`Missing ${bundleSource}/index.html — build the frontend first: pnpm --dir frontend run build`) + process.exit(1) +} + +rmSync(bundleTarget, { force: true, recursive: true }) +cpSync(bundleSource, bundleTarget, { recursive: true }) +console.log(`Copied ${bundleSource} to dist/`) diff --git a/frameos/editor/scripts/sync-version.mjs b/frameos/editor/scripts/sync-version.mjs new file mode 100644 index 000000000..a42f74553 --- /dev/null +++ b/frameos/editor/scripts/sync-version.mjs @@ -0,0 +1,33 @@ +// Keep this package's version identical to the independent editor component +// version (without its content hash). +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))) +const versionsPath = join(packageDir, '..', '..', 'versions.json') +const packageJsonPath = join(packageDir, 'package.json') + +const versions = JSON.parse(readFileSync(versionsPath, 'utf8')) +const editorVersion = String(versions.editor ?? '').split('+')[0] +if (!/^\d{4}\.\d{1,2}\.\d+$/.test(editorVersion)) { + console.error(`Unexpected editor version in versions.json: ${versions.editor}`) + process.exit(1) +} + +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) +if (packageJson.version === editorVersion) { + console.log(`frameos-editor version already ${editorVersion}`) + process.exit(0) +} + +if (process.argv.includes('--check')) { + console.error( + `frameos-editor version ${packageJson.version} != editor version ${editorVersion}. Run: npm run sync-version` + ) + process.exit(1) +} + +packageJson.version = editorVersion +writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n') +console.log(`frameos-editor version set to ${editorVersion}`) diff --git a/frameos/tools/wasm/frameos_library.js b/frameos/tools/wasm/frameos_library.js index 8f7ba77af..4ad52163f 100644 --- a/frameos/tools/wasm/frameos_library.js +++ b/frameos/tools/wasm/frameos_library.js @@ -43,10 +43,10 @@ addToLibrary({ {{{ makeSetValue('outLenPtr', 0, 0, 'i32') }}} var method = UTF8ToString(methodPtr) var url = UTF8ToString(urlPtr) - // When a same-origin backend proxy is configured, route the request - // through it: the browser can't fetch most external URLs directly (CORS), - // but the backend can — mirroring how the device fetches server-side. The - // proxy fetch is same-origin, so auth cookies flow and CORS doesn't apply. + // When a same-origin backend proxy is configured, it is the FALLBACK: + // requests go straight from the browser first (client-side, no server + // load), and only CORS/network failures are retried through the proxy, + // which fetches server-side like the device would. var proxyUrl = Module['frameosProxyUrl'] try { var body = null @@ -66,10 +66,30 @@ addToLibrary({ } } - var xhr = new XMLHttpRequest() - // Synchronous: the Nim/pixie pipeline is fully synchronous and this + // Synchronous XHR: the Nim/pixie pipeline is fully synchronous and this // module runs in a Web Worker, where sync XHR is permitted. - if (proxyUrl) { + var directRequest = function () { + var xhr = new XMLHttpRequest() + xhr.open(method, url, false) + try { + xhr.responseType = 'arraybuffer' + if (timeoutMs > 0) xhr.timeout = timeoutMs + } catch (e) { + // Main-thread fallback: sync XHR only supports text responses there. + } + for (var name in headers) { + try { + xhr.setRequestHeader(name, headers[name]) + } catch (e) { + // Forbidden header names (User-Agent & co) throw; skip them. + } + } + xhr.send(body) + return xhr + } + + var proxyRequest = function () { + var xhr = new XMLHttpRequest() xhr.open('POST', proxyUrl, false) xhr.withCredentials = true try { @@ -86,22 +106,21 @@ addToLibrary({ xhr.send( JSON.stringify({ method: method, url: url, headers: headers, bodyBase64: bodyBase64, timeoutMs: timeoutMs }) ) - } else { - xhr.open(method, url, false) - try { - xhr.responseType = 'arraybuffer' - if (timeoutMs > 0) xhr.timeout = timeoutMs - } catch (e) { - // Main-thread fallback: sync XHR only supports text responses there. - } - for (var name in headers) { - try { - xhr.setRequestHeader(name, headers[name]) - } catch (e) { - // Forbidden header names (User-Agent & co) throw; skip them. - } - } - xhr.send(body) + return xhr + } + + var xhr = null + var directError = null + try { + xhr = directRequest() + } catch (e) { + // A cross-origin host without CORS headers throws on sync send. + directError = e + } + if ((xhr === null || xhr.status === 0) && proxyUrl) { + xhr = proxyRequest() + } else if (xhr === null) { + throw directError } var bytes diff --git a/frameos/wasm/README.md b/frameos/wasm/README.md new file mode 100644 index 000000000..0dfa95a4a --- /dev/null +++ b/frameos/wasm/README.md @@ -0,0 +1,72 @@ +# frameos-wasm + +Run [FrameOS](https://frameos.net) scenes in the browser through WebAssembly. The package wraps the +emscripten-built FrameOS scene runtime with a typed API and ships a minimal management interface: +a live canvas, scene switching, showIf-aware state fields, event buttons, and logs — the same +control surface a frame exposes on-device. + +The package version always equals the FrameOS release the runtime was built from. + +## Install + +```sh +npm install frameos-wasm +``` + +The runtime assets (`frameos.js`, `frameos.wasm`, `preview-worker.js`) live in +`frameos-wasm/dist/assets/`. They must be served **same-origin** (the runtime runs in a module Web +Worker and uses synchronous XHR): copy that directory into your static assets, e.g. to +`/frameos-wasm/`. + +## Quick start — management interface + +```ts +import { mountFrameOSManager } from 'frameos-wasm' + +const handle = mountFrameOSManager(document.getElementById('preview')!, { + workerUrl: '/frameos-wasm/preview-worker.js', + width: 800, + height: 480, + scenes, // parsed scenes.json (from a template zip, backup, or export) +}) +// later: handle.preview.sendEvent('myButton'), handle.destroy() +``` + +## Lower level — just the runtime + +```ts +import { createFrameOSPreview } from 'frameos-wasm' + +const preview = createFrameOSPreview({ + workerUrl: '/frameos-wasm/preview-worker.js', + width: 800, + height: 480, + scenes, + canvas: document.querySelector('canvas'), + onLog: (line) => console.log(line), + onState: (state) => console.log('scene state', state), +}) +preview.setSceneState({ message: 'Hello' }) +preview.sendEvent('button', { label: 'a' }) +preview.selectScene('sceneId') +preview.destroy() +``` + +Helpers for building your own UI are exported too: `evaluateShowIf`, `visiblePublicStateFields`, +`coerceStateFieldValue`, `sceneEventButtons`, and the `StateField`/`FrameOSScene` types. + +## Notes + +- Scenes that fetch external URLs are subject to browser CORS unless you pass `proxyUrl` (a + same-origin endpoint that forwards `{method, url, headers, bodyBase64, timeoutMs}` — see + FrameOS's `/api/frames/{id}/scene_preview_proxy`). +- Apps that need host processes are not in the wasm build: `data/chromiumScreenshot`, + `data/rstpSnapshot`, `data/localImage`. +- `index.html` in the package root is a standalone demo page: + `npx serve node_modules/frameos-wasm` and paste a scenes.json. + +## Development (FrameOS repo) + +The runtime assets are built by `frameos/tools/build_wasm.sh` into `frontend/public/frameos-wasm` +and copied into `dist/assets` by `npm run build`. `npm run sync-version` copies the FrameOS +version out of the repo's `versions.json`; publishing refuses to run when the two disagree. diff --git a/frameos/wasm/index.html b/frameos/wasm/index.html new file mode 100644 index 000000000..ed4daa727 --- /dev/null +++ b/frameos/wasm/index.html @@ -0,0 +1,66 @@ + + + + + + frameos-wasm — scene preview + + + +

frameos-wasm scene preview

+

+ Runs a FrameOS scene in your browser through the WebAssembly runtime, with the same state fields, event + buttons, and logs the frame itself exposes. Paste the contents of a scenes.json (from a template + zip, a backup, or an export) and press start. Serve this file over HTTP with the runtime assets next to it — + for example: npx serve node_modules/frameos-wasm and open /index.html. +

+
+ + + +
+ × + +
+ +
+
+ + + diff --git a/frameos/wasm/package.json b/frameos/wasm/package.json new file mode 100644 index 000000000..965eef859 --- /dev/null +++ b/frameos/wasm/package.json @@ -0,0 +1,34 @@ +{ + "name": "frameos-wasm", + "version": "2026.7.3", + "description": "Run FrameOS scenes in the browser via WebAssembly: a typed live-preview runtime plus a minimal showIf-aware management interface.", + "keywords": ["frameos", "wasm", "webassembly", "e-ink", "smart-frame"], + "homepage": "https://frameos.net", + "repository": { + "type": "git", + "url": "https://github.com/FrameOS/frameos.git", + "directory": "frameos/wasm" + }, + "license": "AGPL-3.0-only", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./assets/*": "./dist/assets/*", + "./package.json": "./package.json" + }, + "files": ["dist", "index.html", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.json && node scripts/build.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "sync-version": "node scripts/sync-version.mjs", + "prepublishOnly": "node scripts/sync-version.mjs --check && npm run build" + }, + "devDependencies": { + "typescript": "~5.9.3" + } +} diff --git a/frameos/wasm/scripts/build.mjs b/frameos/wasm/scripts/build.mjs new file mode 100644 index 000000000..ff4030c60 --- /dev/null +++ b/frameos/wasm/scripts/build.mjs @@ -0,0 +1,26 @@ +// Copy the wasm runtime assets into dist/assets. The assets are built by +// frameos/tools/build_wasm.sh into frontend/public/frameos-wasm (a gitignored +// build output — run that script first; the release workflow does). +import { copyFileSync, existsSync, mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))) +const assetsSource = join(packageDir, '..', '..', 'frontend', 'public', 'frameos-wasm') +const assetsTarget = join(packageDir, 'dist', 'assets') + +const files = ['frameos.js', 'frameos.wasm', 'preview-worker.js'] +for (const file of files) { + if (!existsSync(join(assetsSource, file))) { + console.error( + `Missing ${join(assetsSource, file)} — build the wasm runtime first: frameos/tools/build_wasm.sh` + ) + process.exit(1) + } +} + +mkdirSync(assetsTarget, { recursive: true }) +for (const file of files) { + copyFileSync(join(assetsSource, file), join(assetsTarget, file)) +} +console.log(`Copied ${files.join(', ')} to dist/assets`) diff --git a/frameos/wasm/scripts/sync-version.mjs b/frameos/wasm/scripts/sync-version.mjs new file mode 100644 index 000000000..c24412d25 --- /dev/null +++ b/frameos/wasm/scripts/sync-version.mjs @@ -0,0 +1,34 @@ +// Keep this package's version identical to the FrameOS release version (the +// `frameos` entry of versions.json at the repo root, without the content +// hash). Run with --check to fail instead of write (used by prepublishOnly). +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))) +const versionsPath = join(packageDir, '..', '..', 'versions.json') +const packageJsonPath = join(packageDir, 'package.json') + +const versions = JSON.parse(readFileSync(versionsPath, 'utf8')) +const frameosVersion = String(versions.frameos ?? '').split('+')[0] +if (!/^\d{4}\.\d{1,2}\.\d+$/.test(frameosVersion)) { + console.error(`Unexpected frameos version in versions.json: ${versions.frameos}`) + process.exit(1) +} + +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) +if (packageJson.version === frameosVersion) { + console.log(`frameos-wasm version already ${frameosVersion}`) + process.exit(0) +} + +if (process.argv.includes('--check')) { + console.error( + `frameos-wasm version ${packageJson.version} != FrameOS version ${frameosVersion}. Run: npm run sync-version` + ) + process.exit(1) +} + +packageJson.version = frameosVersion +writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n') +console.log(`frameos-wasm version set to ${frameosVersion}`) diff --git a/frameos/wasm/src/index.ts b/frameos/wasm/src/index.ts new file mode 100644 index 000000000..4a73e6ac7 --- /dev/null +++ b/frameos/wasm/src/index.ts @@ -0,0 +1,27 @@ +// frameos-wasm: run FrameOS scenes in the browser via WebAssembly. +// +// The runtime assets (frameos.js, frameos.wasm, preview-worker.js) ship in +// this package under ./assets — serve that directory same-origin and pass +// `workerUrl: '/preview-worker.js'`. Version tracks the FrameOS +// release the runtime was built from (versions.json in the FrameOS repo). +export { FrameOSPreview, createFrameOSPreview, type FrameOSPreviewOptions } from './preview' +export { mountFrameOSManager, type FrameOSManagerHandle, type FrameOSManagerOptions } from './manager' +export { + coerceStateFieldValue, + evaluateShowIf, + stateFieldShowIfValues, + visiblePublicStateFields, +} from './showIf' +export { + LIFECYCLE_EVENTS, + sceneEventButtons, + type ConfigFieldCondition, + type ConfigFieldConditionAnd, + type FrameOSScene, + type PreviewFrame, + type SceneEventButton, + type SceneInfo, + type SceneNode, + type ShowIfCondition, + type StateField, +} from './types' diff --git a/frameos/wasm/src/manager.ts b/frameos/wasm/src/manager.ts new file mode 100644 index 000000000..1f019f236 --- /dev/null +++ b/frameos/wasm/src/manager.ts @@ -0,0 +1,308 @@ +// A minimal, dependency-free management interface for a FrameOS preview: +// canvas, scene switcher, showIf-aware state fields, event buttons, and a log +// pane. Mirrors the device's own /control page (frameos/assets/web/control.html) +// but runs entirely in the browser against the wasm runtime. +import { FrameOSPreview, type FrameOSPreviewOptions } from './preview' +import { coerceStateFieldValue, evaluateShowIf, stateFieldShowIfValues } from './showIf' +import { sceneEventButtons, type FrameOSScene, type StateField } from './types' + +export interface FrameOSManagerOptions extends Omit { + /** Show the log pane (default true). */ + showLogs?: boolean + /** Max log lines kept (default 200). */ + maxLogLines?: number +} + +export interface FrameOSManagerHandle { + preview: FrameOSPreview + destroy: () => void +} + +/** + * Mount a self-contained preview + control surface into `container`. + * Returns the underlying FrameOSPreview for programmatic control. + */ +export function mountFrameOSManager(container: HTMLElement, options: FrameOSManagerOptions): FrameOSManagerHandle { + const showLogs = options.showLogs !== false + const maxLogLines = options.maxLogLines ?? 200 + + container.classList.add('frameos-manager') + container.innerHTML = '' + injectStyles() + + const stage = el('div', 'frameos-manager__stage') + const canvas = document.createElement('canvas') + canvas.className = 'frameos-manager__canvas' + canvas.width = options.width + canvas.height = options.height + stage.appendChild(canvas) + + const status = el('div', 'frameos-manager__status', 'Loading FrameOS runtime…') + const controls = el('div', 'frameos-manager__controls') + const logPane = el('pre', 'frameos-manager__logs') + logPane.style.display = showLogs ? '' : 'none' + + container.appendChild(stage) + container.appendChild(status) + container.appendChild(controls) + container.appendChild(logPane) + + // Values the user typed, overriding the runtime's reported state until the + // runtime confirms them via a state message. + let editedValues: Record = {} + let destroyed = false + const logLines: string[] = [] + + const appendLog = (line: string): void => { + logLines.push(line) + if (logLines.length > maxLogLines) { + logLines.splice(0, logLines.length - maxLogLines) + } + logPane.textContent = logLines.join('\n') + logPane.scrollTop = logPane.scrollHeight + } + + const sceneById = new Map() + for (const scene of options.scenes) { + sceneById.set(scene.id, scene) + } + + const preview = new FrameOSPreview({ + ...options, + canvas, + onReady: (sceneInfo) => { + status.textContent = '' + renderControls() + options.onReady?.(sceneInfo) + }, + onFrame: (frame) => { + status.textContent = `Rendered ${frame.width}×${frame.height} in ${frame.renderMs} ms` + options.onFrame?.(frame) + }, + onState: (state) => { + // Drop only the edits the runtime has confirmed; anything the user + // typed but has not applied yet must survive a render (or any other + // state report) instead of being silently reverted. + const unconfirmed: Record = {} + for (const [key, value] of Object.entries(editedValues)) { + if (String(state?.[key] ?? '') !== String(value ?? '')) { + unconfirmed[key] = value + } + } + editedValues = unconfirmed + // Rebuilding the form mid-typing would steal focus and discard the + // in-progress value; the next interaction re-renders anyway. + if (!controls.contains(document.activeElement)) { + renderControls() + } + options.onState?.(state) + }, + onLog: (message) => { + appendLog(message) + options.onLog?.(message) + }, + onSceneEvent: (name, payload) => { + appendLog(`event: ${name} ${JSON.stringify(payload)}`) + options.onSceneEvent?.(name, payload) + }, + onError: (message) => { + status.textContent = message + status.classList.add('frameos-manager__status--error') + appendLog(`error: ${message}`) + options.onError?.(message) + }, + }) + + function currentScene(): FrameOSScene | undefined { + return (preview.currentSceneId && sceneById.get(preview.currentSceneId)) || options.scenes[0] + } + + function publicFields(scene: FrameOSScene | undefined): StateField[] { + return (scene?.fields ?? []).filter((field) => field.access === 'public' && field.name) + } + + function fieldValues(scene: FrameOSScene | undefined): Record { + return stateFieldShowIfValues(publicFields(scene), preview.state, editedValues) + } + + function renderControls(): void { + if (destroyed) { + return + } + controls.innerHTML = '' + const scene = currentScene() + + // Scene switcher, when more than one scene is loaded. + const scenes = preview.sceneInfo?.scenes ?? [] + if (scenes.length > 1) { + const row = el('div', 'frameos-manager__row') + row.appendChild(el('label', 'frameos-manager__label', 'Scene')) + const select = document.createElement('select') + select.className = 'frameos-manager__input' + for (const item of scenes) { + const option = document.createElement('option') + option.value = item.id + option.textContent = item.name || item.id + option.selected = item.id === preview.currentSceneId + select.appendChild(option) + } + select.onchange = () => { + editedValues = {} + preview.selectScene(select.value) + renderControls() + } + row.appendChild(select) + controls.appendChild(row) + } + + // State fields, filtered by showIf against current + edited values. + const fields = publicFields(scene) + const values = fieldValues(scene) + const visible = fields.filter((field) => evaluateShowIf(field.showIf, values, field.name)) + for (const field of visible) { + controls.appendChild(fieldRow(field, values[field.name])) + } + // One row of actions: Apply & render (when there are fields), the + // scene's custom event buttons, and a plain Render. + const buttonRow = el('div', 'frameos-manager__row frameos-manager__row--buttons') + if (visible.length > 0) { + const apply = document.createElement('button') + apply.type = 'button' + apply.className = 'frameos-manager__button frameos-manager__button--primary' + apply.textContent = 'Apply & render' + apply.onclick = () => { + const state: Record = {} + for (const field of visible) { + const value = field.name in editedValues ? editedValues[field.name] : values[field.name] + if (value !== undefined) { + state[field.name] = coerceStateFieldValue(field, value) + } + } + preview.setSceneState(state) + } + buttonRow.appendChild(apply) + } + + // Custom event nodes as buttons. Listener nodes filter on payload values + // (a "button" listener with label "A" only fires for {label: "A"} — see + // eventNodeMatchesPayload in frameos' interpreter.nim), so the label must + // ride along in the payload, not just caption the button. + for (const event of sceneEventButtons(scene)) { + const button = document.createElement('button') + button.type = 'button' + button.className = 'frameos-manager__button' + button.textContent = event.label || event.keyword + button.onclick = () => preview.sendEvent(event.keyword, event.label ? { label: event.label } : {}) + buttonRow.appendChild(button) + } + const render = document.createElement('button') + render.type = 'button' + render.className = 'frameos-manager__button' + render.textContent = 'Render' + render.onclick = () => preview.render() + buttonRow.appendChild(render) + controls.appendChild(buttonRow) + } + + function fieldRow(field: StateField, value: unknown): HTMLElement { + const row = el('div', 'frameos-manager__row') + row.appendChild(el('label', 'frameos-manager__label', field.label || field.name)) + + const update = (next: unknown): void => { + editedValues = { ...editedValues, [field.name]: next } + // showIf conditions may depend on this field: re-render the form (a + // fresh element keeps focus handling simple — apply happens on click). + renderControls() + } + + if (field.type === 'select' || field.type === 'boolean' || field.type === 'font') { + const select = document.createElement('select') + select.className = 'frameos-manager__input' + const opts = field.type === 'boolean' ? ['true', 'false'] : field.options ?? [] + for (const opt of opts) { + const option = document.createElement('option') + option.value = opt + option.textContent = opt + option.selected = String(value ?? '') === opt + select.appendChild(option) + } + select.onchange = () => update(select.value) + row.appendChild(select) + } else if (field.type === 'text') { + const textarea = document.createElement('textarea') + textarea.className = 'frameos-manager__input' + textarea.rows = 3 + textarea.value = value === undefined || value === null ? '' : String(value) + textarea.onchange = () => update(textarea.value) + row.appendChild(textarea) + } else { + const input = document.createElement('input') + input.className = 'frameos-manager__input' + input.type = + field.type === 'integer' || field.type === 'float' + ? 'number' + : field.type === 'date' + ? 'date' + : field.type === 'color' + ? 'color' + : 'text' + if (field.type === 'float') { + input.step = 'any' + } + if (field.placeholder) { + input.placeholder = field.placeholder + } + input.value = value === undefined || value === null ? '' : String(value) + input.onchange = () => update(input.value) + row.appendChild(input) + } + return row + } + + return { + preview, + destroy: () => { + destroyed = true + preview.destroy() + container.innerHTML = '' + container.classList.remove('frameos-manager') + }, + } +} + +function el(tag: string, className: string, text?: string): HTMLElement { + const node = document.createElement(tag) + node.className = className + if (text) { + node.textContent = text + } + return node +} + +let stylesInjected = false +function injectStyles(): void { + if (stylesInjected || typeof document === 'undefined') { + return + } + stylesInjected = true + const style = document.createElement('style') + style.setAttribute('data-frameos-manager', '') + style.textContent = ` +.frameos-manager { display: flex; flex-direction: column; gap: 12px; font: 14px/1.4 system-ui, sans-serif; } +.frameos-manager__stage { display: flex; justify-content: center; background: #0f172a; border-radius: 12px; padding: 12px; } +.frameos-manager__canvas { max-width: 100%; max-height: min(60vh, 480px); width: auto; height: auto; border-radius: 6px; background: #fff; } +.frameos-manager__status { min-height: 1.2em; font-size: 12px; opacity: 0.7; } +.frameos-manager__status--error { color: #dc2626; opacity: 1; } +.frameos-manager__controls { display: flex; flex-direction: column; gap: 8px; } +.frameos-manager__row { display: flex; align-items: center; gap: 8px; } +.frameos-manager__row--buttons { flex-wrap: wrap; } +.frameos-manager__label { min-width: 120px; font-weight: 600; } +.frameos-manager__input { flex: 1; padding: 6px 8px; border: 1px solid #cbd5e1; border-radius: 8px; background: inherit; color: inherit; } +.frameos-manager__button { padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 8px; background: transparent; color: inherit; cursor: pointer; } +.frameos-manager__button:hover { background: rgba(100, 116, 139, 0.1); } +.frameos-manager__button--primary { background: #2563eb; border-color: #2563eb; color: #fff; align-self: flex-start; } +.frameos-manager__button--primary:hover { background: #1d4ed8; } +.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; } +` + document.head.appendChild(style) +} diff --git a/frameos/wasm/src/preview.ts b/frameos/wasm/src/preview.ts new file mode 100644 index 000000000..ecc754ec6 --- /dev/null +++ b/frameos/wasm/src/preview.ts @@ -0,0 +1,172 @@ +// Typed wrapper around the FrameOS preview worker (assets/preview-worker.js). +// The worker loads the emscripten-built scene runtime (frameos.js/frameos.wasm) +// and drives renders; this class owns the worker lifecycle, paints frames onto +// a canvas, and exposes events/state as callbacks. +import type { FrameOSScene, PreviewFrame, SceneInfo } from './types' + +export interface FrameOSPreviewOptions { + /** URL of the module worker script: `/preview-worker.js`. The + * frameos.js/frameos.wasm files must live next to it (same directory) — + * copy the package's `dist/assets/` folder somewhere same-origin. */ + workerUrl: string | URL + /** Render width/height in pixels (the frame's dimensions). */ + width: number + height: number + /** The scenes to load — the parsed contents of a scenes.json. */ + scenes: FrameOSScene[] + /** Scene to select initially; defaults to the runtime's default scene. */ + sceneId?: string + /** Frame name shown in logs. */ + name?: string + /** IANA time zone for the simulated frame; defaults to the browser's. */ + timeZone?: string + /** Frame settings (app API keys etc.); most previews run fine without. */ + settings?: Record + /** Same-origin proxy endpoint for the runtime's HTTP requests. Without it, + * scenes fetching external data hit browser CORS limits. */ + proxyUrl?: string + /** Canvas to paint frames onto; can also be attached later. */ + canvas?: HTMLCanvasElement | null + onReady?: (sceneInfo: SceneInfo) => void + onFrame?: (frame: PreviewFrame) => void + onState?: (state: Record) => void + onLog?: (message: string) => void + onSceneEvent?: (name: string, payload: Record) => void + onError?: (message: string) => void +} + +interface PendingFrame { + width: number + height: number + buffer: ArrayBuffer +} + +export class FrameOSPreview { + readonly options: FrameOSPreviewOptions + private worker: Worker | null = null + private canvas: HTMLCanvasElement | null = null + private pendingFrame: PendingFrame | null = null + private destroyed = false + + /** Latest scene info from the runtime (set once `ready` fires). */ + sceneInfo: SceneInfo | null = null + /** Latest public state of the current scene. */ + state: Record = {} + /** The scene currently selected in the runtime. */ + currentSceneId: string | null = null + + constructor(options: FrameOSPreviewOptions) { + this.options = options + this.canvas = options.canvas ?? null + this.currentSceneId = options.sceneId ?? null + + this.worker = new Worker(options.workerUrl, { type: 'module' }) + this.worker.onerror = (event: ErrorEvent) => { + options.onError?.(event.message || 'FrameOS preview worker failed to load') + } + this.worker.onmessage = (event: MessageEvent) => this.handleMessage(event.data ?? {}) + this.worker.postMessage({ + type: 'init', + width: options.width, + height: options.height, + name: options.name || 'frameos-wasm preview', + timeZone: options.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + scenesJson: JSON.stringify(options.scenes), + settingsJson: JSON.stringify(options.settings ?? {}), + proxyUrl: options.proxyUrl || '', + sceneId: options.sceneId || '', + }) + } + + private handleMessage(msg: Record): void { + if (this.destroyed) { + return + } + switch (msg.type) { + case 'ready': + this.sceneInfo = msg.sceneInfo ?? null + if (this.sceneInfo?.currentSceneId) { + this.currentSceneId = this.sceneInfo.currentSceneId + } + this.options.onReady?.(msg.sceneInfo) + break + case 'frame': + this.pendingFrame = { width: msg.width, height: msg.height, buffer: msg.buffer } + this.paint() + this.options.onFrame?.({ width: msg.width, height: msg.height, renderMs: msg.renderMs }) + break + case 'state': + this.state = msg.state ?? {} + this.options.onState?.(this.state) + break + case 'log': + this.options.onLog?.(String(msg.message ?? '')) + break + case 'sceneEvent': + this.options.onSceneEvent?.(String(msg.name ?? ''), msg.payload ?? {}) + break + case 'error': + this.options.onError?.(String(msg.message ?? 'Unknown FrameOS preview error')) + break + } + } + + /** Attach (or replace) the canvas frames are painted onto. */ + attachCanvas(canvas: HTMLCanvasElement | null): void { + this.canvas = canvas + this.paint() + } + + private paint(): void { + const canvas = this.canvas + const frame = this.pendingFrame + if (!canvas || !frame || !frame.buffer.byteLength) { + return + } + if (canvas.width !== frame.width) { + canvas.width = frame.width + } + if (canvas.height !== frame.height) { + canvas.height = frame.height + } + const context = canvas.getContext('2d') + if (!context) { + return + } + context.putImageData(new ImageData(new Uint8ClampedArray(frame.buffer), frame.width, frame.height), 0, 0) + } + + /** Force a render now. */ + render(): void { + this.worker?.postMessage({ type: 'render' }) + } + + /** Dispatch a scene event (a custom event node's keyword, "button", ...). */ + sendEvent(name: string, payload: Record = {}): void { + this.worker?.postMessage({ type: 'event', name, payload }) + } + + /** Update the current scene's state fields; renders by default. */ + setSceneState(state: Record, render = true): void { + this.sendEvent('setSceneState', { state, render }) + } + + /** Switch the runtime to another loaded scene. */ + selectScene(sceneId: string): void { + this.currentSceneId = sceneId + this.worker?.postMessage({ type: 'selectScene', sceneId }) + } + + /** Terminate the worker; the instance cannot be reused afterwards. */ + destroy(): void { + this.destroyed = true + this.worker?.terminate() + this.worker = null + this.pendingFrame = null + this.canvas = null + } +} + +export function createFrameOSPreview(options: FrameOSPreviewOptions): FrameOSPreview { + return new FrameOSPreview(options) +} diff --git a/frameos/wasm/src/showIf.ts b/frameos/wasm/src/showIf.ts new file mode 100644 index 000000000..407da76c7 --- /dev/null +++ b/frameos/wasm/src/showIf.ts @@ -0,0 +1,115 @@ +// showIf evaluation for scene state fields. A framework-free mirror of +// frontend/src/utils/showIf.ts (which itself mirrors the Nim implementation +// in frameos/src/frameos/utils/show_if.nim) — keep the three in sync. +import type { ShowIfCondition, StateField } from './types' + +function matchCondition( + condition: ShowIfCondition, + values: Record, + currentFieldName: string | null +): boolean { + if ('and' in condition) { + return condition.and.every((cond) => matchCondition(cond, values, currentFieldName)) + } + + const { value, operator, field: fieldName } = condition + const field = fieldName || currentFieldName || '' + const actualValue = values[field] as any + + if (operator === 'eq') { + return actualValue === value + } else if (operator === 'ne') { + return actualValue !== value + } else if (operator === 'gt') { + return actualValue > (value as any) + } else if (operator === 'lt') { + return actualValue < (value as any) + } else if (operator === 'gte') { + return actualValue >= (value as any) + } else if (operator === 'lte') { + return actualValue <= (value as any) + } else if (operator === 'in') { + return !!(value as any)?.includes?.(actualValue) + } else if (operator === 'notIn') { + return !(value as any)?.includes?.(actualValue) + } else if (operator === 'empty') { + return !actualValue + } else if (operator === 'notEmpty') { + return !!actualValue + } + return value !== undefined ? value === actualValue : !!actualValue +} + +/** + * True when the field should be visible: no conditions, or at least one + * top-level condition matches (top level is OR-ed, `and` blocks are AND-ed). + */ +export function evaluateShowIf( + conditions: ShowIfCondition[] | undefined, + values: Record, + currentFieldName?: string | null +): boolean { + if (!conditions || conditions.length === 0) { + return true + } + return conditions.some((condition) => matchCondition(condition, values, currentFieldName ?? null)) +} + +/** + * Coerce a raw form value (usually a string) to the field's type so showIf + * comparisons behave the same as on the frame itself. + */ +export function coerceStateFieldValue(field: Pick, value: unknown): unknown { + if (value === undefined || value === null) { + return value + } + if (field.type === 'boolean') { + return value === true || value === 'true' + } + if (field.type === 'integer') { + const parsed = typeof value === 'number' ? Math.trunc(value) : parseInt(String(value)) + return isNaN(parsed) ? undefined : parsed + } + if (field.type === 'float') { + const parsed = typeof value === 'number' ? value : parseFloat(String(value)) + return isNaN(parsed) ? undefined : parsed + } + return value +} + +/** The values map for showIf checks: field defaults overlaid with the sources. */ +export function stateFieldShowIfValues( + fields: StateField[], + ...valueSources: (Record | null | undefined)[] +): Record { + const values: Record = {} + for (const field of fields) { + if (!field.name) { + continue + } + let value = field.value + for (const source of valueSources) { + if (source && field.name in source && source[field.name] !== undefined) { + value = source[field.name] + } + } + const coerced = coerceStateFieldValue(field, value) + if (coerced !== undefined) { + values[field.name] = coerced + } + } + return values +} + +/** + * The public state fields visible in a control form given the current values + * (later sources win, defaults fill the gaps). + */ +export function visiblePublicStateFields( + fields: StateField[], + ...valueSources: (Record | null | undefined)[] +): StateField[] { + const publicFields = fields.filter((field) => field.access === 'public') + const values = stateFieldShowIfValues(publicFields, ...valueSources) + return publicFields.filter((field) => evaluateShowIf(field.showIf, values, field.name ?? null)) +} diff --git a/frameos/wasm/src/types.ts b/frameos/wasm/src/types.ts new file mode 100644 index 000000000..a8bbc04f9 --- /dev/null +++ b/frameos/wasm/src/types.ts @@ -0,0 +1,101 @@ +// Types for the FrameOS scenes JSON and the preview worker protocol. These +// mirror the FrameOS backend/frontend definitions (frontend/src/types.tsx and +// frameos/src/frameos/types.nim) for the parts a browser preview needs. + +/** One showIf condition: compare a (state) field's value against `value`. */ +export interface ConfigFieldCondition { + field?: string + operator?: 'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte' | 'empty' | 'notEmpty' | 'in' | 'notIn' | null + value?: unknown +} + +/** All conditions inside `and` must match; top-level conditions are OR-ed. */ +export interface ConfigFieldConditionAnd { + and: ConfigFieldCondition[] +} + +export type ShowIfCondition = ConfigFieldCondition | ConfigFieldConditionAnd + +/** A scene state field, as found in a scene JSON's `fields` array. */ +export interface StateField { + name: string + label?: string + type?: string + value?: unknown + options?: string[] + placeholder?: string + required?: boolean + secret?: boolean + persist?: 'memory' | 'disk' + access?: 'private' | 'public' + showIf?: ShowIfCondition[] +} + +export interface SceneNode { + id?: string + type?: string + data?: Record & { keyword?: string; config?: Record } +} + +/** A FrameOS scene as exported in scenes.json / template zips. */ +export interface FrameOSScene { + id: string + name?: string + nodes?: SceneNode[] + edges?: unknown[] + fields?: StateField[] + settings?: Record + default?: boolean + [key: string]: unknown +} + +/** frameos_wasm_scene_info() payload, sent with the worker's `ready` message. */ +export interface SceneInfo { + loaded: number + currentSceneId: string + currentSceneName: string + defaultSceneId: string + renderRequested: boolean + scenes: { id: string; name: string; refreshInterval: number }[] +} + +export interface PreviewFrame { + width: number + height: number + renderMs: number +} + +/** An interactive scene event (custom `event` node) usable as a button. */ +export interface SceneEventButton { + keyword: string + label: string | null +} + +/** Events every scene handles on its own; not useful as interactive buttons. */ +export const LIFECYCLE_EVENTS = new Set(['render', 'init', 'open', 'close', 'setSceneState', 'setCurrentScene']) + +/** The custom event nodes of a scene, deduplicated — render these as buttons. */ +export function sceneEventButtons(scene: FrameOSScene | undefined | null): SceneEventButton[] { + if (!scene) { + return [] + } + const events: SceneEventButton[] = [] + const seen = new Set() + for (const node of scene.nodes ?? []) { + if (node.type !== 'event') { + continue + } + const data = (node.data ?? {}) as Record & { config?: Record } + const keyword = String(data.keyword ?? '') + if (!keyword || LIFECYCLE_EVENTS.has(keyword)) { + continue + } + const label = (data.config?.label ?? data.label ?? null) as string | null + const dedupeKey = `${keyword}:${label ?? ''}` + if (!seen.has(dedupeKey)) { + seen.add(dedupeKey) + events.push({ keyword, label: label ? String(label) : null }) + } + } + return events +} diff --git a/frameos/wasm/tsconfig.json b/frameos/wasm/tsconfig.json new file mode 100644 index 000000000..6ff0d1d10 --- /dev/null +++ b/frameos/wasm/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/frontend/build.mjs b/frontend/build.mjs index 167b65174..4c3037d30 100644 --- a/frontend/build.mjs +++ b/frontend/build.mjs @@ -15,6 +15,7 @@ import { export const __dirname = path.dirname(fileURLToPath(import.meta.url)) await import('./scripts/generateRepoApps.mjs') +await import('./scripts/generateBuiltinApps.mjs') startDevServer(__dirname) copyPublicFolder(path.resolve(__dirname, 'public'), path.resolve(__dirname, 'dist')) @@ -25,17 +26,17 @@ const common = { bundle: true, } -function isMonacoWorkerOutput(outputPath) { - return outputPath.startsWith('dist/static/monaco/') && outputPath.endsWith('.js') +function isMonacoWorkerOutput(outputPath, staticPrefix) { + return outputPath.startsWith(`${staticPrefix}monaco/`) && outputPath.endsWith('.js') } -function isSharedWorkerChunk(outputPath) { - return outputPath.startsWith('dist/static/chunk-') && outputPath.endsWith('.js') +function isSharedWorkerChunk(outputPath, staticPrefix) { + return outputPath.startsWith(`${staticPrefix}chunk-`) && outputPath.endsWith('.js') } -function copyMonacoWorkerChunks(buildOutputs) { +function copyMonacoWorkerChunks(buildOutputs, staticPrefix = 'dist/static/') { const outputs = buildOutputs.outputs ?? {} - const workerOutputKeys = Object.keys(outputs).filter(isMonacoWorkerOutput) + const workerOutputKeys = Object.keys(outputs).filter((key) => isMonacoWorkerOutput(key, staticPrefix)) const chunkKeys = new Set() const visit = (key) => { const output = outputs[key] @@ -43,7 +44,7 @@ function copyMonacoWorkerChunks(buildOutputs) { return } for (const imported of output.imports ?? []) { - if (isSharedWorkerChunk(imported.path) && !chunkKeys.has(imported.path)) { + if (isSharedWorkerChunk(imported.path, staticPrefix) && !chunkKeys.has(imported.path)) { chunkKeys.add(imported.path) visit(imported.path) } @@ -56,7 +57,7 @@ function copyMonacoWorkerChunks(buildOutputs) { for (const key of chunkKeys) { const sourcePath = path.resolve(__dirname, key) - const targetPath = path.resolve(__dirname, 'dist/static/monaco', path.basename(key)) + const targetPath = path.resolve(__dirname, `${staticPrefix}monaco`, path.basename(key)) fse.copyFileSync(sourcePath, targetPath) const sourceMapPath = `${sourcePath}.map` @@ -66,6 +67,82 @@ function copyMonacoWorkerChunks(buildOutputs) { } } +// The embedded scene editor (dist-editor/): the same Diagram/EditApp code, +// but with frameLogic and logsLogic swapped for in-memory shims so it runs +// without a backend — scenes go in and out over postMessage. Published to +// npm as `frameos-editor`; see src/embed/. +const embedAliasPlugin = { + name: 'frameos-embed-aliases', + setup(build) { + const shimSkip = /frameLogicShim|logsLogicShim/ + const realFrameLogic = path.resolve(__dirname, 'src/scenes/frame/frameLogic') + const realLogsLogic = path.resolve(__dirname, 'src/scenes/frame/panels/Logs/logsLogic') + build.onResolve({ filter: /(frameLogic|logsLogic)$/ }, (args) => { + if (!args.path.startsWith('.') || !args.importer || shimSkip.test(args.importer)) { + return null + } + const resolved = path.resolve(path.dirname(args.importer), args.path) + if (resolved === realFrameLogic) { + return { path: path.resolve(__dirname, 'src/embed/frameLogicShim.ts') } + } + if (resolved === realLogsLogic) { + return { path: path.resolve(__dirname, 'src/embed/logsLogicShim.ts') } + } + return null + }) + }, +} + +function writeEditorHtml(outputs = {}) { + const distEditor = path.resolve(__dirname, 'dist-editor') + fse.mkdirpSync(distEditor) + // Hashless copies of the entry + monaco workers, so configureMonaco's + // worker URLs keep stable names; the html references the HASHED entry + // files so a redeployed bundle busts browser caches. + let entryJs = './static/editor.js' + let entryCss = './static/editor.css' + for (const key of Object.keys(outputs)) { + if (/^dist-editor\/static\/(editor-[A-Z0-9]+\.(js|css)|monaco\/(editor|json|css|html|ts)\.worker-[A-Z0-9]+\.js)$/.test(key)) { + createHashlessEntrypoints(__dirname, [key]) + if (/static\/editor-[A-Z0-9]+\.js$/.test(key)) { + entryJs = `./static/${path.basename(key)}` + } else if (/static\/editor-[A-Z0-9]+\.css$/.test(key)) { + entryCss = `./static/${path.basename(key)}` + } + } + } + fse.writeFileSync( + path.resolve(distEditor, 'index.html'), + ` + + + + + FrameOS Scene Editor + + + + +
+ +
+ + + +` + ) +} + await buildInParallel( [ { @@ -84,6 +161,26 @@ await buildInParallel( outdir: path.resolve(__dirname, 'dist', 'static'), ...common, }, + { + name: 'FrameOS Embedded Editor', + globalName: 'frameOSEditor', + // publicPath './' makes chunk imports same-directory-relative, so the + // entry must land at the outdir root, next to the chunks (the workers + // get their shared chunks copied in by copyMonacoWorkerChunks). + entryPoints: [ + { in: 'src/embed/editor.tsx', out: 'editor' }, + { in: 'src/monaco/editor.worker.ts', out: 'monaco/editor.worker' }, + { in: 'src/monaco/json.worker.ts', out: 'monaco/json.worker' }, + { in: 'src/monaco/css.worker.ts', out: 'monaco/css.worker' }, + { in: 'src/monaco/html.worker.ts', out: 'monaco/html.worker' }, + { in: 'src/monaco/ts.worker.ts', out: 'monaco/ts.worker' }, + ], + splitting: true, + format: 'esm', + outdir: path.resolve(__dirname, 'dist-editor', 'static'), + extraPlugins: [embedAliasPlugin], + ...common, + }, ], { async onBuildComplete(config, buildResponse) { @@ -95,6 +192,16 @@ await buildInParallel( if (config.name === 'FrameOS Frontend') { writeIndexHtml(chunks, entrypoints) } + if (config.name === 'FrameOS Embedded Editor') { + try { + writeEditorHtml(buildResponse.outputs) + copyMonacoWorkerChunks(buildResponse, 'dist-editor/static/') + } catch (error) { + console.error('Embedded editor packaging failed:', error) + throw error + } + return + } const files = Object.keys(buildResponse.outputs).filter( (key) => key.startsWith('dist/static/main-') || key.startsWith('dist/static/monaco/') diff --git a/frontend/package.json b/frontend/package.json index fa67dee7a..85a7cd859 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "dev:vite": "vite --host 0.0.0.0 --port 8616", "clean": "rm -rf ./dist && mkdir ./dist", "build": "npm run build:repo-apps && npm run build:kea && npm run schema && tsc && npm run build:esbuild", - "build:repo-apps": "node scripts/generateRepoApps.mjs", + "build:repo-apps": "node scripts/generateRepoApps.mjs && node scripts/generateBuiltinApps.mjs", "build:kea": "kea-typegen write --write-paths", "build:esbuild": "node build.mjs", "prettier": "prettier --write src", diff --git a/frontend/scripts/generateBuiltinApps.mjs b/frontend/scripts/generateBuiltinApps.mjs new file mode 100644 index 000000000..007c8f199 --- /dev/null +++ b/frontend/scripts/generateBuiltinApps.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Embed the built-in (Nim) app catalog into the bundle, mirroring +// generateRepoApps.mjs. The backend serves the same configs via GET +// /api/apps; the embedded copy makes the app catalog available without a +// backend (standalone/embedded editor, offline fallback) and ships the app +// sources so "view app source" works there too. +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const frontendRoot = path.resolve(__dirname, '..') +const repoRoot = path.resolve(frontendRoot, '..') +const sourceRoot = path.join(repoRoot, 'frameos', 'src', 'apps') +const outputPath = path.join(frontendRoot, 'src', 'generated', 'builtinApps.ts') + +async function pathExists(filePath) { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +async function subdirs(dir) { + if (!(await pathExists(dir))) { + return [] + } + const entries = await fs.readdir(dir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() +} + +async function buildEmbeddedBuiltinApps() { + const configs = {} + const sources = {} + + for (const category of await subdirs(sourceRoot)) { + for (const appName of await subdirs(path.join(sourceRoot, category))) { + const appDir = path.join(sourceRoot, category, appName) + const configPath = path.join(appDir, 'config.json') + if (!(await pathExists(configPath))) { + continue + } + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) + if (!config || typeof config !== 'object' || !config.name) { + continue + } + + const keyword = `${category}/${appName}` + configs[keyword] = { ...config, category } + + const files = (await fs.readdir(appDir, { withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort() + sources[keyword] = {} + for (const file of files) { + sources[keyword][file] = await fs.readFile(path.join(appDir, file), 'utf8') + } + } + } + + return { configs, sources } +} + +function renderModule({ configs, sources }) { + return `${[ + '// Auto-generated by scripts/generateBuiltinApps.mjs.', + "import type { AppConfig } from '../types'", + '', + // Cast: real config.json files use a few field properties the strict + // frontend types don't model (min/max, bare showIf operators). + `export const embeddedBuiltinAppConfigs = ${JSON.stringify(configs, null, 2)} as unknown as Record`, + '', + `export const embeddedBuiltinAppSources: Record> = ${JSON.stringify(sources, null, 2)}`, + '', + ].join('\n')}` +} + +await fs.mkdir(path.dirname(outputPath), { recursive: true }) +await fs.writeFile(outputPath, renderModule(await buildEmbeddedBuiltinApps())) diff --git a/frontend/scripts/smokeEditorEmbed.mjs b/frontend/scripts/smokeEditorEmbed.mjs new file mode 100644 index 000000000..223ac7252 --- /dev/null +++ b/frontend/scripts/smokeEditorEmbed.mjs @@ -0,0 +1,108 @@ +// Smoke test for the embedded editor bundle: serve dist-editor statically, +// post an init message with a small scene, and check nodes render + edits +// round-trip over postMessage. +import http from 'node:http' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { chromium } from '@playwright/test' + +const distEditor = process.argv[2] ?? new URL("../dist-editor", import.meta.url).pathname +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.ttf': 'font/ttf' } + +const server = http.createServer(async (req, res) => { + const urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname) + const filePath = path.join(distEditor, urlPath === '/' ? 'index.html' : urlPath) + try { + const body = await readFile(filePath) + res.writeHead(200, { 'content-type': mime[path.extname(filePath)] ?? 'application/octet-stream' }) + res.end(body) + } catch { + res.writeHead(404) + res.end('not found') + } +}) +await new Promise((resolve) => server.listen(0, resolve)) +const port = server.address().port + +const scenes = [ + { + id: 'scene-1', + name: 'Smoke scene', + default: true, + nodes: [ + { id: 'n-render', type: 'event', position: { x: 0, y: 0 }, data: { keyword: 'render' } }, + { + id: 'n-color', + type: 'app', + position: { x: 300, y: 0 }, + data: { keyword: 'render/color', config: { color: '#ff0000' } }, + }, + ], + edges: [{ id: 'e1', source: 'n-render', target: 'n-color', sourceHandle: 'next', targetHandle: 'prev' }], + fields: [], + }, +] + +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }) +const errors = [] +page.on('pageerror', (err) => errors.push(`pageerror: ${err.message}`)) +page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(`console: ${msg.text()}`) + } +}) +page.on('requestfailed', (req) => errors.push(`reqfail: ${req.url()}`)) +page.on('response', (res) => { + if (res.status() === 404) { + errors.push(`404: ${res.url()}`) + } +}) + +const received = [] +await page.exposeFunction('smokeReport', (msg) => received.push(msg)) + +await page.addInitScript(() => { + window.addEventListener('message', (event) => { + if (event.data?.type?.startsWith('frameos-editor:')) { + // The page IS the "parent" here (window.parent === window when not framed). + window.smokeReport({ type: event.data.type, sceneCount: event.data.scenes?.length }) + } + }) +}) + +await page.goto(`http://127.0.0.1:${port}/index.html`) +try { + await page.waitForFunction(() => document.body.innerText.includes('Waiting for scenes'), null, { timeout: 15000 }) +} catch { + console.log('INIT SCREEN MISSING') + console.log(JSON.stringify({ errors, bodyText: await page.evaluate(() => document.body.innerText.slice(0, 300)), html: await page.evaluate(() => document.getElementById('root')?.innerHTML?.slice(0, 300)) }, null, 2)) + await browser.close(); server.close(); process.exit(1) +} + +await page.evaluate((scenes) => { + window.postMessage({ type: 'frameos-editor:init', scenes, width: 800, height: 480, mode: 'rpios' }, '*') +}, scenes) + +// The diagram should render both nodes. +try { + await page.waitForSelector('.react-flow__node', { timeout: 15000 }) +} catch { + console.log('NODES MISSING') + console.log(JSON.stringify({ errors, received, bodyText: await page.evaluate(() => document.body.innerText.slice(0, 300)) }, null, 2)) + await browser.close(); server.close(); process.exit(1) +} +const nodeCount = await page.locator('.react-flow__node').count() + +// Ask for the scenes back. +await page.evaluate(() => window.postMessage({ type: 'frameos-editor:get-scenes' }, '*')) +await page.waitForFunction(() => true, null, { timeout: 1000 }).catch(() => {}) +await page.waitForTimeout(800) + +const bodyText = await page.evaluate(() => document.body.innerText.slice(0, 400)) +await page.screenshot({ path: path.join(path.dirname(distEditor), 'editor-smoke.png') }).catch(() => {}) + +console.log(JSON.stringify({ nodeCount, received, errors: errors.slice(0, 8), bodyText: bodyText.slice(0, 200) }, null, 2)) +await browser.close() +server.close() +process.exit(nodeCount >= 2 && received.some((m) => m.type === 'frameos-editor:scenes' && m.sceneCount === 1) ? 0 : 1) diff --git a/frontend/src/embed/EmbedScenePreview.tsx b/frontend/src/embed/EmbedScenePreview.tsx new file mode 100644 index 000000000..96cd04e26 --- /dev/null +++ b/frontend/src/embed/EmbedScenePreview.tsx @@ -0,0 +1,460 @@ +import { useActions, useValues } from 'kea' +import clsx from 'clsx' +import { useEffect, useMemo, useRef, useState } from 'react' +import { CameraIcon, CursorArrowRaysIcon, KeyIcon, PencilSquareIcon } from '@heroicons/react/24/outline' + +import { Button } from '../components/Button' +import { Checkbox } from '../components/Checkbox' +import { Label } from '../components/Label' +import { Modal } from '../components/Modal' +import { Spinner } from '../components/Spinner' +import { TextInput } from '../components/TextInput' +import { Tooltip } from '../components/Tooltip' +import { visiblePublicStateFields } from '../utils/showIf' +import { appsModel } from '../models/appsModel' +import { collectSecretSettingsFromScenes, settingsDetails } from '../scenes/frame/panels/secretSettings' +import { collectScenePreviewPayloadScenes } from '../scenes/frame/panels/Scenes/scenesLogic' +import { livePreviewLogic } from '../scenes/frame/panels/Scenes/livePreviewLogic' +import { + formatTimestamp, + logLineColor, + openCanvasImageInNewTab, + renderLogLine, +} from '../scenes/frame/panels/Scenes/LivePreviewModal' +import { StateFieldEdit } from '../scenes/frame/panels/Scenes/StateFieldEdit' + +// The Preview drawer panel of the standalone embedded editor: runs the edited +// scenes through the frameos-wasm runtime, in the browser — canvas, event +// buttons, live scene state and the runtime log. Same livePreviewLogic as the +// main app's "Preview in browser" modal, rendered as panel content and +// without the frame-dependent actions (there is no frame to preview on). +export function EmbedScenePreview({ frameId, sceneId }: { frameId: number; sceneId: string }): JSX.Element { + const { + livePreviewScene, + previewStatus, + previewError, + previewLogs, + previewState, + previewSceneEvents, + previewDimensions, + gpioButtons, + wasmUnsupportedApps, + lastRenderMs, + renderCount, + previewSettings, + scenes, + } = useValues(livePreviewLogic({ frameId })) + const { + openLivePreview, + closeLivePreview, + registerCanvas, + dispatchPreviewEvent, + forcePreviewRender, + setPreviewSettings, + } = useActions(livePreviewLogic({ frameId })) + const { apps } = useValues(appsModel) + + const canvasRef = useRef(null) + const [screenshotStatus, setScreenshotStatus] = useState(null) + const [showPublicState, setShowPublicState] = useState(true) + const [showPrivateState, setShowPrivateState] = useState(false) + // Non-null while the "edit state" modal is open; holds the edited values. + const [editStateValues, setEditStateValues] = useState | null>(null) + + // API keys (etc.) the previewed scenes' apps need. Typed values live here + // per flat "group.field" key; "Apply" nests them into the settings object + // the runtime expects and restarts the preview. Seeded from the previously + // applied settings so reopening the panel shows them. + const [settingsForm, setSettingsForm] = useState>(() => { + const flat: Record = {} + for (const [group, groupValues] of Object.entries(previewSettings ?? {})) { + for (const [key, value] of Object.entries(groupValues ?? {})) { + if (typeof value === 'string') { + flat[`${group}.${key}`] = value + } + } + } + return flat + }) + const requiredSettingKeys = useMemo(() => { + const scene = scenes.find((item) => item.id === sceneId) + const payloadScenes = scene ? collectScenePreviewPayloadScenes(scene, scenes, null) : [] + return collectSecretSettingsFromScenes(payloadScenes, apps) + }, [scenes, sceneId, apps]) + + const applySettings = (): void => { + const nested: Record> = {} + for (const settingKey of requiredSettingKeys) { + for (const field of settingsDetails[settingKey]?.fields ?? []) { + const value = settingsForm[field.path.join('.')]?.trim() + if (value) { + const [group, key] = field.path as [string, string] + nested[group] = { ...(nested[group] ?? {}), [key]: value } + } + } + } + setPreviewSettings(nested) + } + + // "Save screenshot": offer the current frame to the embedding page (which + // can store it — FrameOS Cloud adds it to the scene's image gallery). If no + // parent acknowledges within a moment, fall back to a plain PNG download. + const saveScreenshot = (): void => { + const canvas = canvasRef.current + if (!canvas) { + setScreenshotStatus('Nothing rendered yet') + return + } + const dataUrl = canvas.toDataURL('image/png') + setScreenshotStatus('Saving…') + let settled = false + const onAck = (event: MessageEvent): void => { + const message = event.data + if (!message || message.type !== 'frameos-editor:screenshot-saved') { + return + } + settled = true + window.removeEventListener('message', onAck) + setScreenshotStatus(message.ok ? 'Saved to scene images' : message.error || 'Saving failed') + if (!message.ok && message.fallbackDownload !== false) { + downloadDataUrl(dataUrl) + setScreenshotStatus('Screenshot downloaded') + } + } + window.addEventListener('message', onAck) + window.parent?.postMessage({ type: 'frameos-editor:save-screenshot', dataUrl, sceneId }, '*') + window.setTimeout(() => { + if (!settled) { + window.removeEventListener('message', onAck) + downloadDataUrl(dataUrl) + setScreenshotStatus('Screenshot downloaded') + } + }, 3000) + } + + const downloadDataUrl = (dataUrl: string): void => { + const link = document.createElement('a') + link.href = dataUrl + link.download = `${sceneId}-preview.png` + link.click() + } + + // The wasm runtime starts when the panel opens and stops when it closes; + // switching scenes restarts it on the newly selected scene. + useEffect(() => { + openLivePreview(sceneId) + return () => closeLivePreview() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sceneId]) + + // Stick the runtime log to the bottom as new lines arrive — but only while + // the user hasn't scrolled up to read older lines. + const logRef = useRef(null) + const stickToBottomRef = useRef(true) + useEffect(() => { + const el = logRef.current + if (el && stickToBottomRef.current) { + el.scrollTop = el.scrollHeight + } + }, [previewLogs]) + + // GPIO buttons get dedicated buttons; hide indistinguishable "button" + // scene-event entries (same rule as LivePreviewModal). + const sceneEventButtons = previewSceneEvents.filter((event) => { + if (event.keyword !== 'button') { + return true + } + if (gpioButtons.length > 0) { + return false + } + return Boolean(event.label) + }) + + const publicFields = (livePreviewScene?.fields ?? []).filter((field) => field.access === 'public') + const publicFieldNames = new Set(publicFields.map((field) => field.name)) + const stateEntries = Object.entries(previewState) + const publicEntries = stateEntries.filter(([key]) => publicFieldNames.has(key)) + const privateEntries = stateEntries.filter(([key]) => !publicFieldNames.has(key)) + const visibleStateEntries = [...(showPublicState ? publicEntries : []), ...(showPrivateState ? privateEntries : [])] + + const openEditState = (): void => { + const values: Record = {} + for (const field of publicFields) { + values[field.name] = previewState[field.name] ?? field.value + } + setEditStateValues(values) + } + + const submitEditState = (): void => { + if (!editStateValues) { + return + } + const state: Record = {} + for (const field of visiblePublicStateFields(publicFields, previewState, editStateValues)) { + const value = editStateValues[field.name] ?? field.value + if (value !== undefined && value !== null) { + state[field.name] = String(value) + } + } + dispatchPreviewEvent('setSceneState', { state, render: true }) + setEditStateValues(null) + } + + const editableFields = editStateValues ? visiblePublicStateFields(publicFields, previewState, editStateValues) : [] + + return ( +
+
+ { + canvasRef.current = element + registerCanvas(element) + }} + width={previewDimensions.width} + height={previewDimensions.height} + className="max-h-[40vh] max-w-full cursor-zoom-in rounded-lg border border-slate-500/20" + title="Open image in a new tab" + onClick={(event) => openCanvasImageInNewTab(event.currentTarget)} + style={{ + imageRendering: 'pixelated', + aspectRatio: `${previewDimensions.width} / ${previewDimensions.height}`, + }} + /> + {previewStatus === 'loading' ? ( +
+ + Rendering scene in your browser… +
+ ) : null} +
+ + {previewStatus === 'error' && previewError ? ( +
+ {previewError} +
+ ) : null} + + {wasmUnsupportedApps.length > 0 ? ( +
+ This scene uses {wasmUnsupportedApps.length === 1 ? 'an app' : 'apps'} not available in the browser preview:{' '} + {wasmUnsupportedApps.map((app, index) => ( + + {index > 0 ? ', ' : ''} + {app.keyword} ({app.reason}) + + ))} + . {wasmUnsupportedApps.length === 1 ? 'That node' : 'Those nodes'} will fail here but{' '} + {wasmUnsupportedApps.length === 1 ? 'works' : 'work'} on a frame. +
+ ) : null} + + {requiredSettingKeys.length > 0 ? ( +
+
+ + This scene uses services that need API keys +
+
+ Keys stay in this browser tab and are used only by the preview. +
+ {requiredSettingKeys.map((settingKey) => { + const details = settingsDetails[settingKey] + if (!details) { + return null + } + return ( +
+
{details.title}
+ {details.fields.map((field) => { + const formKey = field.path.join('.') + return ( + + ) + })} +
+ ) + })} + +
+ ) : null} + +
+ + + {sceneEventButtons.map((event) => ( + + ))} + {gpioButtons.map((button) => ( + + ))} + + {screenshotStatus ? {screenshotStatus} : null} + {renderCount > 0 ? ( + <> + {renderCount} render{renderCount === 1 ? '' : 's'} + {lastRenderMs !== null ? `, last ${lastRenderMs} ms` : ''} + + ) : null} + + +
+ +
+
+
Scene state
+ {stateEntries.length > 0 ? ( + <> + + + + ) : null} + {publicFields.length > 0 ? ( + + ) : null} +
+ {stateEntries.length > 0 ? ( +
+ {visibleStateEntries.length > 0 ? ( + visibleStateEntries.map(([key, value]) => ( +
+ {key} + : + {typeof value === 'string' ? value : JSON.stringify(value)} +
+ )) + ) : ( +
No state fields selected
+ )} +
+ ) : null} +
+ +
+
Runtime log
+
{ + const el = event.currentTarget + stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 + }} + className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-white/10 bg-slate-900 p-2 font-mono text-sm leading-5" + > + {previewLogs.length > 0 ? ( + previewLogs.map((log, index) => ( +
+
{formatTimestamp(log.timestamp)}
+
+ {renderLogLine(log.line)} +
+
+ )) + ) : ( +
No logs yet
+ )} +
+
+ + {editStateValues ? ( + setEditStateValues(null)} + title={`Scene state: ${livePreviewScene?.name ?? 'scene'}`} + > +
+ {editableFields.length > 0 ? ( +
+ {editableFields.map((field) => ( +
+ + setEditStateValues((values) => ({ ...values, [field.name]: value }))} + currentState={previewState} + stateChanges={editStateValues} + /> +
+ ))} +
+ ) : ( +
This scene does not export publicly controllable state.
+ )} +
+ + +
+
+
+ ) : null} +
+ ) +} diff --git a/frontend/src/embed/EmbeddedEditor.tsx b/frontend/src/embed/EmbeddedEditor.tsx new file mode 100644 index 000000000..b4ce0238e --- /dev/null +++ b/frontend/src/embed/EmbeddedEditor.tsx @@ -0,0 +1,414 @@ +import clsx from 'clsx' +import copy from 'copy-to-clipboard' +import { BindLogic, useActions, useMountedLogic, useValues } from 'kea' +import { useEffect, useState } from 'react' +import { + ArrowsPointingInIcon, + BoltIcon, + ClipboardDocumentIcon, + Cog6ToothIcon, + PuzzlePieceIcon, + ServerStackIcon, + VariableIcon, + XMarkIcon, +} from '@heroicons/react/24/outline' +import { PencilSquareIcon, PlayIcon } from '@heroicons/react/24/solid' + +import { FrameosTheme } from '../utils/frameosTheme' +import { ZoomOutArea } from '../icons/ZoomOutArea' +import { Modal } from '../components/Modal' + +import { frameEditorsLogic } from '../scenes/frame/frameEditorsLogic' +import { Diagram } from '../scenes/frame/panels/Diagram/Diagram' +import { diagramLogic } from '../scenes/frame/panels/Diagram/diagramLogic' +import { EditApp } from '../scenes/frame/panels/EditApp/EditApp' +import { Apps } from '../scenes/frame/panels/Apps/Apps' +import { Events } from '../scenes/frame/panels/Events/Events' +import { SceneJSON } from '../scenes/frame/panels/SceneJSON/SceneJSON' +import { SceneState } from '../scenes/frame/panels/SceneState/SceneState' +import { SceneSettings } from '../scenes/frame/panels/Scenes/SceneSettings' +import { RenameSceneModal } from '../scenes/frame/panels/Scenes/RenameSceneModal' +import { scenesLogic } from '../scenes/frame/panels/Scenes/scenesLogic' +import { livePreviewLogic } from '../scenes/frame/panels/Scenes/livePreviewLogic' +import { workspaceLogic } from '../scenes/workspace/workspaceLogic' +import { FrameScene } from '../types' +import { embedBridge, embedFrameLogic } from './embedFrameLogic' +import { EmbedScenePreview } from './EmbedScenePreview' +// Note: via the build alias this resolves to embedFrameLogic too; imported +// under its public name so BindLogic wires the same instance the Diagram +// dependency graph connects to. +import { frameLogic } from '../scenes/frame/frameLogic' + +const EMBED_FRAME_ID = 1 + +// The postMessage protocol (all messages are objects with a `type`): +// parent -> editor: +// {type: 'frameos-editor:init', scenes, sceneId?, mode?, width?, height?, +// interval?, theme?, previewProxyUrl?, description?} +// {type: 'frameos-editor:get-scenes'} -> replies with :scenes +// {type: 'frameos-editor:select-scene', sceneId} +// editor -> parent: +// {type: 'frameos-editor:ready'} once listening +// {type: 'frameos-editor:scenes', scenes} after every edit (debounced) +// and as the :get-scenes reply +// {type: 'frameos-editor:save-screenshot', dataUrl, sceneId} +// Preview panel screenshot; reply with +// {type: 'frameos-editor:screenshot-saved', ok, +// error?, fallbackDownload?} or the editor +// downloads the PNG locally after a timeout +// `previewProxyUrl` is an optional same-origin endpoint the wasm live preview +// routes CORS-blocked HTTP requests through. `description` is the embedding +// page's description of the scene (scenes.json doesn't carry one), shown in +// the Scene settings panel. +export function EmbeddedEditor(): JSX.Element { + const logicProps = { frameId: EMBED_FRAME_ID } + const logic = useMountedLogic(embedFrameLogic(logicProps)) + const { initEmbedFrame } = useActions(embedFrameLogic(logicProps)) + const { scenes } = useValues(embedFrameLogic(logicProps)) + const { setTheme } = useActions(workspaceLogic) + const [initialized, setInitialized] = useState(false) + const [selectedSceneId, setSelectedSceneId] = useState(null) + const [sceneDescription, setSceneDescription] = useState(null) + const [theme, setThemeState] = useState(() => + document.documentElement.dataset.frameosTheme === 'dark' ? 'dark' : 'light' + ) + + useEffect(() => { + embedBridge.onScenesChanged = (nextScenes: FrameScene[]) => { + window.parent?.postMessage({ type: 'frameos-editor:scenes', scenes: nextScenes }, '*') + } + + const onMessage = (event: MessageEvent): void => { + const message = event.data + if (!message || typeof message !== 'object') { + return + } + if (message.type === 'frameos-editor:init' && Array.isArray(message.scenes)) { + if (message.theme === 'dark' || message.theme === 'light') { + setThemeState(message.theme) + // Through workspaceLogic so the diagram, Monaco editors and panels + // (which read workspaceLogic.theme) all follow the embedding page. + setTheme(message.theme) + } + if (typeof message.previewProxyUrl === 'string' && message.previewProxyUrl) { + const config = ((window as any).FRAMEOS_APP_CONFIG = (window as any).FRAMEOS_APP_CONFIG || {}) + config.preview_proxy_url = message.previewProxyUrl + } + setSceneDescription( + typeof message.description === 'string' && message.description.trim() ? message.description : null + ) + initEmbedFrame({ + id: EMBED_FRAME_ID, + scenes: message.scenes, + mode: message.mode || 'rpios', + width: message.width || 800, + height: message.height || 480, + interval: message.interval ?? 300, + rotate: 0, + } as any) + setSelectedSceneId( + message.sceneId ?? + (message.scenes.find((scene: FrameScene) => scene.default) || message.scenes[0])?.id ?? + null + ) + setInitialized(true) + } else if (message.type === 'frameos-editor:get-scenes') { + window.parent?.postMessage({ type: 'frameos-editor:scenes', scenes: logic.values.frameForm?.scenes ?? [] }, '*') + } else if (message.type === 'frameos-editor:select-scene' && typeof message.sceneId === 'string') { + setSelectedSceneId(message.sceneId) + } + } + window.addEventListener('message', onMessage) + window.parent?.postMessage({ type: 'frameos-editor:ready' }, '*') + return () => { + window.removeEventListener('message', onMessage) + embedBridge.onScenesChanged = undefined + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (!initialized) { + return ( +
+ Waiting for scenes… (post a {'{type: "frameos-editor:init", scenes: [...]}'} message) +
+ ) + } + + return ( + + + + + + ) +} + +type EmbedPanel = 'info' | 'preview' | 'stateVariables' | 'apps' | 'events' | 'json' + +interface EmbedPanelDefinition { + panel: EmbedPanel + label: string + icon: JSX.Element +} + +// The same panel set the main app's scene workspace offers, minus the ones +// that need a backend (compiled source, AI chat) or a frame (frame preview). +const panelDefinitions: EmbedPanelDefinition[] = [ + { panel: 'info', label: 'Scene settings', icon: }, + { panel: 'preview', label: 'Preview', icon: }, + { panel: 'stateVariables', label: 'State variables', icon: }, + { panel: 'apps', label: 'Apps', icon: }, + { panel: 'events', label: 'Events', icon: }, + { panel: 'json', label: 'JSON', icon: }, +] + +const utilityButtonClassName = + 'frameos-icon-button pointer-events-auto flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-white/90 bg-white/90 text-slate-500 shadow-lg shadow-slate-300/25 backdrop-blur-xl transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400' + +function EmbedDiagramButtons({ sceneId }: { sceneId: string }): JSX.Element { + const { fitDiagramView, rearrangeCurrentScene } = useActions(diagramLogic({ frameId: EMBED_FRAME_ID, sceneId })) + return ( + <> + + + + ) +} + +function EmbedSceneInfoPanel({ scene, description }: { scene: FrameScene; description: string | null }): JSX.Element { + const { renameScene } = useActions(scenesLogic({ frameId: EMBED_FRAME_ID })) + const nodes = scene.nodes ?? [] + const edges = scene.edges ?? [] + const connectedNodeIds = new Set() + edges.forEach((edge) => { + if (edge.source) { + connectedNodeIds.add(edge.source) + } + if (edge.target) { + connectedNodeIds.add(edge.target) + } + }) + const stats = [ + { label: 'Nodes', value: nodes.length }, + { label: 'Edges', value: edges.length }, + { label: 'Scene apps', value: Object.keys(scene.apps ?? {}).length }, + { label: 'Fields', value: scene.fields?.length ?? 0 }, + { label: 'Disconnected', value: nodes.filter((node) => !connectedNodeIds.has(node.id)).length }, + ] + + return ( +
+
+
+
{scene.name || 'Untitled scene'}
+
+
{scene.id}
+ +
+
+ +
+ {description ?
{description}
: null} +
+ {stats.map((stat) => ( +
+
{stat.label}
+
{stat.value}
+
+ ))} +
+ + +
+ ) +} + +function EmbedUtilityDrawer({ + panel, + scene, + sceneDescription, + onClose, +}: { + panel: EmbedPanel + scene: FrameScene + sceneDescription: string | null + onClose: () => void +}): JSX.Element { + const definition = panelDefinitions.find((candidate) => candidate.panel === panel) + + const renderPanel = (): JSX.Element | null => { + switch (panel) { + case 'info': + return + case 'preview': + return + case 'stateVariables': + return + case 'apps': + return + case 'events': + return + case 'json': + return + default: + return null + } + } + + return ( +
+
+
+
+

+ {definition?.label} +

+
+ +
+
{renderPanel()}
+
+
+ ) +} + +function EmbeddedEditorBody({ + selectedSceneId, + setSelectedSceneId, + scenes, + theme, + sceneDescription, +}: { + selectedSceneId: string | null + setSelectedSceneId: (sceneId: string) => void + scenes: FrameScene[] + theme: FrameosTheme + sceneDescription: string | null +}): JSX.Element { + const { activeEditor } = useValues(frameEditorsLogic({ frameId: EMBED_FRAME_ID })) + const { closeEditor } = useActions(frameEditorsLogic({ frameId: EMBED_FRAME_ID })) + // Keep the preview logic (and the API keys applied to it) alive across + // panel open/close cycles. + useMountedLogic(livePreviewLogic({ frameId: EMBED_FRAME_ID })) + const [activePanel, setActivePanel] = useState(null) + const appEditor = activeEditor?.kind === 'editApp' ? activeEditor : null + + const dark = theme === 'dark' + const surface = dark ? 'bg-[#16181c] text-slate-100' : 'bg-white text-slate-900' + const divider = dark ? 'border-slate-700' : 'border-slate-200' + const mutedButton = dark + ? 'rounded px-3 py-1 text-sm text-slate-400 hover:bg-slate-800' + : 'rounded px-3 py-1 text-sm text-slate-500 hover:bg-slate-100' + + const selectedScene = scenes.find((scene) => scene.id === selectedSceneId) ?? null + + return ( +
+ {scenes.length > 1 ? ( +
+ {scenes.map((scene) => ( + + ))} +
+ ) : null} +
+ {selectedSceneId ? : null} + {selectedSceneId ? ( +
+ {/* One vertical column at the right edge: the drawer opens to its + left (right-[4.5rem]), so the buttons stay clickable. */} +
+
+ +
+ {panelDefinitions.map((definition) => ( + + ))} +
+
+
+ ) : null} + {activePanel && selectedScene ? ( + setActivePanel(null)} + /> + ) : null} +
+ {appEditor ? ( + closeEditor(appEditor.key)} + title={appEditor.title || 'Edit app source'} + panelClassName="max-w-[min(1200px,calc(100vw-2rem))]" + bodyClassName="h-[calc(100dvh-11rem)]" + > +
+ +
+
+ ) : null} +
+ ) +} diff --git a/frontend/src/embed/editor.tsx b/frontend/src/embed/editor.tsx new file mode 100644 index 000000000..77efc5459 --- /dev/null +++ b/frontend/src/embed/editor.tsx @@ -0,0 +1,14 @@ +import ReactDOM from 'react-dom/client' +import '../utils/configureMonaco' +import '../index.css' +import { initKea } from '../initKea' +import { EmbeddedEditor } from './EmbeddedEditor' + +// Standalone embedded scene editor (AGPL, like all of FrameOS). Built as its +// own bundle with frameLogic/logsLogic swapped for in-memory shims — no +// backend, no websocket; scenes arrive and leave over postMessage. See +// EmbeddedEditor.tsx for the protocol and build.mjs for the alias setup. + +initKea() + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render() diff --git a/frontend/src/embed/embedFrameLogic.ts b/frontend/src/embed/embedFrameLogic.ts new file mode 100644 index 000000000..f78dfc640 --- /dev/null +++ b/frontend/src/embed/embedFrameLogic.ts @@ -0,0 +1,157 @@ +import { actions, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { forms } from 'kea-forms' +import { FrameScene, FrameType } from '../types' +import { frameFormSceneErrors } from '../scenes/frame/frameFormSceneErrors' +import type { embedFrameLogicType } from './embedFrameLogicType' + +// In-memory replacement for frameLogic's editor-facing slice, used by the +// standalone embedded editor build (see frameLogicShim.ts and build.mjs). +// Scenes come in over postMessage and edits flow back out through +// embedBridge — there is no backend and nothing to deploy. +// +// frameForm is a real kea-forms form (like the one on the real frameLogic): +// the settings/state/events panels render
fields, and logics like scenesLogic connect to +// form-generated actions such as submitFrameFormSuccess. + +export interface EmbedFrameLogicProps { + frameId: number +} + +export const embedBridge: { + onScenesChanged?: (scenes: FrameScene[]) => void +} = {} + +export const embedFrameLogic = kea([ + path(['src', 'embed', 'embedFrameLogic']), + props({} as EmbedFrameLogicProps), + key((props: EmbedFrameLogicProps) => props.frameId), + actions({ + initEmbedFrame: (frame: Partial) => ({ frame }), + applyTemplate: (template: any, openDrawer?: boolean) => ({ template, openDrawer }), + sendEvent: (event: string, payload?: Record) => ({ event, payload }), + updateScene: (sceneId: string, scene: Partial) => ({ sceneId, scene }), + updateNodeData: (sceneId: string, nodeId: string, nodeData: Record) => ({ + sceneId, + nodeId, + nodeData, + }), + }), + forms(() => ({ + frameForm: { + options: { + showErrorsOnTouch: true, + }, + defaults: {} as FrameType, + errors: (state: Partial) => ({ + scenes: frameFormSceneErrors(state.scenes), + }), + // Nothing to save: edits already stream out through embedBridge. + submit: async () => {}, + }, + })), + reducers({ + frame: [ + null as Partial | null, + { + initEmbedFrame: (_: any, { frame }: { frame: Partial }) => frame, + }, + ], + frameForm: { + initEmbedFrame: (_: any, { frame }: { frame: Partial }) => ({ ...frame } as FrameType), + }, + }), + selectors({ + frameId: [() => [(_: any, props: EmbedFrameLogicProps) => props.frameId], (frameId: number) => frameId], + scenes: [ + (s: any) => [s.frame, s.frameForm], + (frame: Partial | null, frameForm: Partial): FrameScene[] => + frameForm?.scenes ?? frame?.scenes ?? [], + ], + mode: [ + (s: any) => [s.frame, s.frameForm], + (frame: Partial | null, frameForm: Partial) => frameForm?.mode || frame?.mode || 'rpios', + ], + width: [ + (s: any) => [s.frameForm], + (frameForm: Partial) => + frameForm.rotate === 90 || frameForm.rotate === 270 ? frameForm.height : frameForm.width, + ], + height: [ + (s: any) => [s.frameForm], + (frameForm: Partial) => + frameForm.rotate === 90 || frameForm.rotate === 270 ? frameForm.width : frameForm.height, + ], + defaultInterval: [(s: any) => [s.frameForm], (frameForm: Partial) => frameForm.interval ?? 300], + defaultScene: [ + (s: any) => [s.scenes], + (scenes: FrameScene[]) => + (scenes.find((scene) => scene.id === 'default' || scene.default) || scenes[0])?.id ?? null, + ], + unsavedChanges: [() => [], () => false], + // No deploys and no frame-admin mode in the embed; connected logics + // (scenesLogic and friends) still expect these to exist. + lastDeploy: [() => [], () => null], + isFrameAdminMode: [() => [], () => false], + }), + listeners(({ actions, values }: { actions: any; values: any }) => ({ + setFrameFormValues: async (_: any, breakpoint: any) => { + await breakpoint(150) + embedBridge.onScenesChanged?.(values.frameForm?.scenes ?? []) + }, + // Individual form-field edits (kea-forms Field components dispatch + // setFrameFormValue) must stream out the same way batched edits do. + setFrameFormValue: async (_: any, breakpoint: any) => { + await breakpoint(150) + embedBridge.onScenesChanged?.(values.frameForm?.scenes ?? []) + }, + sendEvent: () => { + // No frame to send events to in the embed. + }, + updateScene: ({ sceneId, scene }: { sceneId: string; scene: Partial }) => { + const frameForm = values.frameForm + const hasScene = frameForm.scenes?.some(({ id }: FrameScene) => id === sceneId) + const scenes = hasScene + ? frameForm.scenes?.map((s: FrameScene) => (s.id === sceneId ? { ...s, ...scene } : s)) + : [...(frameForm.scenes ?? []), { ...scene, id: sceneId } as FrameScene] + actions.setFrameFormValues({ scenes }) + }, + updateNodeData: ({ + sceneId, + nodeId, + nodeData, + }: { + sceneId: string + nodeId: string + nodeData: Record + }) => { + const scenes: FrameScene[] = values.frameForm.scenes ?? values.frame?.scenes ?? [] + const scene = scenes.find(({ id }) => id === sceneId) + const currentNode = scene?.nodes?.find(({ id }) => id === nodeId) + if (currentNode) { + actions.setFrameFormValues({ + scenes: scenes.map((s) => + s.id === sceneId + ? { + ...s, + nodes: s.nodes?.map((n) => + n.id === nodeId ? { ...n, data: { ...(n.data ?? {}), ...nodeData } } : n + ), + } + : s + ), + }) + } else { + console.error(`Node ${nodeId} not found in scene ${sceneId}`) + } + }, + applyTemplate: ({ template }: { template: any }) => { + // The embedded editor has no template store; adding a template means + // appending its scenes. + const incoming: FrameScene[] = template?.scenes ?? [] + if (incoming.length > 0) { + actions.setFrameFormValues({ scenes: [...(values.frameForm.scenes ?? []), ...incoming] }) + } + }, + })), +]) diff --git a/frontend/src/embed/frameLogicShim.ts b/frontend/src/embed/frameLogicShim.ts new file mode 100644 index 000000000..75cfddd7a --- /dev/null +++ b/frontend/src/embed/frameLogicShim.ts @@ -0,0 +1,7 @@ +// Swapped in for scenes/frame/frameLogic by the embedded-editor build (an +// esbuild alias in build.mjs). Everything the real module exports stays +// available (helpers, types); only the `frameLogic` logic itself is replaced +// with the in-memory embed version, so the whole Diagram/EditApp dependency +// graph runs without a backend. +export * from '../scenes/frame/frameLogic' +export { embedFrameLogic as frameLogic } from './embedFrameLogic' diff --git a/frontend/src/embed/logsLogicShim.ts b/frontend/src/embed/logsLogicShim.ts new file mode 100644 index 000000000..40a33227d --- /dev/null +++ b/frontend/src/embed/logsLogicShim.ts @@ -0,0 +1,21 @@ +import { kea, key, path, props, selectors } from 'kea' +import { LogType } from '../../src/types' +import type { logsLogicType } from './logsLogicShimType' + +// Swapped in for panels/Logs/logsLogic by the embedded-editor build: there is +// no backend or websocket, so the log feed (used only for runtime error +// badges on nodes) is permanently empty. + +export interface LogsLogicProps { + frameId: number +} + +export const logsLogic = kea([ + path(['src', 'embed', 'logsLogicShim']), + props({} as LogsLogicProps), + key((props: LogsLogicProps) => props.frameId), + selectors({ + logs: [() => [], (): LogType[] => []], + logsLoading: [() => [], () => false], + }), +]) diff --git a/frontend/src/generated/builtinApps.ts b/frontend/src/generated/builtinApps.ts new file mode 100644 index 000000000..258d1f505 --- /dev/null +++ b/frontend/src/generated/builtinApps.ts @@ -0,0 +1,4106 @@ +// Auto-generated by scripts/generateBuiltinApps.mjs. +import type { AppConfig } from '../types' + +export const embeddedBuiltinAppConfigs = { + "data/beRecycle": { + "name": "Recycling Calendar for Belgium", + "description": "Return a JSON event list of trash pickup dates", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "label": "Street name", + "name": "streetName", + "type": "string", + "value": "", + "placeholder": "", + "required": true + }, + { + "label": "Number", + "name": "number", + "type": "integer", + "value": "", + "placeholder": "", + "required": true + }, + { + "label": "Postal code", + "name": "postalCode", + "type": "integer", + "value": "", + "placeholder": "", + "required": true + }, + { + "label": "Export events from (YYYY-MM-DD)", + "name": "exportFrom", + "type": "string", + "value": "", + "placeholder": "now", + "required": false + }, + { + "label": "Export events until (YYYY-MM-DD)", + "name": "exportUntil", + "type": "string", + "value": "", + "placeholder": "1 year later", + "required": false + }, + { + "label": "Maximum number of events to export", + "name": "exportCount", + "type": "integer", + "value": "50", + "placeholder": "50", + "required": false + }, + { + "label": "Language", + "name": "language", + "type": "select", + "options": [ + "en", + "fr", + "nl" + ], + "value": "en", + "placeholder": "", + "required": true + }, + { + "label": "x-secret value", + "name": "xSecret", + "type": "string", + "value": "", + "placeholder": "", + "required": false, + "hint": "In case the default value stops working, open recycleapp.be, and copy the value of the x-secret header sent to `/v1/access-token`." + }, + { + "markdown": "[{ summary, startTime, endTime, timezone }]" + } + ], + "output": [ + { + "name": "events", + "type": "json" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "14400" + } + }, + "data/chromiumScreenshot": { + "name": "Chromium screenshot", + "description": "Capture a snapshot of a website with playwright and headless chromium. Needs a 64-bit system and at least 1GB of RAM.", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "url", + "type": "text", + "value": "https://frameos.net/", + "required": true, + "label": "URL to capture", + "placeholder": "" + }, + { + "name": "width", + "type": "integer", + "value": "", + "label": "Width (0=full width)", + "placeholder": "0" + }, + { + "name": "height", + "type": "integer", + "value": "", + "label": "Height (0=full height)", + "placeholder": "0" + }, + { + "name": "persistSession", + "type": "boolean", + "value": true, + "label": "Persist browser session between renders", + "hint": "When enabled, cookies/session storage persist between captures. Disable to use a fresh incognito-like session for each render." + }, + { + "name": "disableLowMemoryCheck", + "type": "boolean", + "value": false, + "label": "Disable low memory check", + "hint": "Always install and run Chromium, even if we have less than 1GB of RAM. Disable this at your own risk!" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "900" + } + }, + "data/clock": { + "name": "Clock", + "description": "Return the current time", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "markdown": "[Date format syntax](https://nim-lang.org/2.0.2/times.html)" + }, + { + "name": "format", + "type": "select", + "options": [ + "yyyy-MM-dd", + "yyyy-MM-dd HH:mm:ss", + "HH:mm:ss:fff", + "HH:mm:ss", + "HH:mm", + "custom" + ], + "value": "HH:mm:ss", + "required": true, + "label": "Format", + "placeholder": "HH:mm:ss" + }, + { + "name": "formatCustom", + "type": "string", + "value": "", + "required": false, + "label": "Custom format", + "placeholder": "", + "showIf": [ + { + "field": "format", + "operator": "eq", + "value": "custom" + } + ] + } + ], + "output": [ + { + "name": "time", + "type": "string" + } + ] + }, + "data/downloadImage": { + "name": "Download Image", + "description": "Download image from an URL", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "url", + "type": "text", + "value": "", + "required": true, + "label": "Image URL", + "placeholder": "https://domain/image" + }, + { + "name": "metadataStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Optional metadata state key", + "hint": "Enter a state key to persist metadata about the image. Stores: {url, width, height, exif: {make, model, lensModel, exposureTime, fNumber, iso, focalLength, dateTimeOriginal, gpsLatitude, ...}, exifSummary}. EXIF is parsed from JPEG downloads; exifSummary is a one-line photographer credit." + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "900" + } + }, + "data/downloadUrl": { + "name": "Download URL", + "description": "Download a URL into a string", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "url", + "type": "text", + "value": "", + "required": true, + "label": "URL", + "placeholder": "https://domain/content" + } + ], + "output": [ + { + "name": "result", + "type": "string" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "900" + } + }, + "data/eventsToAgenda": { + "name": "Events to Agenda", + "description": "Convert events to an basic-caret formatted agenda string", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "events", + "type": "json", + "required": true, + "label": "Events" + }, + { + "name": "baseFontSize", + "type": "float", + "value": "24", + "required": true, + "label": "Base font size" + }, + { + "name": "titleFontSize", + "type": "float", + "value": "48", + "required": true, + "label": "Title font size" + }, + { + "name": "titleColor", + "type": "color", + "value": "#FFFFFF", + "required": true, + "label": "Day title color" + }, + { + "name": "textColor", + "type": "color", + "value": "#FFFFFF", + "required": true, + "label": "Base text color" + }, + { + "name": "timeColor", + "type": "color", + "value": "#FF0000", + "required": true, + "label": "Time color" + }, + { + "name": "startWithToday", + "type": "boolean", + "value": "true", + "required": true, + "label": "Always start with today's date" + } + ], + "output": [ + { + "name": "result", + "type": "string" + } + ] + }, + "data/frameOSGallery": { + "name": "FrameOS Gallery", + "description": "Random image from the FrameOS gallery", + "category": "data", + "version": "1.0.0", + "settings": [ + "frameOS" + ], + "fields": [ + { + "markdown": "[Click here](https://gallery.frameos.net/) to see all the galleries." + }, + { + "name": "category", + "type": "select", + "options": [ + "building-art-styles", + "cute", + "cyberpunk-europe", + "masterpieces", + "space-gallery", + "space-odyssey", + "other" + ], + "value": "cute", + "required": false, + "label": "Category" + }, + { + "name": "categoryOther", + "type": "string", + "value": "", + "required": false, + "label": "Category (if other)", + "placeholder": "", + "showIf": [ + { + "field": "category", + "operator": "eq", + "value": "other" + } + ] + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "900" + } + }, + "data/googlePhotos": { + "name": "Google Photos", + "description": "Show images from a shared Google Photos album. Reads the public share page directly since Google offers no API for shared albums - this may stop working if Google changes the page format.", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "shareUrl", + "type": "text", + "value": "", + "required": true, + "label": "Shared album link", + "placeholder": "https://photos.app.goo.gl/...", + "hint": "In Google Photos, open an album, tap Share and create a link that anyone can view. Paste the resulting https://photos.app.goo.gl/... or https://photos.google.com/share/... link here. The album must stay shared via link for the frame to read it." + }, + { + "name": "mode", + "type": "select", + "options": [ + "random", + "sequential" + ], + "value": "random", + "required": true, + "label": "Order of images" + }, + { + "name": "counterStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Optional state key for persistence", + "hint": "Enter a state key to persist the current image index between restarts.", + "showIf": [ + { + "field": "mode", + "operator": "eq", + "value": "sequential" + } + ] + }, + { + "name": "fitMode", + "type": "select", + "options": [ + "cover", + "contain" + ], + "value": "cover", + "required": true, + "label": "Fit mode", + "hint": "How Google Photos sizes the downloaded image. 'cover' crops the photo to exactly fill the frame, 'contain' keeps the whole photo visible within the frame's dimensions." + }, + { + "name": "metadataStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Metadata state key", + "hint": "Enter a state key to persist metadata about the image. Stores: {source, index, count, imageUrl}." + }, + { + "name": "saveAssets", + "type": "select", + "value": "auto", + "options": [ + "auto", + "always", + "never" + ], + "label": "Save asset", + "hint": "Save the downloaded image to disk as an asset. It'll be placed into the frame's assets folder.\n\nYou can later use the 'Local image' app to view saved assets.\n\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved." + }, + { + "name": "cacheAlbumSeconds", + "type": "integer", + "value": "3600", + "required": false, + "label": "Album cache duration (seconds)", + "placeholder": "3600", + "hint": "How long to reuse the scraped list of photos before fetching the album page again. New photos added to the album show up after this many seconds at the latest." + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "900" + } + }, + "data/haSensor": { + "name": "Home Assistant Sensor", + "description": "Get the state of a Home Assistant entity", + "category": "data", + "version": "1.0.0", + "settings": [ + "homeAssistant" + ], + "fields": [ + { + "markdown": "Find the [entity id here](http://homeassistant.local:8123/config/entities)" + }, + { + "name": "entityId", + "type": "text", + "required": true, + "label": "Entity ID", + "placeholder": "Home Assistant entity name. Example: sensor.home_solar_percentage or water_heater.hot_water" + }, + { + "name": "debug", + "type": "boolean", + "value": "false", + "required": false, + "label": "Debug logging" + } + ], + "output": [ + { + "name": "state", + "type": "json" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "60" + } + }, + "data/icalJson": { + "name": "iCal to Events JSON", + "description": "Convert an iCal file into a JSON event list", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "ical", + "type": "string", + "required": true, + "label": "iCal file contents" + }, + { + "name": "exportFrom", + "type": "string", + "value": "", + "placeholder": "now", + "required": false, + "label": "Export events from (YYYY-MM-DD)" + }, + { + "name": "exportUntil", + "type": "string", + "value": "", + "placeholder": "1 year later", + "required": false, + "label": "Export events until (YYYY-MM-DD)" + }, + { + "name": "exportCount", + "type": "integer", + "value": "50", + "placeholder": "50", + "required": false, + "label": "Maximum number of events to export" + }, + { + "name": "search", + "type": "string", + "value": "", + "placeholder": "", + "required": false, + "label": "Filter events by keyword" + }, + { + "name": "addLocation", + "type": "boolean", + "value": "true", + "required": false, + "label": "Add 'location' to the result JSON" + }, + { + "name": "addUrl", + "type": "boolean", + "value": "true", + "required": false, + "label": "Add 'url' to the result JSON" + }, + { + "name": "addDescription", + "type": "boolean", + "value": "false", + "required": false, + "label": "Add 'description' to the result JSON" + }, + { + "name": "addTimezone", + "type": "boolean", + "value": "false", + "required": false, + "label": "Add 'timezone' to the result JSON" + }, + { + "markdown": "[{ summary, startTime, endTime, location, url, description, timezone }]" + } + ], + "output": [ + { + "name": "events", + "type": "json" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true + } + }, + "data/immich": { + "name": "Immich", + "description": "Show images from a self-hosted Immich server", + "category": "data", + "version": "1.0.0", + "settings": [ + "immich" + ], + "fields": [ + { + "name": "mode", + "type": "select", + "value": "random", + "options": [ + "random", + "album", + "favorites", + "memories" + ], + "required": false, + "label": "Mode", + "hint": "Which images to show: a random image from the whole library, a random image from one album, a random favorite, or today's memories." + }, + { + "name": "albumId", + "type": "string", + "value": "", + "required": false, + "label": "Album ID", + "hint": "UUID of the album — visible in the album URL.", + "placeholder": "e.g. 7f2d3c8a-1b2c-4d5e-8f90-a1b2c3d4e5f6", + "showIf": [ + { + "field": "mode", + "operator": "eq", + "value": "album" + } + ] + }, + { + "name": "personId", + "type": "string", + "value": "", + "required": false, + "label": "Person ID", + "hint": "Optional. UUID of a person to limit random images to — visible in the person's URL.", + "placeholder": "e.g. 9c8b7a6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", + "showIf": [ + { + "field": "mode", + "operator": "eq", + "value": "random" + } + ] + }, + { + "name": "metadataStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Metadata state key", + "hint": "Enter a state key to persist metadata about the image. Stores: {source, id, originalFileName, camera EXIF fields, people, ...}." + }, + { + "name": "saveAssets", + "type": "select", + "value": "auto", + "options": [ + "auto", + "always", + "never" + ], + "label": "Save asset", + "hint": "Save the downloaded image to disk as an asset. It'll be placed into the frame's assets folder.\n\nYou can later use the 'Local image' app to view saved assets.\n\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved." + }, + { + "name": "previewSize", + "type": "select", + "value": "preview", + "options": [ + "preview", + "fullsize" + ], + "label": "Image size", + "hint": "'preview' is resized server-side and much lighter on frame memory. 'fullsize' downloads the original file." + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "900" + } + }, + "data/localImage": { + "name": "Local Image", + "description": "Show an image from the SD card", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "path", + "type": "text", + "value": "", + "required": true, + "label": "Image filename or folder", + "placeholder": "Defaults to the assets folder (e.g. /srv/assets)" + }, + { + "name": "order", + "type": "select", + "options": [ + "random", + "alphabetical" + ], + "value": "random", + "required": true, + "label": "Order of images" + }, + { + "name": "counterStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Optional state key for persistence", + "hint": "Enter a state key to persist the current image index between restarts." + }, + { + "name": "metadataStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Optional metadata state key", + "hint": "Enter a state key to persist metadata about the last image. Stores: {path, filename, index, total, width, height, exif: {make, model, lensModel, exposureTime, fNumber, iso, focalLength, dateTimeOriginal, gpsLatitude, ...}, exifSummary}. EXIF is parsed from JPEG files; exifSummary is a one-line photographer credit." + }, + { + "name": "search", + "type": "string", + "value": "", + "required": false, + "label": "Filter filenames by keyword" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "900" + } + }, + "data/log": { + "name": "Log JSON", + "description": "Log a JSON object", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "inputJson", + "type": "json", + "required": false, + "label": "Input JSON" + } + ], + "output": [ + { + "name": "outputJson", + "type": "json" + } + ] + }, + "data/newImage": { + "name": "New Image", + "description": "Create a new image with a single color background", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "width", + "type": "integer", + "required": false, + "label": "Width", + "placeholder": "auto" + }, + { + "name": "height", + "type": "integer", + "required": false, + "label": "Height", + "placeholder": "auto" + }, + { + "name": "color", + "type": "color", + "value": "#ffffff", + "required": true, + "label": "Color" + }, + { + "name": "opacity", + "type": "float", + "required": false, + "label": "Opacity (0-1)", + "value": "1" + }, + { + "name": "renderNext", + "type": "node", + "required": false, + "label": "Render next" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true + } + }, + "data/openaiImage": { + "name": "OpenAI Image", + "description": "Random AI generated art from OpenAI's image models", + "category": "data", + "version": "1.0.0", + "settings": [ + "openAI" + ], + "fields": [ + { + "name": "prompt", + "type": "text", + "rows": 4, + "value": "", + "required": true, + "label": "Prompt", + "placeholder": "e.g. pumpkin pyjama party, digital art" + }, + { + "name": "model", + "type": "select", + "options": [ + "gpt-image-2", + "gpt-image-1.5", + "gpt-image-1", + "dall-e-3", + "dall-e-2" + ], + "value": "gpt-image-2", + "required": true, + "label": "Model" + }, + { + "name": "size", + "type": "select", + "options": [ + "best for orientation", + "1024x640", + "640x1024", + "1024x1024", + "1536x1024", + "1024x1536" + ], + "value": "best for orientation", + "label": "Size", + "showIf": [ + { + "field": "model", + "operator": "eq", + "value": "gpt-image-2" + } + ] + }, + { + "name": "size", + "type": "select", + "options": [ + "best for orientation", + "1024x1024", + "1536x1024", + "1024x1536" + ], + "value": "best for orientation", + "label": "Size", + "showIf": [ + { + "field": "model", + "operator": "eq", + "value": "gpt-image-1" + }, + { + "field": "model", + "operator": "eq", + "value": "gpt-image-1.5" + } + ] + }, + { + "name": "size", + "type": "select", + "options": [ + "best for orientation", + "1024x1024", + "1792x1024", + "1024x1792" + ], + "value": "best for orientation", + "label": "Size", + "showIf": [ + { + "field": "model", + "operator": "eq", + "value": "dall-e-3" + } + ] + }, + { + "name": "size", + "type": "select", + "options": [ + "best for orientation", + "1024x1024", + "512x512", + "256x256" + ], + "value": "best for orientation", + "label": "Size", + "showIf": [ + { + "field": "model", + "operator": "eq", + "value": "dall-e-2" + } + ] + }, + { + "name": "style", + "type": "select", + "options": [ + "vivid", + "natural", + "" + ], + "value": "vivid", + "label": "Style", + "showIf": [ + { + "field": "model", + "operator": "eq", + "value": "dall-e-3" + } + ] + }, + { + "name": "quality", + "type": "select", + "options": [ + "standard", + "hd", + "" + ], + "value": "standard", + "label": "Quality", + "showIf": [ + { + "field": "model", + "operator": "eq", + "value": "dall-e-3" + } + ] + }, + { + "name": "quality", + "type": "select", + "options": [ + "auto", + "low", + "medium", + "high", + "" + ], + "value": "auto", + "label": "Quality", + "showIf": [ + { + "field": "model", + "operator": "eq", + "value": "gpt-image-2" + }, + { + "field": "model", + "operator": "eq", + "value": "gpt-image-1" + }, + { + "field": "model", + "operator": "eq", + "value": "gpt-image-1.5" + } + ] + }, + { + "name": "outputFormat", + "type": "select", + "options": [ + "auto", + "png", + "jpeg", + "webp" + ], + "value": "auto", + "label": "Output format", + "showIf": [ + { + "field": "model", + "operator": "eq", + "value": "gpt-image-2" + }, + { + "field": "model", + "operator": "eq", + "value": "gpt-image-1" + }, + { + "field": "model", + "operator": "eq", + "value": "gpt-image-1.5" + } + ] + }, + { + "name": "outputCompression", + "type": "integer", + "value": 0, + "min": 0, + "max": 100, + "label": "Output compression", + "hint": "Compression for JPEG/WebP outputs. Use 0 for the runtime default.", + "showIf": [ + { + "and": [ + { + "field": "outputFormat", + "operator": "ne", + "value": "png" + }, + { + "field": "model", + "operator": "eq", + "value": "gpt-image-2" + } + ] + }, + { + "and": [ + { + "field": "outputFormat", + "operator": "ne", + "value": "png" + }, + { + "field": "model", + "operator": "eq", + "value": "gpt-image-1" + } + ] + }, + { + "and": [ + { + "field": "outputFormat", + "operator": "ne", + "value": "png" + }, + { + "field": "model", + "operator": "eq", + "value": "gpt-image-1.5" + } + ] + } + ] + }, + { + "name": "metadataStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Metadata state key", + "hint": "Enter a state key to persist metadata about the image. Stores: {prompt, generatedPrompt, model, size}." + }, + { + "name": "saveAssets", + "type": "select", + "value": "auto", + "options": [ + "auto", + "always", + "never" + ], + "label": "Save asset", + "hint": "Save the generated image to disk as an asset. It'll be placed into the frame's assets folder.\n\nYou can later use the 'Local image' app to view saved assets.\n\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved." + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "3600" + } + }, + "data/openaiText": { + "name": "OpenAI Text", + "description": "Text response from ChatGPT and friends", + "category": "data", + "version": "1.0.0", + "settings": [ + "openAI" + ], + "fields": [ + { + "name": "system", + "type": "text", + "rows": 6, + "value": "You're a smart e-ink frame running FrameOS. Reply with plain text only. Space is very limited.", + "required": true, + "label": "System Prompt", + "placeholder": "" + }, + { + "name": "user", + "type": "text", + "rows": 6, + "value": "", + "required": true, + "label": "User Prompt", + "placeholder": "Write your prompt here. Keep it short and clear." + }, + { + "name": "model", + "type": "string", + "value": "gpt-5.5", + "required": true, + "label": "Model" + } + ], + "output": [ + { + "name": "reply", + "type": "string" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "600" + } + }, + "data/parseJson": { + "name": "Parse JSON", + "description": "Parse a text string and convert to JSON", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "text", + "type": "text", + "required": true, + "label": "Text" + } + ], + "output": [ + { + "name": "json", + "type": "json" + } + ] + }, + "data/prettyJson": { + "name": "Pretty JSON", + "description": "Pretty print JSON into a string", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "json", + "type": "json", + "required": true, + "label": "JSON" + }, + { + "name": "ident", + "type": "integer", + "value": "2", + "required": false, + "label": "Indent" + }, + { + "name": "prettify", + "type": "boolean", + "value": "true", + "required": false, + "label": "Prettify" + } + ], + "output": [ + { + "name": "result", + "type": "string" + } + ] + }, + "data/qr": { + "name": "QR Code", + "description": "QR codes. Default to a link to the frame control URL.", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "codeType", + "label": "Code Type", + "type": "select", + "options": [ + "Frame Control URL", + "Frame Image URL", + "Custom" + ], + "value": "Frame Control URL", + "required": false + }, + { + "name": "code", + "label": "Custom code", + "type": "string", + "value": "", + "required": false, + "showIf": [ + { + "field": "codeType", + "operator": "eq", + "value": "Custom" + } + ] + }, + { + "name": "size", + "type": "float", + "value": "2", + "label": "Size" + }, + { + "name": "sizeUnit", + "type": "select", + "options": [ + "pixels per dot", + "pixels total", + "percent" + ], + "value": "pixels per dot", + "required": true, + "label": "Size unit" + }, + { + "name": "alRad", + "type": "float", + "value": "30", + "label": "Alignment pattern radius %" + }, + { + "name": "moRad", + "type": "float", + "value": "0", + "label": "Module radius %" + }, + { + "name": "moSep", + "type": "float", + "value": "0", + "label": "Module separation %" + }, + { + "name": "padding", + "type": "integer", + "value": "1", + "required": true, + "label": "Padding in dots", + "placeholder": "1" + }, + { + "name": "qrCodeColor", + "type": "color", + "value": "#000000", + "required": true, + "label": "QR Code Color", + "placeholder": "#000000" + }, + { + "name": "backgroundColor", + "type": "color", + "value": "#ffffff", + "required": true, + "label": "Background Color", + "placeholder": "#ffffff" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true + } + }, + "data/resizeImage": { + "name": "Resize", + "description": "Scale or stretch an image", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "image", + "type": "image", + "required": true, + "label": "Image" + }, + { + "name": "width", + "type": "integer", + "required": true, + "label": "New Width", + "placeholder": "e.g., 1024" + }, + { + "name": "height", + "type": "integer", + "required": true, + "label": "New Height", + "placeholder": "e.g., 1024" + }, + { + "name": "scalingMode", + "type": "select", + "options": [ + "cover", + "contain", + "stretch", + "center" + ], + "value": "contain", + "required": true, + "label": "Scaling mode" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "data/rotateImage": { + "name": "Rotate", + "description": "Rotate an image", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "image", + "type": "image", + "required": true, + "label": "Image" + }, + { + "name": "rotationDegree", + "type": "float", + "value": "0", + "required": true, + "label": "Rotation Degree", + "placeholder": "e.g., 45" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "data/rstpSnapshot": { + "name": "RSTP Snapshot", + "description": "Take a still image from a webcam feed with ffmpeg", + "category": "data", + "version": "1.0.0", + "apt": [ + "ffmpeg" + ], + "fields": [ + { + "name": "url", + "type": "text", + "value": "", + "required": true, + "label": "RTSP URL", + "placeholder": "rstp://domain/cam" + }, + { + "name": "timeoutSeconds", + "type": "integer", + "value": 15, + "required": true, + "label": "FFmpeg timeout in seconds", + "placeholder": "15" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "900" + } + }, + "data/unsplash": { + "name": "Unsplash", + "description": "Random unsplash image", + "category": "data", + "version": "1.0.0", + "settings": [ + "unsplash" + ], + "fields": [ + { + "name": "search", + "type": "string", + "value": "nature", + "required": false, + "label": "Search", + "placeholder": "e.g. pineapple, nature, birds, power" + }, + { + "name": "orientation", + "type": "select", + "value": "auto", + "options": [ + "auto", + "any", + "landscape", + "portrait", + "squarish" + ], + "required": false, + "label": "Orientation", + "placeholder": "landscape, portrait, square" + }, + { + "name": "metadataStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Metadata state key", + "hint": "Enter a state key to persist metadata about the image. Stores: {search, title, description, location, author, links, ...}." + }, + { + "name": "saveAssets", + "type": "select", + "value": "auto", + "options": [ + "auto", + "always", + "never" + ], + "label": "Save asset", + "hint": "Save the generated image to disk as an asset. It'll be placed into the frame's assets folder.\n\nYou can later use the 'Local image' app to view saved assets.\n\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved." + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "900" + } + }, + "data/weather": { + "name": "Weather (Open-Meteo)", + "description": "Fetch current, hourly, and daily forecasts from Open-Meteo for a location and date.", + "category": "data", + "version": "1.1.0", + "fields": [ + { + "name": "location", + "type": "string", + "value": "", + "required": true, + "label": "Location", + "placeholder": "City, state, or country" + }, + { + "name": "date", + "type": "date", + "value": "", + "required": false, + "label": "Date", + "placeholder": "2024-01-30" + }, + { + "name": "forecastDays", + "type": "integer", + "value": "1", + "required": false, + "label": "Forecast days (1-16)", + "hint": "How many days of hourly and daily forecast to fetch. Ignored when a specific date is set.", + "showIf": [ + { + "field": "date", + "operator": "empty" + } + ] + }, + { + "name": "temperatureUnit", + "type": "select", + "options": [ + "celsius", + "fahrenheit" + ], + "value": "celsius", + "required": true, + "label": "Temperature unit" + }, + { + "name": "windSpeedUnit", + "type": "select", + "options": [ + "kmh", + "ms", + "mph", + "kn" + ], + "value": "kmh", + "required": true, + "label": "Wind speed unit" + }, + { + "name": "precipitationUnit", + "type": "select", + "options": [ + "mm", + "inch" + ], + "value": "mm", + "required": true, + "label": "Precipitation unit" + } + ], + "output": [ + { + "name": "weather", + "type": "json", + "example": "{\n \"provider\": \"open-meteo\",\n \"forecastModes\": [\n \"current\",\n \"hourly\",\n \"daily\"\n ],\n \"date\": \"2026-01-18\",\n \"location\": {\n \"name\": \"Brussels\",\n \"latitude\": 50.85045,\n \"longitude\": 4.34878,\n \"timezone\": \"auto\",\n \"country\": \"Belgium\",\n \"countryCode\": \"BE\",\n \"admin1\": \"Brussels Capital\",\n \"admin2\": \"Bruxelles-Capitale\"\n },\n \"forecast\": {\n \"latitude\": 50.854,\n \"longitude\": 4.35,\n \"generationtime_ms\": 0.3679990768432617,\n \"utc_offset_seconds\": 3600,\n \"timezone\": \"Europe/Brussels\",\n \"timezone_abbreviation\": \"GMT+1\",\n \"elevation\": 27,\n \"current_weather_units\": {\n \"time\": \"iso8601\",\n \"interval\": \"seconds\",\n \"temperature\": \"\\u00b0C\",\n \"windspeed\": \"km/h\",\n \"winddirection\": \"\\u00b0\",\n \"is_day\": \"\",\n \"weathercode\": \"wmo code\"\n },\n \"current_weather\": {\n \"time\": \"2026-01-18T22:15\",\n \"interval\": 900,\n \"temperature\": 5.7,\n \"windspeed\": 5.4,\n \"winddirection\": 132,\n \"is_day\": 0,\n \"weathercode\": 0\n },\n \"hourly_units\": {\n \"time\": \"iso8601\",\n \"temperature_2m\": \"\\u00b0C\",\n \"apparent_temperature\": \"\\u00b0C\",\n \"precipitation\": \"mm\",\n \"weathercode\": \"wmo code\",\n \"windspeed_10m\": \"km/h\",\n \"winddirection_10m\": \"\\u00b0\"\n },\n \"hourly\": {\n \"time\": [\n \"2026-01-18T00:00\",\n \"2026-01-18T01:00\",\n \"2026-01-18T02:00\",\n \"2026-01-18T03:00\",\n \"2026-01-18T04:00\",\n \"2026-01-18T05:00\",\n \"2026-01-18T06:00\",\n \"2026-01-18T07:00\",\n \"2026-01-18T08:00\",\n \"2026-01-18T09:00\",\n \"2026-01-18T10:00\",\n \"2026-01-18T11:00\",\n \"2026-01-18T12:00\",\n \"2026-01-18T13:00\",\n \"2026-01-18T14:00\",\n \"2026-01-18T15:00\",\n \"2026-01-18T16:00\",\n \"2026-01-18T17:00\",\n \"2026-01-18T18:00\",\n \"2026-01-18T19:00\",\n \"2026-01-18T20:00\",\n \"2026-01-18T21:00\",\n \"2026-01-18T22:00\",\n \"2026-01-18T23:00\"\n ],\n \"temperature_2m\": [\n 9.2,\n 8.2,\n 8.3,\n 8.1,\n 7.2,\n 7.1,\n 7.3,\n 7.1,\n 6.7,\n 7.3,\n 8.5,\n 8.5,\n 9.7,\n 10.9,\n 11.1,\n 11.3,\n 11.1,\n 9.7,\n 8.7,\n 8,\n 6.8,\n 6.2,\n 5.8,\n 5.5\n ],\n \"apparent_temperature\": [\n 7.5,\n 6.3,\n 6.5,\n 6.2,\n 5.1,\n 4.8,\n 4.9,\n 4.8,\n 3.7,\n 4.4,\n 5.6,\n 5.6,\n 6.9,\n 8.7,\n 8,\n 8.6,\n 8.3,\n 6.7,\n 5.9,\n 5,\n 3.6,\n 3.5,\n 3.3,\n 2.4\n ],\n \"precipitation\": [\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0,\n 0\n ],\n \"weathercode\": [\n 0,\n 1,\n 3,\n 3,\n 3,\n 3,\n 3,\n 3,\n 3,\n 3,\n 3,\n 0,\n 0,\n 1,\n 2,\n 2,\n 0,\n 3,\n 3,\n 2,\n 0,\n 0,\n 0,\n 0\n ],\n \"windspeed_10m\": [\n 4,\n 3.6,\n 2.9,\n 3.6,\n 5.8,\n 5,\n 6.5,\n 5,\n 9.4,\n 9,\n 8.6,\n 9,\n 9.4,\n 5.8,\n 9.7,\n 7.9,\n 8.6,\n 9.4,\n 10.4,\n 8.6,\n 9.7,\n 5.8,\n 5,\n 7.2\n ],\n \"winddirection_10m\": [\n 166,\n 59,\n 353,\n 67,\n 78,\n 52,\n 71,\n 68,\n 101,\n 54,\n 87,\n 80,\n 62,\n 66,\n 69,\n 74,\n 92,\n 73,\n 77,\n 110,\n 117,\n 202,\n 132,\n 168\n ]\n },\n \"daily_units\": {\n \"time\": \"iso8601\",\n \"temperature_2m_max\": \"\\u00b0C\",\n \"temperature_2m_min\": \"\\u00b0C\",\n \"precipitation_sum\": \"mm\",\n \"weathercode\": \"wmo code\",\n \"sunrise\": \"iso8601\",\n \"sunset\": \"iso8601\",\n \"windspeed_10m_max\": \"km/h\"\n },\n \"daily\": {\n \"time\": [\n \"2026-01-18\"\n ],\n \"temperature_2m_max\": [\n 11.3\n ],\n \"temperature_2m_min\": [\n 5.5\n ],\n \"precipitation_sum\": [\n 0\n ],\n \"weathercode\": [\n 3\n ],\n \"sunrise\": [\n \"2026-01-18T08:35\"\n ],\n \"sunset\": [\n \"2026-01-18T17:10\"\n ],\n \"windspeed_10m_max\": [\n 10.4\n ]\n }\n }\n}" + } + ] + }, + "data/wikicommons": { + "name": "Wikimedia Commons", + "description": "Images from Wikimedia Commons", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "mode", + "type": "select", + "value": "pictureOfTheDay", + "options": [ + "pictureOfTheDay", + "onThisDay", + "randomPictureOfTheDay", + "randomImage" + ], + "required": false, + "label": "Mode", + "hint": "Choose today's Picture of the Day, the Picture of the Day from this date in a previous year, a random previous Picture of the Day, or a random Commons image." + }, + { + "name": "saveAssets", + "type": "select", + "value": "auto", + "options": [ + "auto", + "always", + "never" + ], + "label": "Save asset", + "hint": "Save the generated image to disk as an asset. It'll be placed into the frame's assets folder.\n\nYou can later use the 'Local image' app to view saved assets.\n\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved." + }, + { + "name": "metadataStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Metadata state key", + "placeholder": "e.g. wikimediaMetadata", + "hint": "Enter a state key to persist metadata about the image. Stores: {title, description, author, license, pageUrl, imageUrl, mime}." + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ], + "cache": { + "enabled": true, + "inputEnabled": true, + "durationEnabled": true, + "duration": "3600" + } + }, + "data/xmlToJson": { + "name": "XML to JSON", + "description": "Convert XML into a JSON tree using Nim's xmlparser", + "category": "data", + "version": "1.0.0", + "fields": [ + { + "name": "xml", + "type": "string", + "required": true, + "label": "XML" + } + ], + "output": [ + { + "name": "json", + "type": "json" + } + ] + }, + "legacy/clock": { + "name": "Clock (legacy)", + "description": "Overlay current time on the image", + "category": "legacy", + "version": "1.0.0", + "fields": [ + { + "markdown": "[Date format syntax](https://nim-lang.org/2.0.2/times.html)" + }, + { + "name": "format", + "type": "select", + "options": [ + "yyyy-MM-dd", + "yyyy-MM-dd HH:mm:ss", + "HH:mm:ss:fff", + "HH:mm:ss", + "HH:mm", + "custom" + ], + "value": "HH:mm:ss", + "required": true, + "label": "Format", + "placeholder": "HH:mm:ss" + }, + { + "name": "formatCustom", + "type": "string", + "value": "", + "required": false, + "label": "Custom format", + "placeholder": "" + }, + { + "name": "position", + "type": "select", + "options": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center-center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "value": "center-center", + "required": true, + "label": "Position", + "placeholder": "center-center" + }, + { + "name": "offsetX", + "type": "float", + "value": "0", + "required": true, + "label": "Offset X", + "placeholder": "0" + }, + { + "name": "offsetY", + "type": "float", + "value": "0", + "required": true, + "label": "Offset Y", + "placeholder": "0" + }, + { + "name": "padding", + "type": "float", + "value": "10", + "required": true, + "label": "Padding", + "placeholder": "10" + }, + { + "name": "fontColor", + "type": "color", + "value": "#ffffff", + "required": true, + "label": "Font Color", + "placeholder": "#ffffff" + }, + { + "name": "fontSize", + "type": "float", + "value": "32", + "required": true, + "label": "Font Size", + "placeholder": "32" + }, + { + "name": "borderColor", + "type": "color", + "value": "#000000", + "required": true, + "label": "Border Color", + "placeholder": "#000000" + }, + { + "name": "borderWidth", + "type": "integer", + "value": "2", + "required": true, + "label": "Border width", + "placeholder": "2" + } + ] + }, + "legacy/downloadImage": { + "name": "Download Image (legacy)", + "description": "Download image from an URL", + "category": "legacy", + "version": "1.0.0", + "fields": [ + { + "name": "url", + "type": "text", + "value": "", + "required": true, + "label": "Image URL", + "placeholder": "https://domain/image" + }, + { + "name": "scalingMode", + "type": "select", + "options": [ + "cover", + "contain", + "stretch", + "center" + ], + "value": "cover", + "required": true, + "label": "Scaling mode" + }, + { + "name": "cacheSeconds", + "type": "float", + "value": "3600", + "required": false, + "label": "Seconds to cache the result", + "placeholder": "Default: 3600 (1h). Use 0 for no cache" + } + ] + }, + "legacy/frameOSGallery": { + "name": "FrameOS Gallery (legacy)", + "description": "Random image from the FrameOS gallery", + "category": "legacy", + "version": "1.0.0", + "settings": [ + "frameOS" + ], + "fields": [ + { + "markdown": "[Click here](https://gallery.frameos.net/) to see all the galleries." + }, + { + "name": "category", + "type": "select", + "options": [ + "building-art-styles", + "cute", + "cyberpunk-europe", + "masterpieces", + "space-gallery", + "space-odyssey" + ], + "value": "cute", + "required": false, + "label": "Category" + }, + { + "name": "scalingMode", + "type": "select", + "options": [ + "cover", + "contain", + "stretch", + "center" + ], + "value": "cover", + "required": true, + "label": "Scaling mode" + }, + { + "name": "cacheSeconds", + "type": "float", + "value": "3600", + "required": false, + "label": "Seconds to cache the result", + "placeholder": "Default: 3600 (1h). Use 0 for no cache" + } + ] + }, + "legacy/haSensor": { + "name": "HA Sensor (legacy)", + "description": "Store the state of a Home Assistant entity in the scene's state", + "category": "legacy", + "version": "1.0.0", + "settings": [ + "homeAssistant" + ], + "fields": [ + { + "markdown": "Find the [entity id here](http://homeassistant.local:8123/config/entities). Then use code like:\n\nscene.state{\"water_heater\"}{\"state\"}.getStr" + }, + { + "name": "entityId", + "type": "text", + "required": true, + "label": "Entity ID", + "placeholder": "Home Assistant entity name. Example: sensor.home_solar_percentage or water_heater.hot_water" + }, + { + "name": "stateKey", + "type": "text", + "required": true, + "label": "State key to store the json in", + "value": "sensor", + "placeholder": "" + }, + { + "name": "cacheSeconds", + "type": "float", + "value": "60", + "required": false, + "label": "Seconds to cache the result", + "placeholder": "Default: 60. Use 0 for no cache" + }, + { + "name": "debug", + "type": "boolean", + "value": "false", + "required": false, + "label": "Debug logging" + } + ] + }, + "legacy/localImage": { + "name": "Local Image (legacy)", + "description": "Show an image from the SD card", + "category": "legacy", + "version": "1.0.0", + "fields": [ + { + "name": "path", + "type": "text", + "value": "/srv/images", + "required": true, + "label": "Image filename or folder", + "placeholder": "/srv/images" + }, + { + "name": "order", + "type": "select", + "options": [ + "random", + "alphabetical" + ], + "value": "random", + "required": true, + "label": "Order of images" + }, + { + "name": "seconds", + "type": "float", + "value": "900", + "required": false, + "label": "Seconds to show one image", + "placeholder": "Default: 900 (15min)" + }, + { + "name": "scalingMode", + "type": "select", + "options": [ + "cover", + "contain", + "stretch", + "center" + ], + "value": "cover", + "required": true, + "label": "Scaling mode" + }, + { + "name": "counterStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Optional state key for persistence" + } + ] + }, + "legacy/openai": { + "name": "OpenAI Image (legacy)", + "description": "Random AI generated art from OpenAI's image models", + "category": "legacy", + "version": "1.0.0", + "settings": [ + "openAI" + ], + "fields": [ + { + "name": "prompt", + "type": "text", + "rows": 6, + "value": "", + "required": true, + "label": "Prompt", + "placeholder": "e.g. pumpkin pyjama party, digital art" + }, + { + "name": "model", + "type": "select", + "options": [ + "gpt-image-2", + "gpt-image-1.5", + "gpt-image-1", + "dall-e-3", + "dall-e-2" + ], + "value": "gpt-image-2", + "required": true, + "label": "Model" + }, + { + "name": "size", + "type": "select", + "options": [ + "best for orientation", + "1024x1024", + "1536x1024", + "1024x1536" + ], + "value": "best for orientation", + "label": "Size" + }, + { + "name": "scalingMode", + "type": "select", + "options": [ + "cover", + "contain", + "stretch", + "center" + ], + "value": "cover", + "required": true, + "label": "Scaling mode" + }, + { + "name": "style", + "type": "select", + "options": [ + "vivid", + "natural", + "" + ], + "value": "vivid", + "label": "Style" + }, + { + "name": "quality", + "type": "select", + "options": [ + "standard", + "hd", + "" + ], + "value": "standard", + "label": "Quality" + }, + { + "name": "cacheSeconds", + "type": "float", + "value": "3600", + "required": false, + "label": "Seconds to cache each prompt", + "placeholder": "Default: 3600 (1h). Use 0 for no cache" + } + ] + }, + "legacy/openaiText": { + "name": "OpenAI Text (legacy)", + "description": "Text response from ChatGPT and friends", + "category": "legacy", + "version": "1.0.0", + "settings": [ + "openAI" + ], + "fields": [ + { + "name": "system", + "type": "text", + "rows": 6, + "value": "You're a smart e-ink frame running FrameOS. Reply with plain text only. Space is very limited.", + "required": true, + "label": "System Prompt", + "placeholder": "" + }, + { + "name": "user", + "type": "text", + "rows": 6, + "value": "", + "required": true, + "label": "User Prompt", + "placeholder": "Write your prompt here. Keep it short and clear." + }, + { + "name": "model", + "type": "string", + "value": "gpt-5.5", + "required": true, + "label": "Model" + }, + { + "name": "stateKey", + "type": "string", + "value": "reply", + "required": true, + "label": "State key for reply" + }, + { + "name": "cacheSeconds", + "type": "float", + "value": "3600", + "required": false, + "label": "Seconds to cache each prompt", + "placeholder": "Default: 3600 (1h). Use 0 for no cache" + } + ] + }, + "legacy/qr": { + "name": "QR Code (legacy)", + "description": "Display QR codes. Default to link to self.", + "category": "legacy", + "version": "1.0.0", + "fields": [ + { + "name": "codeType", + "label": "Code Type", + "type": "select", + "options": [ + "Frame Control URL", + "Frame Image URL", + "Custom" + ], + "value": "Frame Control URL", + "required": false + }, + { + "name": "code", + "label": "Code (if Custom above)", + "type": "string", + "value": "", + "required": false + }, + { + "name": "size", + "type": "float", + "value": "2", + "label": "Size" + }, + { + "name": "sizeUnit", + "type": "select", + "options": [ + "percent", + "pixels per dot", + "pixels total" + ], + "value": "pixels per dot", + "required": true, + "label": "Size unit" + }, + { + "name": "alRad", + "type": "float", + "value": "30", + "label": "Alignment pattern radius %" + }, + { + "name": "moRad", + "type": "float", + "value": "0", + "label": "Module radius %" + }, + { + "name": "moSep", + "type": "float", + "value": "0", + "label": "Module separation %" + }, + { + "name": "position", + "type": "select", + "options": [ + "top-left", + "top-center", + "top-right", + "center-left", + "center-center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "value": "center-center", + "required": true, + "label": "Position", + "placeholder": "center-center" + }, + { + "name": "offsetX", + "type": "float", + "value": "0", + "required": true, + "label": "Offset X", + "placeholder": "0" + }, + { + "name": "offsetY", + "type": "float", + "value": "0", + "required": true, + "label": "Offset Y", + "placeholder": "0" + }, + { + "name": "padding", + "type": "integer", + "value": "1", + "required": true, + "label": "Padding in dots", + "placeholder": "1" + }, + { + "name": "qrCodeColor", + "type": "color", + "value": "#000000", + "required": true, + "label": "QR Code Color", + "placeholder": "#000000" + }, + { + "name": "backgroundColor", + "type": "color", + "value": "#ffffff", + "required": true, + "label": "Background Color", + "placeholder": "#ffffff" + } + ] + }, + "legacy/resize": { + "name": "Resize (legacy)", + "description": "Scale or stretch the image", + "category": "legacy", + "version": "1.0.0", + "fields": [ + { + "name": "width", + "type": "integer", + "required": true, + "label": "New Width", + "placeholder": "e.g., 1024" + }, + { + "name": "height", + "type": "integer", + "required": true, + "label": "New Height", + "placeholder": "e.g., 1024" + }, + { + "name": "scalingMode", + "type": "select", + "options": [ + "cover", + "contain", + "stretch", + "center" + ], + "value": "contain", + "required": true, + "label": "Scaling mode" + } + ] + }, + "legacy/rotate": { + "name": "Rotate (legacy)", + "description": "Rotate the image", + "category": "legacy", + "version": "1.0.0", + "fields": [ + { + "name": "rotationDegree", + "type": "float", + "value": "0", + "required": true, + "label": "Rotation Degree", + "placeholder": "e.g., 45" + }, + { + "name": "scalingMode", + "type": "select", + "options": [ + "expand", + "cover", + "contain", + "stretch", + "center" + ], + "value": "cover", + "required": true, + "label": "Scaling mode" + } + ] + }, + "legacy/unsplash": { + "name": "Unsplash (legacy/broken)", + "description": "Random unsplash image", + "category": "legacy", + "version": "1.0.0", + "fields": [ + { + "name": "keyword", + "type": "string", + "value": "nature", + "required": false, + "label": "Random keyword (one word)", + "placeholder": "e.g. pineapple, nature, birds, power" + }, + { + "name": "cacheSeconds", + "type": "float", + "value": "3600", + "required": false, + "label": "Seconds to cache the result", + "placeholder": "Default: 3600 (1h). Use 0 to refetch on every render." + } + ] + }, + "logic/breakIfRendering": { + "name": "Break if rendering", + "description": "Cancel execution if the event is dispatched when the scene is rendering", + "category": "logic", + "version": "1.0.0", + "fields": [] + }, + "logic/ifElse": { + "name": "If-Else", + "description": "If Condition Then Node Else Node", + "category": "logic", + "version": "1.0.0", + "settings": [], + "fields": [ + { + "label": "Condition", + "name": "condition", + "value": "true", + "type": "boolean", + "required": true + }, + { + "label": "Truthy", + "name": "thenNode", + "type": "node" + }, + { + "label": "Falsy", + "name": "elseNode", + "type": "node" + } + ] + }, + "logic/nextSleepDuration": { + "name": "Next sleep duration", + "description": "Override the delay between renders", + "category": "logic", + "version": "1.0.0", + "fields": [ + { + "name": "duration", + "type": "float", + "required": true, + "label": "Duration in seconds" + } + ] + }, + "logic/setAsState": { + "name": "Set as state", + "description": "Save the value (json node) as a state variable", + "category": "logic", + "version": "1.0.0", + "fields": [ + { + "name": "stateKey", + "type": "string", + "required": true, + "label": "State key", + "value": "", + "placeholder": "" + }, + { + "name": "valueString", + "type": "string", + "required": false, + "label": "Value as a string" + }, + { + "name": "valueJson", + "type": "json", + "required": false, + "label": "Value as JSON" + }, + { + "name": "debugLog", + "type": "boolean", + "required": false, + "label": "Log to console", + "value": false + } + ] + }, + "render/calendar": { + "name": "Calendar", + "description": "Render a monthly calendar with events", + "category": "render", + "version": "1.3.0", + "fields": [ + { + "name": "inputImage", + "type": "image", + "value": "", + "required": false, + "label": "Background image" + }, + { + "name": "events", + "type": "json", + "required": false, + "value": "[]", + "label": "Events JSON" + }, + { + "name": "year", + "type": "integer", + "value": "0", + "required": false, + "label": "Year (0 = current)" + }, + { + "name": "month", + "type": "integer", + "value": "0", + "required": false, + "label": "Month (1–12; 0 = current)" + }, + { + "name": "startWeekOnMonday", + "type": "boolean", + "value": "true", + "required": true, + "label": "Week starts on Monday" + }, + { + "name": "scale", + "type": "integer", + "value": "100", + "required": true, + "label": "Scale (%) — scales fonts, paddings, strokes" + }, + { + "name": "theme", + "type": "select", + "options": [ + "light", + "dark", + "custom" + ], + "value": "light", + "required": true, + "label": "Calendar theme" + }, + { + "name": "transparentBackground", + "type": "boolean", + "value": false, + "required": true, + "label": "Transparent background" + }, + { + "name": "backgroundColor", + "type": "color", + "value": "#ffffff", + "required": true, + "label": "Overall background color", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "weekendBackgroundColor", + "type": "color", + "value": "#9eafd6", + "required": true, + "label": "Weekend day background color (Sat/Sun cells)", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "todayStrokeColor", + "type": "color", + "value": "#ff0000", + "required": true, + "label": "Today's cell outline color", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "todayBackgroundColor", + "type": "color", + "value": "#e5afaf", + "required": true, + "label": "Today's cell background color", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "todayStrokeWidth", + "type": "float", + "value": "2", + "required": true, + "label": "Today's cell outline thickness", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "dateTextColor", + "type": "color", + "value": "#000000", + "required": true, + "label": "Date number color", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "eventTimeColor", + "type": "color", + "value": "#333333", + "required": true, + "label": "Event time color", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "eventTitleColor", + "type": "color", + "value": "#333333", + "required": true, + "label": "Event title color", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "titleFont", + "type": "font", + "required": false, + "label": "Title font (used for month & year)", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "titleFontSize", + "type": "float", + "value": "28", + "required": true, + "label": "Month & year title font size", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "titleTextColor", + "type": "color", + "value": "#000000", + "required": true, + "label": "Title text color", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "titleBackgroundColor", + "type": "color", + "value": "#ffffff", + "required": true, + "label": "Title background", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "weekdayFont", + "type": "font", + "required": false, + "label": "Weekday font (used for weekday row)", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "weekdayFontSize", + "type": "float", + "value": "16", + "required": true, + "label": "Weekday row font size", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "weekdayTextColor", + "type": "color", + "value": "#000000", + "required": true, + "label": "Weekday text color", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "weekdayBackgroundColor", + "type": "color", + "value": "#ffffff", + "required": true, + "label": "Weekday background", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "dateFont", + "type": "font", + "required": false, + "label": "Date number font", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "dateFontSize", + "type": "float", + "value": "18", + "required": true, + "label": "Date number font size", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "eventTitleFont", + "type": "font", + "required": false, + "label": "Event title font", + "value": "Ubuntu-Medium.ttf", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "eventTimeFont", + "type": "font", + "required": false, + "label": "Event time font", + "value": "Ubuntu-Light.ttf", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "eventFontSize", + "type": "float", + "value": "14", + "required": true, + "label": "Event font size", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "padding", + "type": "integer", + "value": "18", + "required": true, + "label": "Outer padding (px)", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "showMonthYear", + "type": "boolean", + "value": "true", + "required": true, + "label": "Show month & year title", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "monthYearPosition", + "type": "select", + "options": [ + "top", + "bottom", + "none" + ], + "value": "top", + "required": true, + "label": "Title position (ignored if hidden)", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "showGrid", + "type": "boolean", + "value": "true", + "required": true, + "label": "Show grid lines", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "gridWidth", + "type": "float", + "value": "1", + "required": true, + "label": "Grid stroke width (px)", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "gridColor", + "type": "color", + "value": "#999999", + "required": true, + "label": "Grid line color", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "name": "showEventTimes", + "type": "boolean", + "value": "true", + "required": true, + "label": "Show times for non all-day events", + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "label": "All day event color count", + "name": "eventColorCount", + "value": "1", + "type": "integer", + "required": true, + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "label": "All day event background #{color}", + "name": "eventColorBackground", + "type": "color", + "value": "#ffffff", + "seq": [ + [ + "color", + 1, + "eventColorCount" + ] + ], + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + }, + { + "label": "All day event foreground #{color}", + "name": "eventColorForeground", + "type": "color", + "value": "#000000", + "seq": [ + [ + "color", + 1, + "eventColorCount" + ] + ], + "showIf": [ + { + "field": "theme", + "operator": "eq", + "value": "custom" + } + ] + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "render/chart": { + "name": "Chart", + "description": "Graphs and charts", + "category": "render", + "version": "1.0.0", + "fields": [ + { + "name": "inputImage", + "type": "image", + "value": "", + "required": false, + "label": "Image to render on (optional)", + "showIf": [ + { + "field": ".meta.showOutput" + } + ] + }, + { + "name": "data", + "type": "json", + "value": "[]", + "required": true, + "label": "Chart data", + "hint": "Accepts [1,2,3], [{\"label\": \"Mon\", \"value\": 3}, ...] or {\"series\": [{\"name\": \"a\", \"color\": \"#ff0000\", \"values\": [1, 2]}], \"labels\": [\"Mon\", \"Tue\"]}" + }, + { + "name": "chartType", + "type": "select", + "options": [ + "line", + "bar", + "area" + ], + "value": "line", + "required": true, + "label": "Chart type" + }, + { + "name": "title", + "type": "string", + "value": "", + "required": false, + "label": "Title" + }, + { + "name": "color", + "type": "color", + "value": "#2a78d6", + "required": true, + "label": "Series color", + "hint": "First series uses this color, extra series get colors from a built-in palette" + }, + { + "name": "transparentBackground", + "type": "boolean", + "value": "true", + "required": true, + "label": "Transparent background" + }, + { + "name": "backgroundColor", + "type": "color", + "value": "#ffffff", + "required": false, + "label": "Background color", + "showIf": [ + { + "field": "transparentBackground", + "operator": "eq", + "value": "false" + } + ] + }, + { + "name": "axisColor", + "type": "color", + "value": "#333333", + "required": true, + "label": "Axis & text color" + }, + { + "name": "showGrid", + "type": "boolean", + "value": "true", + "required": true, + "label": "Show grid lines" + }, + { + "name": "showLabels", + "type": "boolean", + "value": "true", + "required": true, + "label": "Show axis labels" + }, + { + "name": "minY", + "type": "string", + "value": "", + "required": false, + "label": "Min Y (empty = auto)", + "placeholder": "auto" + }, + { + "name": "maxY", + "type": "string", + "value": "", + "required": false, + "label": "Max Y (empty = auto)", + "placeholder": "auto" + }, + { + "name": "lineWidth", + "type": "float", + "value": "2", + "required": false, + "label": "Line width", + "showIf": [ + { + "field": "chartType", + "operator": "in", + "value": [ + "line", + "area" + ] + } + ] + }, + { + "name": "fontSize", + "type": "float", + "value": "16", + "required": true, + "label": "Font size" + }, + { + "name": "padding", + "type": "float", + "value": "16", + "required": true, + "label": "Padding" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "render/color": { + "name": "Color", + "description": "Set a single color background", + "category": "render", + "version": "1.0.0", + "fields": [ + { + "name": "inputImage", + "type": "image", + "value": "", + "required": false, + "label": "Image to render on (optional)", + "showIf": [ + { + "field": ".meta.showOutput" + } + ] + }, + { + "name": "color", + "type": "color", + "value": "#ffffff", + "required": true, + "label": "Color" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "render/gradient": { + "name": "Gradient", + "description": "Set a gradient background", + "category": "render", + "version": "1.0.0", + "fields": [ + { + "name": "inputImage", + "type": "image", + "value": "", + "required": false, + "label": "Image to render on (optional)", + "showIf": [ + { + "field": ".meta.showOutput" + } + ] + }, + { + "name": "startColor", + "type": "color", + "value": "#800080", + "required": true, + "label": "Start color (hex)" + }, + { + "name": "endColor", + "type": "color", + "value": "#ffc0cb", + "required": true, + "label": "End color (hex)" + }, + { + "name": "angle", + "type": "float", + "value": "45", + "required": true, + "label": "Angle (degrees)" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "render/image": { + "name": "Render Image", + "description": "Render an image onto a canvas", + "category": "render", + "version": "1.0.0", + "fields": [ + { + "name": "inputImage", + "type": "image", + "value": "", + "required": false, + "label": "Image to render on (optional)", + "showIf": [ + { + "field": ".meta.showOutput" + } + ] + }, + { + "name": "image", + "type": "image", + "required": true, + "label": "Image" + }, + { + "name": "placement", + "type": "select", + "options": [ + "cover", + "contain", + "stretch", + "center", + "tiled", + "top-left", + "top-center", + "top-right", + "center-left", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "value": "cover", + "required": true, + "label": "Placement" + }, + { + "name": "offsetX", + "type": "integer", + "value": "0", + "required": false, + "label": "Offset X", + "showIf": [ + { + "field": "placement", + "operator": "notIn", + "value": [ + "cover", + "contain", + "stretch" + ] + } + ] + }, + { + "name": "offsetY", + "type": "integer", + "value": "0", + "required": false, + "label": "Offset Y", + "showIf": [ + { + "field": "placement", + "operator": "notIn", + "value": [ + "cover", + "contain", + "stretch" + ] + } + ] + }, + { + "name": "blendMode", + "type": "select", + "options": [ + "normal", + "overwrite", + "darken", + "multiply", + "color-burn", + "lighten", + "screen", + "color-dodge", + "overlay", + "soft-light", + "hard-light", + "difference", + "exclusion", + "hue", + "saturation", + "color", + "luminosity", + "mask", + "inverse-mask", + "exclude-mask" + ], + "value": "normal", + "required": false, + "label": "Blend Mode" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "render/opacity": { + "name": "Opacity", + "description": "Change how transparent an image is", + "category": "render", + "version": "1.0.0", + "fields": [ + { + "name": "image", + "type": "image", + "required": false, + "label": "Image (optional)", + "showIf": [ + { + "field": ".meta.showOutput" + } + ] + }, + { + "name": "opacity", + "type": "float", + "value": "1", + "required": true, + "label": "Opacity (0-1)" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "render/split": { + "name": "Split", + "description": "Render a grid", + "category": "render", + "version": "1.0.0", + "settings": [], + "fields": [ + { + "markdown": "Loop index in: `context.loopIndex`" + }, + { + "name": "inputImage", + "type": "image", + "value": "", + "required": false, + "label": "Image to render on (optional)", + "showIf": [ + { + "field": ".meta.showOutput" + } + ] + }, + { + "label": "Rows", + "name": "rows", + "value": "1", + "type": "integer", + "required": true + }, + { + "label": "Columns", + "name": "columns", + "value": "1", + "type": "integer", + "required": true + }, + { + "label": "Hide empty cells", + "name": "hideEmpty", + "type": "boolean", + "value": "false" + }, + { + "label": "Render cell: {row} x {column}", + "name": "render_functions", + "type": "node", + "seq": [ + [ + "row", + 1, + "rows" + ], + [ + "column", + 1, + "columns" + ] + ], + "showIf": [ + { + "operator": "notEmpty" + }, + { + "field": "hideEmpty", + "operator": "eq", + "value": "false" + } + ] + }, + { + "label": "Render all other cells", + "name": "render_function", + "type": "node" + }, + { + "label": "Gap", + "name": "gap", + "placeholder": "0", + "type": "string" + }, + { + "label": "Margin", + "name": "margin", + "placeholder": "0", + "type": "string" + }, + { + "label": "Widths", + "name": "width_ratios", + "placeholder": "1 2 1", + "hint": "Relative widths of columns, separated by spaces", + "type": "string" + }, + { + "label": "Heights", + "name": "height_ratios", + "placeholder": "1 2 1", + "hint": "Relative heights of rows, separated by spaces", + "type": "string" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "render/svg": { + "name": "Render SVG", + "description": "Render an SVG onto a canvas", + "category": "render", + "version": "1.0.0", + "fields": [ + { + "name": "inputImage", + "type": "image", + "value": "", + "required": false, + "label": "Image to render on (optional)", + "showIf": [ + { + "field": ".meta.showOutput" + } + ] + }, + { + "name": "svg", + "type": "text", + "value": "", + "required": true, + "label": "SVG", + "hint": "Provide raw SVG markup or a data URL like data:image/svg+xml;charset=utf-8,." + }, + { + "name": "placement", + "type": "select", + "options": [ + "cover", + "contain", + "stretch", + "center", + "tiled", + "top-left", + "top-center", + "top-right", + "center-left", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right" + ], + "value": "cover", + "required": true, + "label": "Placement" + }, + { + "name": "offsetX", + "type": "integer", + "value": "0", + "required": false, + "label": "Offset X", + "showIf": [ + { + "field": "placement", + "operator": "notIn", + "value": [ + "cover", + "contain", + "stretch" + ] + } + ] + }, + { + "name": "offsetY", + "type": "integer", + "value": "0", + "required": false, + "label": "Offset Y", + "showIf": [ + { + "field": "placement", + "operator": "notIn", + "value": [ + "cover", + "contain", + "stretch" + ] + } + ] + }, + { + "name": "blendMode", + "type": "select", + "options": [ + "normal", + "overwrite", + "darken", + "multiply", + "color-burn", + "lighten", + "screen", + "color-dodge", + "overlay", + "soft-light", + "hard-light", + "difference", + "exclusion", + "hue", + "saturation", + "color", + "luminosity", + "mask", + "inverse-mask", + "exclude-mask" + ], + "value": "normal", + "required": false, + "label": "Blend Mode" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "render/text": { + "name": "Text", + "description": "Overlay a block of text", + "category": "render", + "version": "1.0.0", + "fields": [ + { + "name": "inputImage", + "type": "image", + "value": "", + "required": false, + "label": "Image to print text on (optional)", + "showIf": [ + { + "field": ".meta.showOutput" + } + ] + }, + { + "name": "text", + "type": "text", + "value": "", + "required": false, + "label": "Text", + "placeholder": "Once upon a time..." + }, + { + "name": "richText", + "label": "Rich text mode", + "type": "select", + "options": [ + "disabled", + "basic-caret" + ], + "value": "disabled", + "required": false, + "hint": "Enable rich text editing\n\nThe \"basic-caret\" mode lets you change the size and color of each part of the text. Example syntax:\n- ^(16)font ^(32)size\n- ^(#FF00FF)color\n- ^(PTSans-Bold.ttf)font\n- ^(underline)lines ^(no-underline) not\n- ^(strikethrough)lines ^(no-strikethrough) not\n- ^(16,#FF0000) combine styles\n- Use ^(reset) to clean house" + }, + { + "name": "position", + "type": "select", + "options": [ + "left", + "center", + "right" + ], + "value": "center", + "required": true, + "label": "Align", + "placeholder": "center" + }, + { + "name": "vAlign", + "type": "select", + "options": [ + "top", + "middle", + "bottom" + ], + "value": "middle", + "required": true, + "label": "Align V", + "placeholder": "middle", + "showIf": [ + { + "field": ".meta.showNextPrev" + }, + { + "and": [ + { + "field": ".meta.showOutput" + }, + { + "field": "inputImage" + } + ] + } + ] + }, + { + "name": "offsetX", + "type": "float", + "value": "0", + "required": true, + "label": "Offset X", + "placeholder": "0", + "showIf": [ + { + "field": ".meta.showNextPrev" + }, + { + "field": "inputImage" + } + ] + }, + { + "name": "offsetY", + "type": "float", + "value": "0", + "required": true, + "label": "Offset Y", + "placeholder": "0", + "showIf": [ + { + "field": ".meta.showNextPrev" + }, + { + "field": "inputImage" + } + ] + }, + { + "name": "padding", + "type": "float", + "value": "10", + "required": true, + "label": "Padding", + "placeholder": "10" + }, + { + "name": "font", + "type": "font", + "required": false, + "label": "Font" + }, + { + "name": "fontColor", + "type": "color", + "value": "#ffffff", + "required": true, + "label": "Font Color", + "placeholder": "#ffffff" + }, + { + "name": "fontSize", + "type": "float", + "value": "32", + "required": true, + "label": "Font Size", + "placeholder": "32" + }, + { + "name": "borderColor", + "type": "color", + "value": "#000000", + "required": true, + "label": "Border Color", + "placeholder": "#000000" + }, + { + "name": "borderWidth", + "type": "integer", + "value": "2", + "required": true, + "label": "Border width", + "placeholder": "2" + }, + { + "name": "overflow", + "type": "select", + "options": [ + "fit-bounds", + "visible" + ], + "value": "fit-bounds", + "label": "Overflow" + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + }, + "render/zoomPan": { + "name": "Zoom & Pan", + "description": "Ken Burns style image motion: every render draws a slightly different crop, so consecutive renders form a smooth zoom and pan. Only useful on displays that can render many times per second (HDMI, LCD, web) - e-ink panels refresh far too slowly for this.", + "category": "render", + "version": "1.0.0", + "fields": [ + { + "name": "inputImage", + "type": "image", + "value": "", + "required": false, + "label": "Image to render on (optional)", + "showIf": [ + { + "field": ".meta.showOutput" + } + ] + }, + { + "name": "image", + "type": "image", + "required": true, + "label": "Image" + }, + { + "name": "motion", + "type": "select", + "options": [ + "zoomInOut", + "zoomIn", + "panLeftRight", + "panTopBottom", + "kenBurns" + ], + "value": "kenBurns", + "required": true, + "label": "Motion", + "hint": "kenBurns combines zooming with a focal point that drifts towards a new corner every cycle." + }, + { + "name": "zoomStart", + "type": "float", + "value": "1.0", + "required": false, + "label": "Start zoom", + "hint": "Zoom level at the start of a cycle. 1.0 shows the largest crop of the image that covers the canvas.", + "showIf": [ + { + "field": "motion", + "operator": "notIn", + "value": [ + "panLeftRight", + "panTopBottom" + ] + } + ] + }, + { + "name": "zoomEnd", + "type": "float", + "value": "1.4", + "required": false, + "label": "End zoom", + "hint": "Zoom level at the end of a cycle. Pan motions keep this zoom for the whole cycle.", + "placeholder": "1.3" + }, + { + "name": "durationSeconds", + "type": "float", + "value": "60", + "required": false, + "label": "Cycle duration (seconds)", + "hint": "Time for one full zoom cycle.", + "placeholder": "60" + }, + { + "name": "easing", + "type": "select", + "options": [ + "linear", + "easeInOut", + "sine" + ], + "value": "easeInOut", + "required": false, + "label": "Easing" + }, + { + "name": "anchor", + "type": "select", + "options": [ + "center", + "top", + "bottom", + "left", + "right", + "random" + ], + "value": "center", + "required": false, + "label": "Anchor", + "hint": "Part of the image the zoom stays locked on. Random picks a new deterministic spot every cycle.", + "showIf": [ + { + "field": "motion", + "operator": "in", + "value": [ + "zoomInOut", + "zoomIn" + ] + } + ] + }, + { + "name": "phaseStateKey", + "type": "string", + "value": "", + "required": false, + "label": "Phase state key", + "hint": "Enter a state key to persist the animation phase so scene re-activations continue smoothly." + } + ], + "output": [ + { + "name": "image", + "type": "image" + } + ] + } +} as unknown as Record + +export const embeddedBuiltinAppSources: Record> = { + "data/beRecycle": { + "app.nim": "import pixie\nimport times\nimport options\nimport json\nimport strutils\nimport chrono\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/http_client\n\nconst API_ENDPOINT = \"https://api.fostplus.be/recycle-public/app/v1\"\nconst USER_AGENT = \"Mozilla/5.0\"\nconst X_CONSUMER = \"recycleapp.be\"\nconst X_SECRET = \"Op2tDi2pBmh1wzeC5TaN2U3knZan7ATcfOQgxh4vqC0mDKmnPP2qzoQusmInpglfIkxx8SZrasBqi5zgMSvyHggK9j6xCQNQ8xwPFY2o03GCcQfcXVOyKsvGWLze7iwcfcgk2Ujpl0dmrt3hSJMCDqzAlvTrsvAEiaSzC9hKRwhijQAFHuFIhJssnHtDSB76vnFQeTCCvwVB27DjSVpDmq8fWQKEmjEncdLqIsRnfxLcOjGIVwX5V0LBntVbeiBvcjyKF2nQ08rIxqHHGXNJ6SbnAmTgsPTg7k6Ejqa7dVfTmGtEPdftezDbuEc8DdK66KDecqnxwOOPSJIN0zaJ6k2Ye2tgMSxxf16gxAmaOUqHS0i7dtG5PgPSINti3qlDdw6DTKEPni7X0rxM\"\n\ntype\n BeRecycleAuthenticateHook* = proc(self: App)\n BeRecycleFetchCollectionsHook* = proc(self: App, fromDate: string, toDate: string): JsonNode\n\n AppConfig* = object\n exportFrom*: string\n exportUntil*: string\n exportCount*: int\n language*: string\n streetName*: string\n number*: int\n postalCode*: int\n xSecret*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n headers: seq[SimpleHttpHeader]\n expiresAt: string\n\n AddressIds = object\n zip: string\n street: string\n housenumber: int\n\nvar\n beRecycleAuthenticateHook*: BeRecycleAuthenticateHook = nil\n beRecycleFetchCollectionsHook*: BeRecycleFetchCollectionsHook = nil\n\nproc fetchBody(self: App, url: string, httpMethod = \"GET\", body = \"\"): string =\n let response = boundedRequestWithHeaders(url,\n httpMethod = httpMethod,\n body = body,\n headers = self.headers,\n maxBytes = self.maxHttpResponseBytes())\n if response.code >= 400:\n raise newException(IOError, \"HTTP \" & response.status & \": \" & response.body)\n response.body\n\nproc authenticate(self: App) =\n self.headers = @[\n (name: \"User-Agent\", value: USER_AGENT),\n (name: \"x-consumer\", value: X_CONSUMER),\n (name: \"x-secret\", value: if self.appConfig.xSecret != \"\": self.appConfig.xSecret else: X_SECRET),\n ]\n let url = API_ENDPOINT & \"/access-token\"\n let atResp = self.fetchBody(url)\n let atRespJson = parseJson(atResp)\n if atRespJson.hasKey(\"accessToken\"):\n self.headers.add((name: \"Authorization\", value: atRespJson[\"accessToken\"].getStr()))\n # TODO: refetch if expired\n self.expiresAt = atRespJson[\"expiresAt\"].getStr()\n else:\n raise newException(ValueError, \"Error occurred while requesting access-token.\")\n\nproc fetchAddressIds(self: App): AddressIds =\n let url = API_ENDPOINT & \"/zipcodes?q=\" & $self.appConfig.postalCode\n let zipResp = self.fetchBody(url)\n let zipJson = parseJson(zipResp)\n var zipId = \"\"\n\n for item in zipJson[\"items\"].items:\n if item[\"code\"].getStr.parseInt == self.appConfig.postalCode:\n zipId = item[\"id\"].getStr\n break\n\n if zipId == \"\":\n raise newException(ValueError, \"Could not find the right zip code.\")\n\n let streetUrl = API_ENDPOINT & \"/streets?q=\" & self.appConfig.streetName & \"&zipcodes=\" & zipId\n let streetResp = self.fetchBody(streetUrl, httpMethod = \"POST\")\n let streetJson = parseJson(streetResp)\n var streetId = \"\"\n\n for item in streetJson[\"items\"].items:\n if self.appConfig.streetName == item{\"name\"}.getStr:\n streetId = item[\"id\"].getStr\n break\n\n if streetId == \"\":\n raise newException(ValueError, \"Could not find the right street name.\")\n result = AddressIds(zip: zipId, street: streetId, housenumber: self.appConfig.number)\n\nproc fetchCollections(self: App, addressIds: AddressIds, fromDate: string, toDate: string): JsonNode =\n let url = API_ENDPOINT & \"/collections?zipcodeId=\" & addressIds.zip & \"&streetId=\" & addressIds.street &\n \"&houseNumber=\" & $addressIds.housenumber & \"&fromDate=\" & fromDate & \"&untilDate=\" & toDate & \"&size=200\"\n let collectionResp = self.fetchBody(url)\n let collections = parseJson(collectionResp)\n\n if collections.hasKey(\"items\"):\n return collections\n else:\n raise newException(ValueError, \"Something went wrong while fetching collections.\")\n\nproc collectionsToEvents*(self: App, collections: JsonNode): seq[JsonNode] =\n let timezone = if self.frameConfig.timeZone != \"\": self.frameConfig.timeZone else: \"UTC\"\n var events: seq[JsonNode] = @[]\n for item in collections[\"items\"].items:\n let date = item[\"timestamp\"].getStr.split(\"T\")[0]\n let event = %*{\n \"summary\": \"Trash: \" & item{\"fraction\"}{\"name\"}{self.appConfig.language}.getStr,\n \"startTime\": date & \"T08:00:00\",\n \"endTime\": date & \"T08:15:00\",\n \"timezone\": timezone,\n }\n events.add(event)\n return events\n\nproc get*(self: App, context: ExecutionContext): JsonNode =\n result = %*[]\n let timezone = if self.frameConfig.timeZone != \"\": self.frameConfig.timeZone else: \"UTC\"\n let startTs = if self.appConfig.exportFrom == \"\": epochTime().Timestamp\n else: parseTs(\"{year/4}-{month/2}-{day/2}\", self.appConfig.exportFrom, timezone)\n let endTs = if self.appConfig.exportUntil == \"\": (epochTime() + 366 * 24 * 60 * 60).Timestamp\n else: parseTs(\"{year/4}-{month/2}-{day/2}\", self.appConfig.exportUntil, timezone)\n let startDay = startTs.format(\"{year/4}-{month/2}-{day/2}\", timezone)\n let endDay = endTs.format(\"{year/4}-{month/2}-{day/2}\", timezone)\n\n self.log(\"Authenticating...\")\n if beRecycleAuthenticateHook == nil:\n self.authenticate()\n else:\n beRecycleAuthenticateHook(self)\n\n var collections: JsonNode\n self.log(\"Fetching collections...\")\n if beRecycleFetchCollectionsHook == nil:\n self.log(\"Fetching address IDs...\")\n let addressIds = self.fetchAddressIds()\n collections = self.fetchCollections(addressIds, startDay, endDay)\n else:\n collections = beRecycleFetchCollectionsHook(self, startDay, endDay)\n\n self.log(%*{\"event\": \"reply\", \"eventsInRange\": len(collections)})\n self.log(\"Converting collections to events...\")\n let events = self.collectionsToEvents(collections)\n return %*(events)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n streetName: params{\"streetName\"}.getStr(\"\"),\n number: block:\n var v = 0\n if params.hasKey(\"number\"):\n let n = params{\"number\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n postalCode: block:\n var v = 0\n if params.hasKey(\"postalCode\"):\n let n = params{\"postalCode\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n exportFrom: params{\"exportFrom\"}.getStr(\"\"),\n exportUntil: params{\"exportUntil\"}.getStr(\"\"),\n exportCount: block:\n var v = 50\n if params.hasKey(\"exportCount\"):\n let n = params{\"exportCount\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n language: params{\"language\"}.getStr(\"en\"),\n xSecret: params{\"xSecret\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"streetName\":\n app.appConfig.streetName = value.asString()\n of \"number\":\n app.appConfig.number = value.asInt().int\n of \"postalCode\":\n app.appConfig.postalCode = value.asInt().int\n of \"exportFrom\":\n app.appConfig.exportFrom = value.asString()\n of \"exportUntil\":\n app.appConfig.exportUntil = value.asString()\n of \"exportCount\":\n app.appConfig.exportCount = value.asInt().int\n of \"language\":\n app.appConfig.language = value.asString()\n of \"xSecret\":\n app.appConfig.xSecret = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Recycling Calendar for Belgium\",\n \"description\": \"Return a JSON event list of trash pickup dates\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"label\": \"Street name\",\n \"name\": \"streetName\",\n \"type\": \"string\",\n \"value\": \"\",\n \"placeholder\": \"\",\n \"required\": true\n },\n {\n \"label\": \"Number\",\n \"name\": \"number\",\n \"type\": \"integer\",\n \"value\": \"\",\n \"placeholder\": \"\",\n \"required\": true\n },\n {\n \"label\": \"Postal code\",\n \"name\": \"postalCode\",\n \"type\": \"integer\",\n \"value\": \"\",\n \"placeholder\": \"\",\n \"required\": true\n },\n {\n \"label\": \"Export events from (YYYY-MM-DD)\",\n \"name\": \"exportFrom\",\n \"type\": \"string\",\n \"value\": \"\",\n \"placeholder\": \"now\",\n \"required\": false\n },\n {\n \"label\": \"Export events until (YYYY-MM-DD)\",\n \"name\": \"exportUntil\",\n \"type\": \"string\",\n \"value\": \"\",\n \"placeholder\": \"1 year later\",\n \"required\": false\n },\n {\n \"label\": \"Maximum number of events to export\",\n \"name\": \"exportCount\",\n \"type\": \"integer\",\n \"value\": \"50\",\n \"placeholder\": \"50\",\n \"required\": false\n },\n {\n \"label\": \"Language\",\n \"name\": \"language\",\n \"type\": \"select\",\n \"options\": [\n \"en\",\n \"fr\",\n \"nl\"\n ],\n \"value\": \"en\",\n \"placeholder\": \"\",\n \"required\": true\n },\n {\n \"label\": \"x-secret value\",\n \"name\": \"xSecret\",\n \"type\": \"string\",\n \"value\": \"\",\n \"placeholder\": \"\",\n \"required\": false,\n \"hint\": \"In case the default value stops working, open recycleapp.be, and copy the value of the x-secret header sent to `/v1/access-token`.\"\n },\n {\n \"markdown\": \"[{ summary, startTime, endTime, timezone }]\"\n }\n ],\n \"output\": [\n {\n \"name\": \"events\",\n \"type\": \"json\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"14400\"\n }\n}\n" + }, + "data/chromiumScreenshot": { + "app.nim": "import pixie\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/image\n\nimport os, strformat, strutils, random, json, net, sequtils\nimport posix except Time\nimport frameos/utils/process\n\nconst APT_TIMEOUT_MS = 30 * 60 * 1000\nconst VENV_TIMEOUT_MS = 30 * 60 * 1000\nconst BROWSER_START_TIMEOUT_MS = 60 * 1000\nconst PLAYWRIGHT_COMMAND_TIMEOUT_MS = 5 * 60 * 1000\n\nconst DEFAULT_PLAYWRIGHT_SCRIPT_START = \"\"\"\nimport time\nfrom playwright.sync_api import sync_playwright\n\nplaywright = sync_playwright().start()\nbrowser = playwright.chromium.connect_over_cdp(\"http://127.0.0.1:BROWSER_DEBUG_PORT\")\ncontext = browser.contexts[0] if browser.contexts else browser.new_context()\npage = context.new_page()\npage.set_viewport_size({\"width\": WIDTH, \"height\": HEIGHT})\npage.goto(URL_TO_CAPTURE, timeout=NAVIGATION_TIMEOUT_MS, wait_until=\"domcontentloaded\")\n\"\"\"\n\nconst DEFAULT_PLAYWRIGHT_SCRIPT_END = \"\"\"\npage.screenshot(path=SCREENSHOT_PATH, timeout=120000)\npage.close()\nif not PERSIST_SESSION:\n context.close()\nplaywright.stop()\n\"\"\"\n\nconst CHROMIUM_DEBUG_PORT = 9222\nconst CHROMIUM_STARTUP_ATTEMPTS = 240\nconst CHROMIUM_STARTUP_SLEEP_MS = 500\nconst CHROMIUM_MIN_RAM_KB = 1024 * 1024\nconst CHROMIUM_STARTUP_SETTLE_MS = 2500\nconst PLAYWRIGHT_NAVIGATION_TIMEOUT_MS = 90000\nconst CHROMIUM_PID_FILE = \"/tmp/frameos_browser_snapshot_chromium.pid\"\nconst CHROMIUM_LOG_FILE = \"/tmp/frameos_browser_snapshot_chromium.log\"\nconst CHROMIUM_USER_DATA_DIR = \"/tmp/frameos_browser_snapshot_profile\"\nconst LOW_RAM_ERROR = \"Error: Can't take a browser snapshot.\\n\\nModern browsers need at least 1GB of RAM to run.\\n\\nThis device has just {memoryMb} MB.\\n\\nSorry. :(\"\nconst LIGHTWEIGHT_CHROMIUM_ARGS = @[\n \"--headless\",\n \"--no-sandbox\",\n \"--disable-gpu\",\n \"--disable-software-rasterizer\",\n \"--disable-extensions\",\n \"--disable-background-networking\",\n \"--disable-breakpad\",\n \"--disable-component-update\",\n \"--disable-default-apps\",\n \"--disable-dev-shm-usage\",\n \"--disable-features=Translate,BackForwardCache,AutofillServerCommunication,OptimizationHints,MediaRouter,SubresourceFilter,PaintHolding\",\n \"--disable-sync\",\n \"--disk-cache-size=1\",\n \"--media-cache-size=1\",\n \"--metrics-recording-only\",\n \"--mute-audio\",\n \"--no-first-run\",\n \"--no-zygote\",\n \"--password-store=basic\",\n \"--renderer-process-limit=1\",\n \"--remote-debugging-address=127.0.0.1\",\n \"--remote-debugging-port=\" & $CHROMIUM_DEBUG_PORT,\n \"--user-data-dir=\" & CHROMIUM_USER_DATA_DIR,\n \"about:blank\"\n]\n\ntype\n AppConfig* = object\n url*: string\n width*: int\n height*: int\n disableLowMemoryCheck*: bool\n persistSession*: bool\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n hasEnoughRam: bool\n memoryKb: int\n\n ChromiumRamProbeHook* = proc(): int\n ChromiumEnsureSystemDependenciesHook* = proc(self: App)\n ChromiumEnsureVenvExistsHook* = proc(self: App): string\n ChromiumEnsureBackgroundBrowserHook* = proc(self: App, width: int, height: int): bool\n\nvar\n chromiumRamProbeHook*: ChromiumRamProbeHook = nil\n chromiumEnsureSystemDependenciesHook*: ChromiumEnsureSystemDependenciesHook = nil\n chromiumEnsureVenvExistsHook*: ChromiumEnsureVenvExistsHook = nil\n chromiumEnsureBackgroundBrowserHook*: ChromiumEnsureBackgroundBrowserHook = nil\n\nproc ensureVenvExists(self: App): string\nproc ensureBackgroundBrowser(self: App, width: int = 800, height: int = 600): bool\nproc stopBackgroundBrowser(self: App)\nproc shellQuote(value: string): string\nproc pickChromiumBinary(): string\nproc currentRamKb(): int\nproc hasMinimumRam(self: App): bool\n\nproc currentRamKb(): int =\n if chromiumRamProbeHook != nil:\n return chromiumRamProbeHook()\n\n try:\n for line in readFile(\"/proc/meminfo\").splitLines():\n if line.startsWith(\"MemTotal:\"):\n let parts = line.splitWhitespace()\n if parts.len >= 2:\n return parseInt(parts[1])\n except CatchableError:\n return 0\n return 0\n\nproc hasMinimumRam(self: App): bool =\n self.memoryKb = currentRamKb()\n if self.appConfig.disableLowMemoryCheck:\n self.log \"Low memory check disabled by config; skipping minimum RAM guard\"\n return true\n\n if self.memoryKb.float < CHROMIUM_MIN_RAM_KB.float * 0.95: # give small 50mb buffer\n self.logError &\"Not enough RAM for Browser Snapshot ({self.memoryKb}kB < {CHROMIUM_MIN_RAM_KB}kB)\"\n return false\n return true\n\nproc shellQuote(value: string): string =\n \"'\" & value.replace(\"'\", \"'\\\\''\") & \"'\"\n\nproc readPidFromFile(path: string): int =\n if not fileExists(path):\n return 0\n try:\n let raw = readFile(path).strip()\n if raw.len == 0:\n return 0\n return parseInt(raw)\n except CatchableError:\n return 0\n\nproc isPidAlive(pid: int): bool =\n if pid <= 0:\n return false\n posix.kill(Pid(pid), 0) == 0\n\nproc tailLog(path: string, maxLines: int = 20): string =\n if not fileExists(path):\n return \"(no chromium log file)\"\n\n try:\n let lines = readFile(path).splitLines()\n let start = max(0, lines.len - maxLines)\n result = lines[start ..< lines.len].join(\"\\n\")\n except CatchableError:\n result = \"(failed to read chromium log file)\"\n\nproc isBrowserDebugPortReady(port: int): bool =\n var socket = newSocket()\n try:\n socket.connect(\"127.0.0.1\", Port(port))\n result = true\n except CatchableError:\n result = false\n finally:\n socket.close()\n\nproc ensureSystemDependencies(self: App) =\n let hasPython = findExe(\"python3\") != \"\"\n let hasChromium = findExe(\"chromium-headless-shell\") != \"\" or\n findExe(\"chromium-browser\") != \"\" or findExe(\"chromium\") != \"\"\n\n if hasPython and hasChromium:\n return\n\n self.log \"Installing Browser Snapshot system dependencies...\"\n let updateResponse = runShellWithParentStreams(\"sudo apt-get update\",\n timeoutMs = APT_TIMEOUT_MS).exitCode\n if updateResponse != 0:\n self.logError &\"Error running apt-get update (response {updateResponse})\"\n return\n\n let pythonInstallResponse = runShellWithParentStreams(\n \"sudo apt-get install -y python3 python3-pip python3-venv\",\n timeoutMs = APT_TIMEOUT_MS).exitCode\n if pythonInstallResponse != 0:\n self.logError &\"Error installing Python dependencies (response {pythonInstallResponse})\"\n return\n\n var chromiumInstallResponse = runShellWithParentStreams(\n \"sudo apt-get install -y chromium-headless-shell\", timeoutMs = APT_TIMEOUT_MS).exitCode\n if chromiumInstallResponse != 0:\n self.log \"Package chromium-headless-shell unavailable, retrying with chromium-browser...\"\n chromiumInstallResponse = runShellWithParentStreams(\n \"sudo apt-get install -y chromium-browser\", timeoutMs = APT_TIMEOUT_MS).exitCode\n if chromiumInstallResponse != 0:\n self.log \"Package chromium-browser unavailable, retrying with chromium...\"\n chromiumInstallResponse = runShellWithParentStreams(\n \"sudo apt-get install -y chromium\", timeoutMs = APT_TIMEOUT_MS).exitCode\n\n if chromiumInstallResponse != 0:\n self.logError &\"Error installing Chromium dependencies (response {chromiumInstallResponse})\"\n\nproc init*(self: App) =\n self.log \"Initializing chromium screenshot app\"\n ## (Initialization if needed)\n self.hasEnoughRam = self.hasMinimumRam()\n if not self.hasEnoughRam:\n self.log \"Not enough RAM to run Chromium. At least 1GB is needed, got \" & $(self.memoryKb / 1024).toInt() & \"MB\"\n return\n\n if chromiumEnsureSystemDependenciesHook == nil:\n self.ensureSystemDependencies()\n else:\n chromiumEnsureSystemDependenciesHook(self)\n\n if chromiumEnsureVenvExistsHook == nil:\n discard self.ensureVenvExists()\n else:\n discard chromiumEnsureVenvExistsHook(self)\n\n if chromiumEnsureBackgroundBrowserHook == nil:\n discard self.ensureBackgroundBrowser(self.appConfig.width, self.appConfig.height)\n else:\n discard chromiumEnsureBackgroundBrowserHook(self, self.appConfig.width, self.appConfig.height)\n\n# Ensure the virtual environment exists and is set up\nproc ensureVenvExists(self: App): string =\n let venvPath = \"/srv/frameos/venvs/screenshot\"\n result = venvPath\n let venvPython = venvPath & \"/bin/python\"\n if not fileExists(venvPython):\n self.log \"Virtual environment not found. Creating venv at \" & venvPath\n try:\n discard runShellWithParentStreams(\"python3 -m venv \" & venvPath, timeoutMs = VENV_TIMEOUT_MS)\n except OSError as e:\n self.logError &\"Error creating venv: {e.msg}\"\n return\n self.log \"Installing playwright package...\"\n try:\n discard runShellWithParentStreams(venvPython & \" -m pip install playwright\", timeoutMs = VENV_TIMEOUT_MS)\n except OSError as e:\n self.logError &\"Error installing playwright: {e.msg}\"\n return\n\nproc ensureBackgroundBrowser(self: App, width: int = 800, height: int = 600): bool =\n if isBrowserDebugPortReady(CHROMIUM_DEBUG_PORT):\n return true\n\n let existingPid = readPidFromFile(CHROMIUM_PID_FILE)\n if existingPid > 0 and isPidAlive(existingPid):\n self.log &\"Chromium PID {existingPid} is already running but debug port is not ready yet\"\n elif existingPid > 0:\n self.log &\"Removing stale Chromium PID file {CHROMIUM_PID_FILE} (PID {existingPid} is not alive)\"\n try:\n removeFile(CHROMIUM_PID_FILE)\n except CatchableError:\n discard\n\n let chromiumBinary = pickChromiumBinary()\n if chromiumBinary.len == 0:\n self.logError \"Could not find chromium-headless-shell, chromium-browser, or chromium in PATH\"\n return false\n\n self.log \"Starting background Chromium process for Browser Snapshot...\"\n try:\n let chromiumArgs = LIGHTWEIGHT_CHROMIUM_ARGS & @[\"--window-size=\" & $width & \",\" & $height]\n let argString = chromiumArgs.mapIt(shellQuote(it)).join(\" \")\n let startCommand = &\"nohup {shellQuote(chromiumBinary)} {argString} >> {shellQuote(CHROMIUM_LOG_FILE)} 2>&1 & echo $! > {shellQuote(CHROMIUM_PID_FILE)}\"\n # the shell backgrounds chromium with nohup and exits right away\n let response = runShellWithParentStreams(\"bash -lc \" & shellQuote(startCommand),\n timeoutMs = BROWSER_START_TIMEOUT_MS).exitCode\n if response != 0:\n self.logError &\"Error starting background Chromium process (response {response})\"\n return false\n except CatchableError as e:\n self.logError &\"Error starting background Chromium process: {e.msg}\"\n return false\n\n for _ in 0 ..< CHROMIUM_STARTUP_ATTEMPTS:\n if isBrowserDebugPortReady(CHROMIUM_DEBUG_PORT):\n return true\n sleep(CHROMIUM_STARTUP_SLEEP_MS)\n\n let chromiumPid = readPidFromFile(CHROMIUM_PID_FILE)\n let chromiumAlive = if chromiumPid > 0: isPidAlive(chromiumPid) else: false\n self.logError &\"Chromium debug port {CHROMIUM_DEBUG_PORT} did not become ready in {CHROMIUM_STARTUP_ATTEMPTS * CHROMIUM_STARTUP_SLEEP_MS / 1000}s (pid={chromiumPid}, alive={chromiumAlive})\"\n self.logError &\"Chromium startup log tail:\\n{tailLog(CHROMIUM_LOG_FILE)}\"\n return false\n\nproc stopBackgroundBrowser(self: App) =\n let chromiumPid = readPidFromFile(CHROMIUM_PID_FILE)\n if chromiumPid <= 0:\n return\n\n self.log &\"Stopping Chromium PID {chromiumPid} before restart\"\n\n discard posix.kill(Pid(chromiumPid), SIGTERM)\n for _ in 0 ..< 10:\n if not isPidAlive(chromiumPid):\n break\n sleep(200)\n\n if isPidAlive(chromiumPid):\n self.log &\"Chromium PID {chromiumPid} did not exit gracefully, forcing kill\"\n discard posix.kill(Pid(chromiumPid), SIGKILL)\n\n try:\n if fileExists(CHROMIUM_PID_FILE):\n removeFile(CHROMIUM_PID_FILE)\n except CatchableError:\n discard\n\nproc pickChromiumBinary(): string =\n let browserCandidates = [\"chromium-headless-shell\", \"chromium-browser\", \"chromium\"]\n for candidate in browserCandidates:\n let path = findExe(candidate)\n if path != \"\":\n return path\n return \"\"\n\nproc get*(self: App, context: ExecutionContext): Image =\n let width = if self.appConfig.width != 0:\n self.appConfig.width\n elif context.hasImage:\n context.image.width\n else:\n self.frameConfig.renderWidth()\n let height = if self.appConfig.height != 0:\n self.appConfig.height\n elif context.hasImage:\n context.image.height\n else:\n self.frameConfig.renderHeight()\n\n if not self.hasEnoughRam:\n return renderError(width, height, LOW_RAM_ERROR.replace(\"{memoryMb}\", $(round(self.memoryKb / 1024).int)))\n\n try:\n let screenshotFile = fmt\"/tmp/frameos_screenshot_{rand(1000000)}_{rand(1000000)}.png\"\n let scriptFile = fmt\"/tmp/frameos_playwright_script_{rand(1000000)}.py\"\n\n # Remove the temp files on every exit path, not just success: a scene\n # stuck on a failing URL re-renders for months, and /tmp is RAM-backed.\n defer:\n try: removeFile(scriptFile)\n except OSError: discard\n try: removeFile(screenshotFile)\n except OSError: discard\n\n self.log &\"Capturing URL `{self.appConfig.url}` at {width}x{height} in {screenshotFile}\"\n\n if fileExists(screenshotFile):\n try: removeFile(screenshotFile)\n except: discard\n\n # Ensure the Python venv for Playwright exists and is set up.\n let venvRoot = if chromiumEnsureVenvExistsHook == nil:\n self.ensureVenvExists()\n else:\n chromiumEnsureVenvExistsHook(self)\n let venvPython = venvRoot & \"/bin/python\"\n let browserReady = if chromiumEnsureBackgroundBrowserHook == nil:\n self.ensureBackgroundBrowser(width, height)\n else:\n chromiumEnsureBackgroundBrowserHook(self, width, height)\n if not browserReady:\n if context.hasImage:\n return context.image\n else:\n return renderError(width, height, \"Chromium browser is not available\")\n\n self.log &\"Waiting {CHROMIUM_STARTUP_SETTLE_MS}ms for Chromium to finish warming up\"\n sleep(CHROMIUM_STARTUP_SETTLE_MS)\n\n # Write the playwright script to a temporary file\n let scriptContext = if self.appConfig.persistSession:\n \"context = browser.contexts[0] if browser.contexts else browser.new_context()\"\n else:\n \"context = browser.new_context()\"\n\n let scripHead = DEFAULT_PLAYWRIGHT_SCRIPT_START.replace(\"URL_TO_CAPTURE\", $(%*(self.appConfig.url)))\n .replace(\"BROWSER_DEBUG_PORT\", $CHROMIUM_DEBUG_PORT)\n .replace(\"context = browser.contexts[0] if browser.contexts else browser.new_context()\", scriptContext)\n .replace(\"WIDTH\", $width).replace(\"HEIGHT\", $height)\n .replace(\"NAVIGATION_TIMEOUT_MS\", $PLAYWRIGHT_NAVIGATION_TIMEOUT_MS)\n # TODO: make this configurable... but also compatible with a background browser process\n let scriptBody = \"\"\"\npage.emulate_media(reduced_motion=\"reduce\")\npage.wait_for_load_state(\"domcontentloaded\")\npage.wait_for_timeout(1500)\n\"\"\"\n let scriptTail = DEFAULT_PLAYWRIGHT_SCRIPT_END.replace(\"SCREENSHOT_PATH\", $(%*(screenshotFile)))\n .replace(\"PERSIST_SESSION\", if self.appConfig.persistSession: \"True\" else: \"False\")\n\n writeFile(scriptFile, scripHead & scriptBody & \"\\n\" & scriptTail)\n\n # Run the script. Retry once if Chromium crashed and closed the target.\n var cmd = &\"{venvPython} {scriptFile}\"\n self.log \"Running command: \" & cmd\n var completed = false\n var lastError = \"\"\n\n for attempt in 0 .. 1:\n if attempt > 0:\n self.log \"Retrying Browser Snapshot with a fresh Chromium process\"\n self.stopBackgroundBrowser()\n let browserReady = if chromiumEnsureBackgroundBrowserHook == nil:\n self.ensureBackgroundBrowser(width, height)\n else:\n chromiumEnsureBackgroundBrowserHook(self, width, height)\n if not browserReady:\n return renderError(width, height, \"Chromium browser is not available\")\n sleep(CHROMIUM_STARTUP_SETTLE_MS)\n\n try:\n let (output, response) = runShellCapture(cmd, timeoutMs = PLAYWRIGHT_COMMAND_TIMEOUT_MS)\n if response == 0:\n completed = true\n break\n\n if output.contains(\"TimeoutError\"):\n self.logError &\"Playwright navigation timed out after {PLAYWRIGHT_NAVIGATION_TIMEOUT_MS}ms while loading {self.appConfig.url}. Chromium may still be warming up.\"\n self.logError &\"Playwright timeout details: {output}\"\n return renderError(width, height, \"Browser snapshot timed out while loading the page\")\n\n if output.contains(\"TargetClosedError\") and attempt == 0:\n self.logError &\"Playwright target closed unexpectedly. Will restart Chromium and retry once. Details: {output}\"\n continue\n\n lastError = output\n break\n except OSError as e:\n lastError = e.msg\n break\n\n if not completed:\n self.logError &\"Playwright command failed: {lastError}\"\n if context.hasImage:\n return context.image\n return renderError(width, height, \"Playwright command failed\")\n\n if fileExists(screenshotFile):\n # The screenshot is captured at width x height; keep those bounds so\n # large displays stay sharp while the decode remains memory-bounded.\n let screenshotImage = readImageWithDisplayBounds(\n screenshotFile,\n maxEdge = max(DisplayDecodeMaxEdge, max(width, height)),\n maxPixels = max(DisplayDecodeMaxPixels, width * height)\n )\n self.log &\"Loaded screenshot from {screenshotFile}. Size: {screenshotImage.width}x{screenshotImage.height}\"\n return screenshotImage\n else:\n self.logError \"No screenshot file was found after running playwright script.\"\n if context.hasImage:\n return context.image\n else:\n return renderError(width, height, \"Screenshot failed\")\n except:\n self.logError \"An error occurred while capturing the screenshot.\"\n if context.hasImage:\n return context.image\n else:\n return renderError(width, height, \"Error capturing screenshot\")\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n url: params{\"url\"}.getStr(\"https://frameos.net/\"),\n width: block:\n var v = 0\n if params.hasKey(\"width\"):\n let n = params{\"width\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n height: block:\n var v = 0\n if params.hasKey(\"height\"):\n let n = params{\"height\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n persistSession: block:\n var v = true\n if params.hasKey(\"persistSession\"):\n let n = params{\"persistSession\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n disableLowMemoryCheck: block:\n var v = false\n if params.hasKey(\"disableLowMemoryCheck\"):\n let n = params{\"disableLowMemoryCheck\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n app_module.init(app_module.App(result))\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"url\":\n app.appConfig.url = value.asString()\n of \"width\":\n app.appConfig.width = value.asInt().int\n of \"height\":\n app.appConfig.height = value.asInt().int\n of \"persistSession\":\n app.appConfig.persistSession = value.asBool()\n of \"disableLowMemoryCheck\":\n app.appConfig.disableLowMemoryCheck = value.asBool()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Chromium screenshot\",\n \"description\": \"Capture a snapshot of a website with playwright and headless chromium. Needs a 64-bit system and at least 1GB of RAM.\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"url\",\n \"type\": \"text\",\n \"value\": \"https://frameos.net/\",\n \"required\": true,\n \"label\": \"URL to capture\",\n \"placeholder\": \"\"\n },\n {\n \"name\": \"width\",\n \"type\": \"integer\",\n \"value\": \"\",\n \"label\": \"Width (0=full width)\",\n \"placeholder\": \"0\"\n },\n {\n \"name\": \"height\",\n \"type\": \"integer\",\n \"value\": \"\",\n \"label\": \"Height (0=full height)\",\n \"placeholder\": \"0\"\n },\n {\n \"name\": \"persistSession\",\n \"type\": \"boolean\",\n \"value\": true,\n \"label\": \"Persist browser session between renders\",\n \"hint\": \"When enabled, cookies/session storage persist between captures. Disable to use a fresh incognito-like session for each render.\"\n },\n {\n \"name\": \"disableLowMemoryCheck\",\n \"type\": \"boolean\",\n \"value\": false,\n \"label\": \"Disable low memory check\",\n \"hint\": \"Always install and run Chromium, even if we have less than 1GB of RAM. Disable this at your own risk!\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"900\"\n }\n }\n" + }, + "data/clock": { + "app.nim": "import times\nimport frameos/types\n\ntype\n AppConfig* = object\n format*: string\n formatCustom*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): string =\n result = now().format(case self.appConfig.format:\n of \"custom\": self.appConfig.formatCustom\n else: self.appConfig.format)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n format: params{\"format\"}.getStr(\"HH:mm:ss\"),\n formatCustom: params{\"formatCustom\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"format\":\n app.appConfig.format = value.asString()\n of \"formatCustom\":\n app.appConfig.formatCustom = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Clock\",\n \"description\": \"Return the current time\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"markdown\": \"[Date format syntax](https://nim-lang.org/2.0.2/times.html)\"\n },\n {\n \"name\": \"format\",\n \"type\": \"select\",\n \"options\": [\"yyyy-MM-dd\", \"yyyy-MM-dd HH:mm:ss\", \"HH:mm:ss:fff\", \"HH:mm:ss\", \"HH:mm\", \"custom\"],\n \"value\": \"HH:mm:ss\",\n \"required\": true,\n \"label\": \"Format\",\n \"placeholder\": \"HH:mm:ss\"\n },\n {\n \"name\": \"formatCustom\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Custom format\",\n \"placeholder\": \"\",\n \"showIf\": [\n {\n \"field\": \"format\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n }\n ],\n \"output\": [\n {\n \"name\": \"time\",\n \"type\": \"string\"\n }\n ]\n}\n" + }, + "data/downloadImage": { + "app.nim": "import json\nimport pixie\nimport options\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/app_images\nimport frameos/utils/exif\nimport frameos/utils/image\n\ntype\n AppConfig* = object\n url*: string\n metadataStateKey*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc buildMetadata*(url: string, image: Image, imageData: string): JsonNode =\n result = %*{\n \"url\": url,\n \"width\": image.width,\n \"height\": image.height\n }\n let exifMetadata = getExifMetadataFromData(imageData)\n if exifMetadata.isSome():\n result[\"exif\"] = exifMetadata.get()\n mergeParsedExif(result, imageData)\n\nproc get*(self: App, context: ExecutionContext): Image =\n try:\n let url = self.appConfig.url\n let (image, imageData) = self.downloadImageWithDataForContext(\n context,\n url,\n maxBytes = self.maxImageResponseBytes()\n )\n if self.appConfig.metadataStateKey != \"\":\n self.scene.state[self.appConfig.metadataStateKey] = buildMetadata(url, image, imageData)\n return image\n except CatchableError as e:\n let detail = if e.msg.len > 0: e.msg else: \"unknown error\"\n self.logError \"An error occurred while downloading the image: \" & detail\n return self.renderErrorForContext(context,\n \"An error occurred while downloading the image.\\n\" & detail)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n url: params{\"url\"}.getStr(\"\"),\n metadataStateKey: params{\"metadataStateKey\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"url\":\n app.appConfig.url = value.asString()\n of \"metadataStateKey\":\n app.appConfig.metadataStateKey = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Download Image\",\n \"description\": \"Download image from an URL\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"url\",\n \"type\": \"text\",\n \"value\": \"\",\n \"required\": true,\n \"label\": \"Image URL\",\n \"placeholder\": \"https://domain/image\"\n },\n {\n \"name\": \"metadataStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Optional metadata state key\",\n \"hint\": \"Enter a state key to persist metadata about the image. Stores: {url, width, height, exif: {make, model, lensModel, exposureTime, fNumber, iso, focalLength, dateTimeOriginal, gpsLatitude, ...}, exifSummary}. EXIF is parsed from JPEG downloads; exifSummary is a one-line photographer credit.\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"900\"\n }\n}\n" + }, + "data/downloadUrl": { + "app.nim": "import frameos/apps\nimport frameos/types\nimport frameos/utils/http_client\n\ntype\n AppConfig* = object\n url*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): string =\n let url = self.appConfig.url\n try:\n return boundedGetContent(url, maxBytes = self.maxHttpResponseBytes())\n except CatchableError as e:\n self.logError e.msg\n return e.msg\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n url: params{\"url\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"url\":\n app.appConfig.url = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Download URL\",\n \"description\": \"Download a URL into a string\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"url\",\n \"type\": \"text\",\n \"value\": \"\",\n \"required\": true,\n \"label\": \"URL\",\n \"placeholder\": \"https://domain/content\"\n }\n ],\n \"output\": [\n {\n \"name\": \"result\",\n \"type\": \"string\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"900\"\n }\n}\n" + }, + "data/eventsToAgenda": { + "app.nim": "import frameos/types\nimport json\nimport strformat\nimport strutils\nimport algorithm\nimport chrono\nimport times\nimport pixie\n\ntype\n AppConfig* = object\n events*: JsonNode\n baseFontSize*: float\n titleFontSize*: float\n textColor*: Color\n timeColor*: Color\n titleColor*: Color\n startWithToday*: bool\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n testOverrideToday*: string\n\nconst titleFormat = \"{weekday}, {month/n} {day}\"\n\nproc getTimezone*(self: App, json: JsonNode): string =\n result = \"UTC\"\n if json.kind == JArray:\n for result in json.items():\n if result{\"timezone\"}.getStr() != \"\":\n return result{\"timezone\"}.getStr()\n if self.frameConfig.timeZone != \"\":\n return self.frameConfig.timeZone\n\nproc formatFontSize(value: float): string =\n var formatted = value.formatFloat(ffDecimal, 6)\n while formatted.len > 0 and formatted[^1] == '0':\n formatted.setLen(formatted.len - 1)\n if formatted.len > 0 and formatted[^1] == '.':\n formatted.setLen(formatted.len - 1)\n if formatted.len == 0:\n return \"0\"\n result = formatted\n\nproc get*(self: App, context: ExecutionContext): string =\n let title = &\"^({formatFontSize(self.appConfig.titleFontSize)},{self.appConfig.titleColor.toHtmlHex()})\"\n let normal = &\"^({formatFontSize(self.appConfig.baseFontSize)},{self.appConfig.textColor.toHtmlHex()})\"\n let time = &\"^({formatFontSize(self.appConfig.baseFontSize)},{self.appConfig.timeColor.toHtmlHex()})\"\n let events = self.appConfig.events\n let timezone = self.getTimezone(events)\n let todayTs =\n if self.testOverrideToday.len > 0:\n parseTs(\"{year/4}-{month/2}-{day/2}\", self.testOverrideToday)\n else:\n epochTime().Timestamp\n\n proc h1(text: string): string = &\"{title}{text}\\n{normal}\\n\"\n proc formatDay(day: string): string = format(parseTs(\"{year/4}-{month/2}-{day/2}\", day), titleFormat)\n\n let noEvents = events == nil or events.kind != JArray or events.len == 0\n\n result = \"\"\n\n var currentDay = \"\"\n if self.appConfig.startWithToday or noEvents:\n result &= h1(format(todayTs, titleFormat, tzName = timezone))\n currentDay = format(todayTs, \"{year/4}-{month/2}-{day/2}\", tzName = timezone)\n\n if noEvents:\n result &= &\"No events found\\n\"\n return\n\n let sortedEvents = events.elems.sorted(\n proc (a, b: JsonNode): int = cmp(a[\"startTime\"].getStr(), b[\"startTime\"].getStr())\n )\n var hasAny = false\n for obj in sortedEvents:\n let summary = obj{\"summary\"}.getStr()\n let startDay = obj{\"startTime\"}.getStr()\n let endDay = obj{\"endTime\"}.getStr()\n let withTime = \"T\" in startDay\n let startDate = startDay.split(\"T\")[0]\n let endDate = endDay.split(\"T\")[0]\n var displayDay = startDate\n\n if self.appConfig.startWithToday and currentDay != \"\" and\n startDate <= currentDay and currentDay <= endDate:\n displayDay = currentDay\n\n if displayDay != currentDay: # new day, past or future\n if not hasAny and displayDay != currentDay and self.appConfig.startWithToday:\n result &= \"No events today\\n\"\n result &= \"\\n\" & h1(formatDay(displayDay))\n currentDay = displayDay\n\n hasAny = true\n\n if withTime:\n let startTime = if \"T\" in startDay: startDay.split(\"T\")[1][0 .. 4] else: \"\"\n let endTime = if \"T\" in endDay: endDay.split(\"T\")[1][0 .. 4] else: \"\"\n result &= &\"{time}{startTime} - {endTime} {normal}{summary}\\n\"\n else:\n if startDay == currentDay and endDay == currentDay:\n result &= &\"{time}All day {normal}{summary}\\n\"\n else:\n result &= &\"{time}Until {formatDay(endDay)} {normal}{summary}\\n\"\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n events: params{\"events\"},\n baseFontSize: block:\n var v = 24.0\n if params.hasKey(\"baseFontSize\"):\n let n = params{\"baseFontSize\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n titleFontSize: block:\n var v = 48.0\n if params.hasKey(\"titleFontSize\"):\n let n = params{\"titleFontSize\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n titleColor: block:\n var v: Color = parseHtmlColor(\"#FFFFFF\")\n if params.hasKey(\"titleColor\"):\n let n = params{\"titleColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n textColor: block:\n var v: Color = parseHtmlColor(\"#FFFFFF\")\n if params.hasKey(\"textColor\"):\n let n = params{\"textColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n timeColor: block:\n var v: Color = parseHtmlColor(\"#FF0000\")\n if params.hasKey(\"timeColor\"):\n let n = params{\"timeColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n startWithToday: block:\n var v = true\n if params.hasKey(\"startWithToday\"):\n let n = params{\"startWithToday\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"events\":\n app.appConfig.events = value.asJson()\n of \"baseFontSize\":\n app.appConfig.baseFontSize = value.asFloat()\n of \"titleFontSize\":\n app.appConfig.titleFontSize = value.asFloat()\n of \"titleColor\":\n app.appConfig.titleColor = value.asColor()\n of \"textColor\":\n app.appConfig.textColor = value.asColor()\n of \"timeColor\":\n app.appConfig.timeColor = value.asColor()\n of \"startWithToday\":\n app.appConfig.startWithToday = value.asBool()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Events to Agenda\",\n \"description\": \"Convert events to an basic-caret formatted agenda string\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"events\",\n \"type\": \"json\",\n \"required\": true,\n \"label\": \"Events\"\n },\n {\n \"name\": \"baseFontSize\",\n \"type\": \"float\",\n \"value\": \"24\",\n \"required\": true,\n \"label\": \"Base font size\"\n },\n {\n \"name\": \"titleFontSize\",\n \"type\": \"float\",\n \"value\": \"48\",\n \"required\": true,\n \"label\": \"Title font size\"\n },\n {\n \"name\": \"titleColor\",\n \"type\": \"color\",\n \"value\": \"#FFFFFF\",\n \"required\": true,\n \"label\": \"Day title color\"\n },\n {\n \"name\": \"textColor\",\n \"type\": \"color\",\n \"value\": \"#FFFFFF\",\n \"required\": true,\n \"label\": \"Base text color\"\n },\n {\n \"name\": \"timeColor\",\n \"type\": \"color\",\n \"value\": \"#FF0000\",\n \"required\": true,\n \"label\": \"Time color\"\n },\n {\n \"name\": \"startWithToday\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": true,\n \"label\": \"Always start with today's date\"\n }\n ],\n \"output\": [\n {\n \"name\": \"result\",\n \"type\": \"string\"\n }\n ]\n}\n" + }, + "data/frameOSGallery": { + "app.nim": "import pixie, strformat, json\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/app_images\nimport frameos/utils/image\n\nconst BASE_URL = \"https://gallery.frameos.net/image\"\n\ntype GalleryDownloadHook* = proc(url: string, maxBytes: int, target: Image, fit: ScaledDecodeFit): Image\n\nproc defaultGalleryDownload(url: string, maxBytes: int, target: Image, fit: ScaledDecodeFit): Image =\n downloadImageForTarget(url, maxBytes, target, fit = fit)\n\nvar galleryDownloadHook*: GalleryDownloadHook = defaultGalleryDownload\n\ntype\n AppConfig* = object\n category*: string\n categoryOther*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc resolvedCategory*(appConfig: AppConfig): string =\n if appConfig.category == \"other\":\n appConfig.categoryOther\n else:\n appConfig.category\n\nproc galleryUrl*(category: string): string =\n &\"{BASE_URL}?category={category}\"\n\nproc get*(self: App, context: ExecutionContext): Image =\n let category = self.appConfig.resolvedCategory()\n self.log(%*{\"category\": category})\n let url = galleryUrl(category)\n let target = context.contextImage()\n try:\n result = galleryDownloadHook(url, self.maxImageResponseBytes(), target,\n scaledDecodeFitForFrame(self.frameConfig))\n except CatchableError as e:\n let detail = if e.msg.len > 0: e.msg else: \"unknown error\"\n self.logError \"An error occurred while downloading the gallery image: \" & detail\n result = self.renderErrorForContext(context,\n \"An error occurred while downloading the gallery image.\\n\" & detail)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n category: params{\"category\"}.getStr(\"cute\"),\n categoryOther: params{\"categoryOther\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"category\":\n app.appConfig.category = value.asString()\n of \"categoryOther\":\n app.appConfig.categoryOther = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"FrameOS Gallery\",\n \"description\": \"Random image from the FrameOS gallery\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"settings\": [\"frameOS\"],\n \"fields\": [\n {\n \"markdown\": \"[Click here](https://gallery.frameos.net/) to see all the galleries.\"\n },\n {\n \"name\": \"category\",\n \"type\": \"select\",\n \"options\": [\n \"building-art-styles\",\n \"cute\",\n \"cyberpunk-europe\",\n \"masterpieces\",\n \"space-gallery\",\n \"space-odyssey\",\n \"other\"\n ],\n \"value\": \"cute\",\n \"required\": false,\n \"label\": \"Category\"\n },\n {\n \"name\": \"categoryOther\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Category (if other)\",\n \"placeholder\": \"\",\n \"showIf\": [\n {\n \"field\": \"category\",\n \"operator\": \"eq\",\n \"value\": \"other\"\n }\n ]\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"900\"\n }\n}\n" + }, + "data/googlePhotos": { + "app.nim": "import pixie\nimport json\nimport random\nimport sets\nimport strformat\nimport strutils\nimport times\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/app_images\nimport frameos/utils/http_client\nimport frameos/hal/entropy\n\nconst\n photoUrlHost* = \"https://lh3.googleusercontent.com/\"\n minPhotoPathLength = 60\n albumUserAgent = \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36\"\n photoPathChars = {'a'..'z', 'A'..'Z', '0'..'9', '-', '_', '/', '=', '.', '\\\\'}\n\ntype\n AppConfig* = object\n shareUrl*: string\n mode*: string\n counterStateKey*: string\n fitMode*: string\n metadataStateKey*: string\n saveAssets*: string\n cacheAlbumSeconds*: int\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n photoUrls*: seq[string]\n albumFetchedAt*: float\n lastShareUrl*: string\n counter*: int\n\nproc extractPhotoUrls*(html: string): seq[string] =\n ## Pull googleusercontent photo base URLs out of a shared album page.\n ## URLs appear both raw and JSON-escaped (= for '='); size suffixes\n ## after '=' are stripped so callers can append their own.\n var seen = initHashSet[string]()\n var searchFrom = 0\n while true:\n let start = html.find(photoUrlHost, searchFrom)\n if start < 0:\n break\n var idx = start + photoUrlHost.len\n while idx < html.len and html[idx] in photoPathChars:\n inc idx\n searchFrom = idx\n var path = html[start + photoUrlHost.len ..< idx]\n while path.endsWith(\"\\\\\"):\n path.setLen(path.len - 1)\n path = path.replace(\"\\\\u003d\", \"=\").replace(\"\\\\u0026\", \"&\").replace(\"\\\\/\", \"/\")\n let suffixStart = path.find('=')\n if suffixStart >= 0:\n path.setLen(suffixStart)\n if path.len <= minPhotoPathLength:\n continue\n if not (path.startsWith(\"pw/\") or path.contains(\"AF1Qip\")):\n continue\n let url = photoUrlHost & path\n if url notin seen:\n seen.incl(url)\n result.add(url)\n\nproc sizedPhotoUrl*(baseUrl: string, width, height: int, fitMode: string): string =\n let suffix = if fitMode == \"contain\": \"-no\" else: \"-c\"\n baseUrl & \"=w\" & $width & \"-h\" & $height & suffix\n\nproc wrapIndex*(counter, count: int): int =\n if count <= 0:\n return 0\n ((counter mod count) + count) mod count\n\nproc init*(self: App) =\n self.appConfig.shareUrl = self.appConfig.shareUrl.strip()\n self.appConfig.counterStateKey = self.appConfig.counterStateKey.strip()\n self.appConfig.metadataStateKey = self.appConfig.metadataStateKey.strip()\n if self.appConfig.cacheAlbumSeconds <= 0:\n self.appConfig.cacheAlbumSeconds = 3600\n if self.appConfig.mode == \"random\":\n randomizeSafe()\n elif self.appConfig.counterStateKey != \"\":\n self.counter = self.scene.state{self.appConfig.counterStateKey}.getInt()\n\nproc error*(self: App, context: ExecutionContext, message: string): Image =\n self.logError(message)\n result = self.renderErrorForContext(context, message)\n\nproc albumStale(self: App): bool =\n self.photoUrls.len == 0 or\n self.appConfig.shareUrl != self.lastShareUrl or\n epochTime() - self.albumFetchedAt >= self.appConfig.cacheAlbumSeconds.float\n\nproc refreshAlbum(self: App) =\n if self.frameConfig.debug:\n self.log(&\"Fetching shared album: {self.appConfig.shareUrl}\")\n let response = boundedRequestWithHeaders(\n self.appConfig.shareUrl,\n headers = @[\n (name: \"User-Agent\", value: albumUserAgent),\n (name: \"Accept\", value: \"text/html\"),\n ],\n timeoutMs = 60000,\n maxBytes = self.maxHttpResponseBytes(),\n maxSeconds = 60\n )\n if response.code != 200:\n raise newException(IOError, \"Error \" & $response.status & \" while fetching the shared album page.\")\n let photoUrls = extractPhotoUrls(response.body)\n if photoUrls.len == 0:\n raise newException(IOError,\n \"No photos found on the shared album page. Make sure the link is a public share link and the album is not empty.\")\n self.photoUrls = photoUrls\n self.albumFetchedAt = epochTime()\n self.lastShareUrl = self.appConfig.shareUrl\n if self.frameConfig.debug:\n self.log(&\"Found {photoUrls.len} photos in the shared album\")\n\nproc get*(self: App, context: ExecutionContext): Image =\n let width = self.contextImageWidth(context)\n let height = self.contextImageHeight(context)\n let shareUrl = self.appConfig.shareUrl\n if shareUrl == \"\":\n return self.error(context, \"Please provide a Google Photos shared album link.\")\n if not shareUrl.startsWith(\"http://\") and not shareUrl.startsWith(\"https://\"):\n return self.error(context, \"Invalid shared album link: \" & shareUrl)\n\n try:\n if self.albumStale():\n try:\n self.refreshAlbum()\n except CatchableError as e:\n if self.photoUrls.len > 0 and shareUrl == self.lastShareUrl:\n self.logError(\"Error refreshing the shared album, reusing the cached photo list: \" & e.msg)\n else:\n return self.error(context, \"Error loading the shared album: \" & e.msg)\n\n let count = self.photoUrls.len\n var index = 0\n if self.appConfig.mode == \"sequential\":\n index = wrapIndex(self.counter, count)\n self.counter = wrapIndex(index + 1, count)\n if self.appConfig.counterStateKey != \"\":\n self.scene.state[self.appConfig.counterStateKey] = %*(self.counter)\n else:\n index = rand(count - 1)\n\n let imageUrl = sizedPhotoUrl(self.photoUrls[index], width, height, self.appConfig.fitMode)\n if self.frameConfig.debug:\n self.log(&\"Downloading image: {imageUrl}\")\n let (downloadedImage, imageData) = self.downloadImageWithDataForContext(\n context,\n imageUrl,\n maxBytes = self.maxImageResponseBytes(),\n fallbackWidth = width,\n fallbackHeight = height\n )\n\n if self.appConfig.metadataStateKey != \"\":\n self.scene.state[self.appConfig.metadataStateKey] = %*{\n \"source\": \"googlePhotos\",\n \"index\": index,\n \"count\": count,\n \"imageUrl\": imageUrl\n }\n\n if imageData.len > 0 and (self.appConfig.saveAssets == \"auto\" or self.appConfig.saveAssets == \"always\"):\n discard self.saveAsset(&\"google-photos {index} {width}x{height}\", \".jpg\", imageData,\n self.appConfig.saveAssets == \"auto\")\n\n result = downloadedImage\n except CatchableError as e:\n return self.error(context, \"Error fetching image from Google Photos: \" & $e.msg)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n shareUrl: params{\"shareUrl\"}.getStr(\"\"),\n mode: params{\"mode\"}.getStr(\"random\"),\n counterStateKey: params{\"counterStateKey\"}.getStr(\"\"),\n fitMode: params{\"fitMode\"}.getStr(\"cover\"),\n metadataStateKey: params{\"metadataStateKey\"}.getStr(\"\"),\n saveAssets: params{\"saveAssets\"}.getStr(\"auto\"),\n cacheAlbumSeconds: block:\n var v = 3600\n if params.hasKey(\"cacheAlbumSeconds\"):\n let n = params{\"cacheAlbumSeconds\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n app_module.init(app_module.App(result))\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"shareUrl\":\n app.appConfig.shareUrl = value.asString()\n of \"mode\":\n app.appConfig.mode = value.asString()\n of \"counterStateKey\":\n app.appConfig.counterStateKey = value.asString()\n of \"fitMode\":\n app.appConfig.fitMode = value.asString()\n of \"metadataStateKey\":\n app.appConfig.metadataStateKey = value.asString()\n of \"saveAssets\":\n app.appConfig.saveAssets = value.asString()\n of \"cacheAlbumSeconds\":\n app.appConfig.cacheAlbumSeconds = value.asInt().int\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Google Photos\",\n \"description\": \"Show images from a shared Google Photos album. Reads the public share page directly since Google offers no API for shared albums - this may stop working if Google changes the page format.\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"shareUrl\",\n \"type\": \"text\",\n \"value\": \"\",\n \"required\": true,\n \"label\": \"Shared album link\",\n \"placeholder\": \"https://photos.app.goo.gl/...\",\n \"hint\": \"In Google Photos, open an album, tap Share and create a link that anyone can view. Paste the resulting https://photos.app.goo.gl/... or https://photos.google.com/share/... link here. The album must stay shared via link for the frame to read it.\"\n },\n {\n \"name\": \"mode\",\n \"type\": \"select\",\n \"options\": [\n \"random\",\n \"sequential\"\n ],\n \"value\": \"random\",\n \"required\": true,\n \"label\": \"Order of images\"\n },\n {\n \"name\": \"counterStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Optional state key for persistence\",\n \"hint\": \"Enter a state key to persist the current image index between restarts.\",\n \"showIf\": [\n {\n \"field\": \"mode\",\n \"operator\": \"eq\",\n \"value\": \"sequential\"\n }\n ]\n },\n {\n \"name\": \"fitMode\",\n \"type\": \"select\",\n \"options\": [\n \"cover\",\n \"contain\"\n ],\n \"value\": \"cover\",\n \"required\": true,\n \"label\": \"Fit mode\",\n \"hint\": \"How Google Photos sizes the downloaded image. 'cover' crops the photo to exactly fill the frame, 'contain' keeps the whole photo visible within the frame's dimensions.\"\n },\n {\n \"name\": \"metadataStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Metadata state key\",\n \"hint\": \"Enter a state key to persist metadata about the image. Stores: {source, index, count, imageUrl}.\"\n },\n {\n \"name\": \"saveAssets\",\n \"type\": \"select\",\n \"value\": \"auto\",\n \"options\": [\n \"auto\",\n \"always\",\n \"never\"\n ],\n \"label\": \"Save asset\",\n \"hint\": \"Save the downloaded image to disk as an asset. It'll be placed into the frame's assets folder.\\n\\nYou can later use the 'Local image' app to view saved assets.\\n\\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved.\"\n },\n {\n \"name\": \"cacheAlbumSeconds\",\n \"type\": \"integer\",\n \"value\": \"3600\",\n \"required\": false,\n \"label\": \"Album cache duration (seconds)\",\n \"placeholder\": \"3600\",\n \"hint\": \"How long to reuse the scraped list of photos before fetching the album page again. New photos added to the album show up after this many seconds at the latest.\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"900\"\n }\n}\n" + }, + "data/haSensor": { + "app.nim": "import json, strformat, options, strutils, times\nimport frameos/apps\nimport frameos/types\nimport frameos/runtime_diagnostics\nimport frameos/utils/http_client\n\nconst\n RequestTimeoutMs = 10000\n MaxResponseSeconds = 15.0\n MinimumFetchIntervalSeconds = 1.0\n\ntype\n AppConfig* = object\n entityId*: string\n debug*: bool\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n lastFetchAt*: float\n json*: Option[JsonNode]\n\nproc error*(self: App, message: string): JsonNode =\n self.logError(message)\n return %*{\"error\": message}\n\nproc get*(self: App, context: ExecutionContext): JsonNode =\n let sceneId = if self.scene.isNil: \"\" else: self.scene.id.string\n let contextEvent = if context.isNil: \"\" else: context.event\n let diagnosticsEnabled = self.frameConfig.debug\n if diagnosticsEnabled:\n markRuntimeCheckpoint(\"app:get\", currentSceneId = sceneId, contextEvent = contextEvent,\n nodeId = self.nodeId.int, nodeType = \"app\", keyword = self.nodeName)\n defer:\n if diagnosticsEnabled:\n markRuntimeCheckpoint(\"app:get:done\", currentSceneId = sceneId, contextEvent = contextEvent,\n nodeId = self.nodeId.int, nodeType = \"app\", keyword = self.nodeName)\n\n let haUrl = self.frameConfig.settings{\"homeAssistant\"}{\"url\"}.getStr\n if haUrl == \"\":\n return self.error(\"Please provide a Home Assistant URL in the settings.\")\n let accessToken = self.frameConfig.settings{\"homeAssistant\"}{\"accessToken\"}.getStr\n if accessToken == \"\":\n return self.error(\"Please provide a Home Assistant access token in the settings.\")\n\n if self.json.isSome and self.lastFetchAt + MinimumFetchIntervalSeconds > epochTime():\n return copy(self.json.get())\n\n let headers: seq[SimpleHttpHeader] = @[\n (name: \"Authorization\", value: \"Bearer \" & accessToken),\n (name: \"Accept\", value: \"application/json\"),\n (name: \"Accept-Encoding\", value: \"identity\"),\n (name: \"Connection\", value: \"close\"),\n ]\n var slashlessUrl = haUrl\n slashlessUrl.removeSuffix(\"/\")\n let url = &\"{slashlessUrl}/api/states/{self.appConfig.entityId}\"\n if self.appConfig.debug:\n self.log(\"Fetching Home Assistant status from \" & url)\n\n try:\n let response = boundedRequestWithHeaders(url,\n headers = headers,\n timeoutMs = RequestTimeoutMs,\n maxBytes = self.maxHttpResponseBytes(),\n maxSeconds = MaxResponseSeconds)\n if response.code >= 400:\n return self.error \"Error fetching Home Assistant status: HTTP \" & response.status & \": \" & response.body\n let responseBody = response.body\n let responseJson = parseJson(responseBody)\n self.json = some(copy(responseJson))\n self.lastFetchAt = epochTime()\n if self.appConfig.debug:\n self.log($responseJson)\n return responseJson\n except CatchableError as e:\n return self.error \"Error fetching Home Assistant status: \" & $e.msg\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n entityId: params{\"entityId\"}.getStr(\"\"),\n debug: block:\n var v = false\n if params.hasKey(\"debug\"):\n let n = params{\"debug\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"entityId\":\n app.appConfig.entityId = value.asString()\n of \"debug\":\n app.appConfig.debug = value.asBool()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Home Assistant Sensor\",\n \"description\": \"Get the state of a Home Assistant entity\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"settings\": [\"homeAssistant\"],\n \"fields\": [\n {\n \"markdown\": \"Find the [entity id here](http://homeassistant.local:8123/config/entities)\"\n },\n {\n \"name\": \"entityId\",\n \"type\": \"text\",\n \"required\": true,\n \"label\": \"Entity ID\",\n \"placeholder\": \"Home Assistant entity name. Example: sensor.home_solar_percentage or water_heater.hot_water\"\n },\n {\n \"name\": \"debug\",\n \"type\": \"boolean\",\n \"value\": \"false\",\n \"required\": false,\n \"label\": \"Debug logging\"\n }\n ],\n \"output\": [\n {\n \"name\": \"state\",\n \"type\": \"json\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"60\"\n }\n}\n" + }, + "data/icalJson": { + "app.nim": "import pixie\nimport times\nimport options\nimport json\nimport strutils\nimport frameos/apps\nimport frameos/types\nimport chrono\nimport frameos/utils/period\n\nimport ./ical\n\ntype\n AppConfig* = object\n ical*: string\n exportFrom*: string\n exportUntil*: string\n exportCount*: int\n search*: string\n addLocation*: bool\n addUrl*: bool\n addDescription*: bool\n addTimezone*: bool\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App; context: ExecutionContext): JsonNode =\n result = %*[]\n if self.appConfig.iCal.startsWith(\"http\"):\n self.logError \"Pass in iCal data as a string, not a URL.\"\n return\n if self.appConfig.iCal == \"\":\n self.logError \"No iCal data provided.\"\n return\n\n let timezone = if self.frameConfig.timeZone != \"\": self.frameConfig.timeZone else: \"UTC\"\n\n let startTs =\n if self.appConfig.exportFrom == \"\":\n epochTime().Timestamp\n else:\n parsePeriodBoundary(self.appConfig.exportFrom, timezone, true)\n\n let endTs =\n if self.appConfig.exportUntil == \"\":\n (epochTime() + 366.0 * 24.0 * 60.0 * 60.0).Timestamp\n else:\n parsePeriodBoundary(self.appConfig.exportUntil, timezone, false)\n\n var parsedCalendar: ParsedCalendar\n try:\n parsedCalendar = parseICalendar(self.appConfig.iCal, timezone)\n except CatchableError as e:\n self.logError \"Error parsing iCal: \" & $e.msg\n return\n\n let matchedEvents = getEvents(parsedCalendar, startTs, endTs, self.appConfig.search, self.appConfig.exportCount)\n var eventsReply: JsonNode = %[]\n for (time, event) in matchedEvents:\n let startTime = if event.fullDay: time.format(\"{year/4}-{month/2}-{day/2}\", parsedCalendar.timeZone)\n else: time.format(\"{year/4}-{month/2}-{day/2}T{hour/2}:{minute/2}:{second/2}\",\n parsedCalendar.timeZone)\n let endTimeFloat = time.float + (event.endTs.float - event.startTs.float) - (if event.fullDay: 0.001 else: 0.0) + (\n if event.fullDay and event.startTs == event.endTs: 86400.0 else: 0.0)\n let endTime = if event.fullDay: endTimeFloat.Timestamp.format(\"{year/4}-{month/2}-{day/2}\", parsedCalendar.timeZone)\n else: endTimeFloat.Timestamp.format(\"{year/4}-{month/2}-{day/2}T{hour/2}:{minute/2}:{second/2}\",\n parsedCalendar.timeZone)\n let jsonEvent = %*{\n \"summary\": event.summary,\n \"startTime\": startTime,\n \"endTime\": endTime,\n }\n if event.location != \"\" and self.appConfig.addLocation:\n jsonEvent[\"location\"] = %*event.location\n if event.url != \"\" and self.appConfig.addUrl:\n jsonEvent[\"url\"] = %*event.url\n if event.description != \"\" and self.appConfig.addDescription:\n jsonEvent[\"description\"] = %*event.description\n if self.appConfig.addTimezone:\n jsonEvent[\"timezone\"] = %*(if event.timeZone == \"\": parsedCalendar.timeZone else: event.timeZone)\n eventsReply.add(jsonEvent)\n self.log(%*{\"event\": \"reply\", \"eventsInRange\": len(eventsReply)})\n return eventsReply\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n ical: params{\"ical\"}.getStr(\"\"),\n exportFrom: params{\"exportFrom\"}.getStr(\"\"),\n exportUntil: params{\"exportUntil\"}.getStr(\"\"),\n exportCount: block:\n var v = 50\n if params.hasKey(\"exportCount\"):\n let n = params{\"exportCount\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n search: params{\"search\"}.getStr(\"\"),\n addLocation: block:\n var v = true\n if params.hasKey(\"addLocation\"):\n let n = params{\"addLocation\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n addUrl: block:\n var v = true\n if params.hasKey(\"addUrl\"):\n let n = params{\"addUrl\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n addDescription: block:\n var v = false\n if params.hasKey(\"addDescription\"):\n let n = params{\"addDescription\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n addTimezone: block:\n var v = false\n if params.hasKey(\"addTimezone\"):\n let n = params{\"addTimezone\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"ical\":\n app.appConfig.ical = value.asString()\n of \"exportFrom\":\n app.appConfig.exportFrom = value.asString()\n of \"exportUntil\":\n app.appConfig.exportUntil = value.asString()\n of \"exportCount\":\n app.appConfig.exportCount = value.asInt().int\n of \"search\":\n app.appConfig.search = value.asString()\n of \"addLocation\":\n app.appConfig.addLocation = value.asBool()\n of \"addUrl\":\n app.appConfig.addUrl = value.asBool()\n of \"addDescription\":\n app.appConfig.addDescription = value.asBool()\n of \"addTimezone\":\n app.appConfig.addTimezone = value.asBool()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"iCal to Events JSON\",\n \"description\": \"Convert an iCal file into a JSON event list\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"ical\",\n \"type\": \"string\",\n \"required\": true,\n \"label\": \"iCal file contents\"\n },\n {\n \"name\": \"exportFrom\",\n \"type\": \"string\",\n \"value\": \"\",\n \"placeholder\": \"now\",\n \"required\": false,\n \"label\": \"Export events from (YYYY-MM-DD)\"\n },\n {\n \"name\": \"exportUntil\",\n \"type\": \"string\",\n \"value\": \"\",\n \"placeholder\": \"1 year later\",\n \"required\": false,\n \"label\": \"Export events until (YYYY-MM-DD)\"\n },\n {\n \"name\": \"exportCount\",\n \"type\": \"integer\",\n \"value\": \"50\",\n \"placeholder\": \"50\",\n \"required\": false,\n \"label\": \"Maximum number of events to export\"\n },\n {\n \"name\": \"search\",\n \"type\": \"string\",\n \"value\": \"\",\n \"placeholder\": \"\",\n \"required\": false,\n \"label\": \"Filter events by keyword\"\n },\n {\n \"name\": \"addLocation\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": false,\n \"label\": \"Add 'location' to the result JSON\"\n },\n {\n \"name\": \"addUrl\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": false,\n \"label\": \"Add 'url' to the result JSON\"\n },\n {\n \"name\": \"addDescription\",\n \"type\": \"boolean\",\n \"value\": \"false\",\n \"required\": false,\n \"label\": \"Add 'description' to the result JSON\"\n },\n {\n \"name\": \"addTimezone\",\n \"type\": \"boolean\",\n \"value\": \"false\",\n \"required\": false,\n \"label\": \"Add 'timezone' to the result JSON\"\n },\n {\n \"markdown\": \"[{ summary, startTime, endTime, location, url, description, timezone }]\"\n }\n ],\n \"output\": [\n {\n \"name\": \"events\",\n \"type\": \"json\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true\n }\n}\n", + "ical.nim": "import pixie\nimport times\nimport strutils\nimport chrono\nimport options\nimport std/algorithm\nimport std/lists\nimport system\nimport tables\nimport lib/tz\n\n# Key missing event features (none used by Google/Apple calendar):\n# - HOURLY, MINUTELY, SECONDLY frequencies\n# - BYHOUR, BYMINUTE, BYSECOND\n# - BYSETPOS\n# - DURATION\n# - RDATE\n\n# Missing metadata fields:\n# - ORGANIZER, ATTENDEE, CONTACT, RELATED-TO, RESOURCES, VALARM, CLASS, CREATED,\n# - TRANSP, PRIORITY, GEO, CATEGORIES\n\ntype\n RRuleFreq* = enum\n daily, weekly, monthly, yearly\n\n RRuleDay* = enum\n none = -1\n mo = 0, tu, we, th, fr, sa, su # match order in chrono\n\n RRule* = object\n freq*: RRuleFreq\n interval*: int\n byDay*: seq[(RRuleDay, int)]\n byMonth*: seq[int]\n byMonthDay*: seq[int]\n byYearDay*: seq[int]\n byWeekNo*: seq[int]\n until*: Timestamp\n count*: int\n weekStart*: RRuleDay\n\n FieldsTable* = Table[string, DoublyLinkedList[string]]\n EventsSeq* = seq[(Timestamp, VEvent)]\n\n EventStatus* = enum stNone, stTentative, stConfirmed, stCancelled\n\n VEvent* = object\n uid*: string\n timeZone*: string\n startTs*: Timestamp\n endTs*: Timestamp\n fullDay*: bool\n rrules*: seq[RRule]\n exDates*: seq[Timestamp]\n recurrenceId*: string\n summary*: string\n description*: string\n location*: string\n url*: string\n status*: EventStatus\n sequence*: int\n dtStamp*: Timestamp\n lastModified*: Timestamp\n\n ParsedCalendar* = object\n events*: seq[VEvent]\n timeZone*: string\n currentFields*: FieldsTable\n inVAlarm*: bool\n inVEvent*: bool\n inVCalendar*: bool\n rRuleProvided*: Table[string, (Timestamp, VEvent)]\n rRuleGenerated*: Table[string, (Timestamp, VEvent)]\n result*: EventsSeq\n splitCutoff*: Table[string, Timestamp]\n masterTzByUid*: Table[string, string]\n staleSeriesCutoff*: Table[string, Timestamp]\n\nconst MAX_RESULT_COUNT = 100000\n\nlet windowsTimezoneMap = block:\n var table = initTable[string, string]()\n for pair in @[\n (\"dateline standard time\", \"Etc/GMT+12\"),\n (\"utc-11\", \"Etc/GMT+11\"),\n (\"aleutian standard time\", \"America/Adak\"),\n (\"hawaiian standard time\", \"Pacific/Honolulu\"),\n (\"marquesas standard time\", \"Pacific/Marquesas\"),\n (\"alaskan standard time\", \"America/Anchorage\"),\n (\"utc-09\", \"Etc/GMT+9\"),\n (\"pacific standard time (mexico)\", \"America/Tijuana\"),\n (\"pacific standard time\", \"America/Los_Angeles\"),\n (\"us mountain standard time\", \"America/Phoenix\"),\n (\"mountain standard time (mexico)\", \"America/Chihuahua\"),\n (\"mountain standard time\", \"America/Denver\"),\n (\"yukon standard time\", \"America/Whitehorse\"),\n (\"central america standard time\", \"America/Guatemala\"),\n (\"central standard time\", \"America/Chicago\"),\n (\"central standard time (mexico)\", \"America/Mexico_City\"),\n (\"canada central standard time\", \"America/Regina\"),\n (\"sa pacific standard time\", \"America/Bogota\"),\n (\"eastern standard time (mexico)\", \"America/Cancun\"),\n (\"eastern standard time\", \"America/New_York\"),\n (\"us eastern standard time\", \"America/Indiana/Indianapolis\"),\n (\"haiti standard time\", \"America/Port-au-Prince\"),\n (\"cuba standard time\", \"America/Havana\"),\n (\"turks and caicos standard time\", \"America/Grand_Turk\"),\n (\"venezuela standard time\", \"America/Caracas\"),\n (\"paraguay standard time\", \"America/Asuncion\"),\n (\"atlantic standard time\", \"America/Halifax\"),\n (\"central brazilian standard time\", \"America/Cuiaba\"),\n (\"sa western standard time\", \"America/La_Paz\"),\n (\"pacific sa standard time\", \"America/Santiago\"),\n (\"newfoundland standard time\", \"America/St_Johns\"),\n (\"tocantins standard time\", \"America/Araguaina\"),\n (\"e. south america standard time\", \"America/Sao_Paulo\"),\n (\"sa eastern standard time\", \"America/Cayenne\"),\n (\"argentina standard time\", \"America/Buenos_Aires\"),\n (\"greenland standard time\", \"America/Nuuk\"),\n (\"montevideo standard time\", \"America/Montevideo\"),\n (\"magallanes standard time\", \"America/Punta_Arenas\"),\n (\"saint pierre standard time\", \"America/Miquelon\"),\n (\"bahia standard time\", \"America/Bahia\"),\n (\"utc-02\", \"Etc/GMT+2\"),\n (\"azores standard time\", \"Atlantic/Azores\"),\n (\"cape verde standard time\", \"Atlantic/Cape_Verde\"),\n (\"gmt standard time\", \"Europe/London\"),\n (\"greenwich standard time\", \"Atlantic/Reykjavik\"),\n (\"morocco standard time\", \"Africa/Casablanca\"),\n (\"gmt daylight time\", \"Europe/London\"),\n (\"w. europe standard time\", \"Europe/Berlin\"),\n (\"central europe standard time\", \"Europe/Warsaw\"),\n (\"romance standard time\", \"Europe/Paris\"),\n (\"central european standard time\", \"Europe/Warsaw\"),\n (\"w. central africa standard time\", \"Africa/Lagos\"),\n (\"namibia standard time\", \"Africa/Windhoek\"),\n (\"gtb standard time\", \"Europe/Bucharest\"),\n (\"middle east standard time\", \"Asia/Beirut\"),\n (\"egypt standard time\", \"Africa/Cairo\"),\n (\"e. europe standard time\", \"Europe/Chisinau\"),\n (\"syria standard time\", \"Asia/Damascus\"),\n (\"west bank standard time\", \"Asia/Hebron\"),\n (\"south africa standard time\", \"Africa/Johannesburg\"),\n (\"fle standard time\", \"Europe/Kyiv\"),\n (\"israel standard time\", \"Asia/Jerusalem\"),\n (\"kaliningrad standard time\", \"Europe/Kaliningrad\"),\n (\"sudan standard time\", \"Africa/Khartoum\"),\n (\"libya standard time\", \"Africa/Tripoli\"),\n (\"turkey standard time\", \"Europe/Istanbul\"),\n (\"arabic standard time\", \"Asia/Baghdad\"),\n (\"arabian standard time\", \"Asia/Dubai\"),\n (\"belarus standard time\", \"Europe/Minsk\"),\n (\"russian standard time\", \"Europe/Moscow\"),\n (\"volgograd standard time\", \"Europe/Volgograd\"),\n (\"saratov standard time\", \"Europe/Saratov\"),\n (\"georgian standard time\", \"Asia/Tbilisi\"),\n (\"azerbaijan standard time\", \"Asia/Baku\"),\n (\"mauritius standard time\", \"Indian/Mauritius\"),\n (\"saratov standard time\", \"Europe/Saratov\"),\n (\"caucasus standard time\", \"Asia/Yerevan\"),\n (\"afghanistan standard time\", \"Asia/Kabul\"),\n (\"west asia standard time\", \"Asia/Tashkent\"),\n (\"ekaterinburg standard time\", \"Asia/Yekaterinburg\"),\n (\"pakistan standard time\", \"Asia/Karachi\"),\n (\"india standard time\", \"Asia/Kolkata\"),\n (\"sri lanka standard time\", \"Asia/Colombo\"),\n (\"nepal standard time\", \"Asia/Kathmandu\"),\n (\"central asia standard time\", \"Asia/Almaty\"),\n (\"bangladesh standard time\", \"Asia/Dhaka\"),\n (\"myanmar standard time\", \"Asia/Yangon\"),\n (\"se asia standard time\", \"Asia/Bangkok\"),\n (\"north asia standard time\", \"Asia/Novosibirsk\"),\n (\"china standard time\", \"Asia/Shanghai\"),\n (\"north asia east standard time\", \"Asia/Irkutsk\"),\n (\"singapore standard time\", \"Asia/Singapore\"),\n (\"taipei standard time\", \"Asia/Taipei\"),\n (\"w. australia standard time\", \"Australia/Perth\"),\n (\"korea standard time\", \"Asia/Seoul\"),\n (\"tokyo standard time\", \"Asia/Tokyo\"),\n (\"yakutsk standard time\", \"Asia/Yakutsk\"),\n (\"cen. australia standard time\", \"Australia/Adelaide\"),\n (\"aus central standard time\", \"Australia/Darwin\"),\n (\"aus eastern standard time\", \"Australia/Sydney\"),\n (\"w. pacific standard time\", \"Pacific/Port_Moresby\"),\n (\"tonga standard time\", \"Pacific/Tongatapu\"),\n (\"lord howe standard time\", \"Australia/Lord_Howe\"),\n (\"tokelau standard time\", \"Pacific/Fakaofo\"),\n (\"fiji standard time\", \"Pacific/Fiji\"),\n (\"new zealand standard time\", \"Pacific/Auckland\"),\n (\"utc+12\", \"Etc/GMT-12\"),\n (\"utc+13\", \"Etc/GMT-13\"),\n (\"samoa standard time\", \"Pacific/Apia\"),\n (\"line islands standard time\", \"Pacific/Kiritimati\"),\n (\"chatham islands standard time\", \"Pacific/Chatham\"),\n (\"argentina daylight time\", \"America/Buenos_Aires\"),\n (\"greenwich daylight time\", \"Atlantic/Reykjavik\"),\n (\"w. europe daylight time\", \"Europe/Berlin\"),\n (\"central europe daylight time\", \"Europe/Warsaw\"),\n (\"romance daylight time\", \"Europe/Paris\"),\n (\"central european daylight time\", \"Europe/Warsaw\"),\n (\"fle daylight time\", \"Europe/Kyiv\"),\n (\"turkey daylight time\", \"Europe/Istanbul\"),\n (\"russian daylight time\", \"Europe/Moscow\"),\n (\"caucasus daylight time\", \"Asia/Yerevan\"),\n (\"aus eastern daylight time\", \"Australia/Sydney\"),\n (\"cen. australia daylight time\", \"Australia/Adelaide\"),\n (\"aus central daylight time\", \"Australia/Darwin\"),\n (\"tasmania standard time\", \"Australia/Hobart\"),\n (\"e. australia standard time\", \"Australia/Brisbane\"),\n (\"central pacific standard time\", \"Pacific/Guadalcanal\"),\n (\"vladivostok standard time\", \"Asia/Vladivostok\"),\n (\"sakhalin standard time\", \"Asia/Sakhalin\"),\n (\"magadan standard time\", \"Asia/Magadan\"),\n (\"kamchatka standard time\", \"Asia/Kamchatka\"),\n (\"utc+14\", \"Etc/GMT-14\"),\n (\"iran standard time\", \"Asia/Tehran\"),\n (\"azerbaijan daylight time\", \"Asia/Baku\"),\n (\"bangladesh daylight time\", \"Asia/Dhaka\"),\n (\"central asia daylight time\", \"Asia/Almaty\"),\n (\"china daylight time\", \"Asia/Shanghai\"),\n (\"korea daylight time\", \"Asia/Seoul\"),\n (\"tokyo daylight time\", \"Asia/Tokyo\"),\n (\"newfoundland daylight time\", \"America/St_Johns\")\n ]:\n table[pair[0]] = pair[1]\n table\n\nproc normalizeTimeZone(timeZone: string): string =\n let trimmed = timeZone.strip()\n if trimmed.len == 0:\n return \"\"\n let lower = trimmed.toLowerAscii()\n if windowsTimezoneMap.hasKey(lower):\n return windowsTimezoneMap[lower]\n if trimmed.toUpperAscii() == \"UTC\" or trimmed.toUpperAscii() == \"GMT\":\n return \"Etc/UTC\"\n if trimmed.startsWith(\"UTC\") and trimmed.len > 3:\n let sign = trimmed[3]\n if sign in ['+', '-']:\n let rest = trimmed[4..^1]\n var hour = 0\n var minute = 0\n try:\n if rest.contains(\":\"):\n let parts = rest.split(\":\")\n if parts.len >= 1 and parts[0].len > 0:\n hour = parts[0].parseInt()\n if parts.len >= 2 and parts[1].len > 0:\n minute = parts[1].parseInt()\n else:\n hour = rest.parseInt()\n except ValueError:\n return trimmed\n if minute == 0:\n let offset = (if sign == '+': -hour else: hour)\n return \"Etc/GMT\" & (if offset >= 0: \"+\" & $offset else: $offset)\n return canonicalTimeZone(trimmed)\n\n####################################################################################################\n# Parsing\n\nproc `<`(a, b: VEvent): bool =\n a.startTs < b.startTs\n\nproc unescape*(line: string): string =\n result = \"\"\n var i = 0\n while i < line.len:\n if line[i] == '\\\\':\n inc i\n if i >= line.len:\n result.add('\\\\')\n break\n case line[i]\n of 'n': result.add('\\n')\n of 'N': result.add('\\n')\n of 't': result.add('\\t') # ?\n of 'T': result.add('\\t') # ?\n of 'r': result.add('\\r') # ?\n of 'R': result.add('\\r') # ?\n of ',': result.add(',')\n of ';': result.add(';')\n else: result.add(line[i])\n else:\n result.add(line[i])\n inc i\n return result\n\nproc parseICalDateTime*(dateTimeStr: string, timeZone: string): Timestamp =\n let dateTime = if dateTimeStr.contains(\";\"): dateTimeStr.split(\";\")[1]\n elif dateTimeStr.contains(\":\"): dateTimeStr.split(\":\")[1]\n else: dateTimeStr\n let format = if 'T' in dateTime:\n \"{year/4}{month/2}{day/2}T{hour/2}{minute/2}{second/2}\" & (if dateTime.endsWith(\"Z\"): \"Z\" else: \"\")\n else:\n \"{year/4}{month/2}{day/2}\"\n try:\n let ts = parseTs(format, dateTime)\n\n # Treat UTC timestamps as the real deal\n if 'T' in dateTime and dateTime.endsWith(\"Z\"):\n return ts\n\n # Otherwise the date/time was in the local zone\n var cal = ts.calendar()\n let normalizedZone = normalizeTimeZone(timeZone)\n if normalizedZone.len == 0:\n return cal.ts\n try:\n cal.shiftTimezone(normalizedZone)\n except CatchableError as e:\n raise newException(TimeParseError,\n \"Failed to shift timezone '\" & timeZone & \"': \" & e.msg)\n return cal.ts\n except ValueError as e:\n raise newException(TimeParseError, \"Failed to parse datetime string: \" & dateTimeStr & \". Error: \" & e.msg)\n\nproc parseDateString(self: var ParsedCalendar, event: var VEvent, value: string): Timestamp =\n let timeZone = normalizeTimeZone(if event.timeZone == \"\": self.timeZone else: event.timeZone)\n if value.contains(\"VALUE=DATE\"):\n let parts = value.split(\":\")\n let date = parts[len(parts) - 1]\n return parseICalDateTime(date, timeZone)\n elif len(value) == 8: # 20181231\n return parseICalDateTime(value, timeZone)\n else:\n return parseICalDateTime(value, timeZone)\n\nproc parseICalDuration*(value: string): float =\n ## Parse an RFC 5545 DURATION (e.g. \"PT1H30M\", \"P1D\", \"-PT15M\", \"P2W\") into\n ## seconds. Raises ValueError on malformed input so the caller can skip the\n ## event instead of silently using a zero-length range.\n var s = value.strip()\n if s.len == 0:\n raise newException(ValueError, \"Empty DURATION\")\n var sign = 1.0\n if s[0] == '+':\n s = s[1 .. ^1]\n elif s[0] == '-':\n sign = -1.0\n s = s[1 .. ^1]\n if s.len == 0 or s[0] != 'P':\n raise newException(ValueError, \"Invalid DURATION: \" & value)\n s = s[1 .. ^1]\n var total = 0.0\n var num = \"\"\n for ch in s:\n if ch == 'T':\n continue\n elif ch.isDigit:\n num.add(ch)\n else:\n if num.len == 0:\n raise newException(ValueError, \"Invalid DURATION: \" & value)\n let n = parseInt(num).float\n num = \"\"\n case ch\n of 'W': total += n * 604800.0\n of 'D': total += n * 86400.0\n of 'H': total += n * 3600.0\n of 'M': total += n * 60.0\n of 'S': total += n\n else: raise newException(ValueError, \"Invalid DURATION unit in: \" & value)\n if num.len != 0:\n raise newException(ValueError, \"Trailing number in DURATION: \" & value)\n return sign * total\n\nproc processCurrentFields*(self: var ParsedCalendar) =\n var event = VEvent()\n\n template getFirstValue(key: string): string =\n self.currentFields[key].head.value\n\n if self.currentFields.hasKey(\"UID\"):\n event.uid = getFirstValue(\"UID\")\n\n if self.currentFields.hasKey(\"TZID\"):\n event.timeZone = normalizeTimeZone(self.currentFields[\"TZID\"].head.value)\n else:\n event.timeZone = self.timeZone\n\n if self.currentFields.hasKey(\"DTSTART\"):\n let value = getFirstValue(\"DTSTART\")\n if value.startsWith(\"TZID=\"):\n event.timeZone = normalizeTimeZone(value.split(\":\")[0].split(\"=\")[1])\n event.startTs = self.parseDateString(event, value)\n event.fullDay = value.contains(\"VALUE=DATE\") or len(value) == 8\n\n if self.currentFields.hasKey(\"DTEND\"):\n let value = getFirstValue(\"DTEND\")\n if value.startsWith(\"TZID=\"):\n event.timeZone = normalizeTimeZone(value.split(\":\")[0].split(\"=\")[1])\n event.endTs = self.parseDateString(event, value)\n event.fullDay = value.contains(\"VALUE=DATE\") or len(value) == 8\n\n if self.currentFields.hasKey(\"DURATION\"):\n event.endTs = (event.startTs.float + parseICalDuration(getFirstValue(\"DURATION\"))).Timestamp\n\n if self.currentFields.hasKey(\"DTSTAMP\"):\n event.dtStamp = parseICalDateTime(getFirstValue(\"DTSTAMP\"), \"UTC\")\n\n if self.currentFields.hasKey(\"RECURRENCE-ID\"):\n event.recurrenceId = getFirstValue(\"RECURRENCE-ID\")\n\n if self.currentFields.hasKey(\"RRULE\"):\n var rrule = RRule(weekStart: RRuleDay.none, byDay: @[], byMonth: @[], byMonthDay: @[])\n let value = getFirstValue(\"RRULE\")\n for split in value.split(';'):\n let keyValue = split.split('=', 2)\n if keyValue.len != 2:\n continue\n case keyValue[0]:\n of \"FREQ\":\n case keyValue[1]\n of \"DAILY\":\n rrule.freq = daily\n of \"WEEKLY\":\n rrule.freq = weekly\n of \"MONTHLY\":\n rrule.freq = monthly\n of \"YEARLY\":\n rrule.freq = yearly\n else:\n raise newException(ValueError, \"Unknown RRULE freq: \" & keyValue[1])\n of \"INTERVAL\":\n rrule.interval = keyValue[1].parseInt()\n of \"COUNT\":\n rrule.count = keyValue[1].parseInt()\n of \"UNTIL\":\n rrule.until = parseICalDateTime(keyValue[1], self.timeZone)\n of \"BYSECOND\":\n raise newException(ValueError, \"BYSECOND is not supported\")\n of \"BYMINUTE\":\n raise newException(ValueError, \"BYMINUTE is not supported\")\n of \"BYHOUR\":\n raise newException(ValueError, \"BYHOUR is not supported\")\n of \"BYDAY\":\n # \"1SU\"\n for day in keyValue[1].split(','):\n if day.len < 2:\n continue\n let weekDay = day[^2..^1]\n let dayNum = if day.len > 2: day[0..(day.len - weekDay.len - 1)].parseInt() else: 0\n case weekDay.toUpper():\n of \"SU\": rrule.byDay.add((RRuleDay.su, dayNum))\n of \"MO\": rrule.byDay.add((RRuleDay.mo, dayNum))\n of \"TU\": rrule.byDay.add((RRuleDay.tu, dayNum))\n of \"WE\": rrule.byDay.add((RRuleDay.we, dayNum))\n of \"TH\": rrule.byDay.add((RRuleDay.th, dayNum))\n of \"FR\": rrule.byDay.add((RRuleDay.fr, dayNum))\n of \"SA\": rrule.byDay.add((RRuleDay.sa, dayNum))\n of \"BYMONTHDAY\":\n for monthDay in keyValue[1].split(','):\n rrule.byMonthDay.add(monthDay.parseInt())\n of \"BYYEARDAY\":\n for yearDay in keyValue[1].split(','):\n rrule.byYearDay.add(yearDay.parseInt())\n of \"BYWEEKNO\":\n for weekNo in keyValue[1].split(','):\n rrule.byWeekNo.add(weekNo.parseInt())\n of \"BYMONTH\":\n for month in keyValue[1].split(','):\n rrule.byMonth.add(month.parseInt())\n of \"BYSETPOS\":\n raise newException(ValueError, \"BYSETPOS is not supported\")\n of \"WKST\":\n case keyValue[1].toUpper()\n of \"SU\": rrule.weekStart = RRuleDay.su\n of \"MO\": rrule.weekStart = RRuleDay.mo\n of \"TU\": rrule.weekStart = RRuleDay.tu\n of \"WE\": rrule.weekStart = RRuleDay.we\n of \"TH\": rrule.weekStart = RRuleDay.th\n of \"FR\": rrule.weekStart = RRuleDay.fr\n of \"SA\": rrule.weekStart = RRuleDay.sa\n else:\n echo \"!! Unknown RRULE rule: \" & split\n\n if rrule.interval == 0:\n rrule.interval = 1\n\n # Since none of the BYDAY, BYMONTHDAY, or BYYEARDAY components are specified, the day is gotten from \"DTSTART\".\n # RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3\n if (rrule.freq == monthly or rrule.freq == yearly) and rrule.byMonth.len > 0 and rrule.byDay.len == 0 and\n rrule.byMonthDay.len == 0 and rrule.byYearDay.len == 0:\n rrule.byMonthDay.add(event.startTs.calendar(event.timeZone).day)\n\n event.rrules.add(rrule)\n # if fields.hasKey(\"RDATE\"):\n # assert(false, \"RDATE is not supported\")\n if self.currentFields.hasKey(\"EXDATE\"):\n for raw in self.currentFields[\"EXDATE\"].items():\n var tzParam = \"\"\n var datesPart = raw\n let colon = raw.find(':')\n if colon != -1:\n for p in raw[0 ..< colon].split(';'):\n if p.startsWith(\"TZID=\"):\n tzParam = normalizeTimeZone(p.split(\"=\", 1)[1])\n datesPart = raw[colon + 1 .. ^1]\n let tzToUse =\n if tzParam.len > 0: tzParam\n elif self.masterTzByUid.hasKey(event.uid): self.masterTzByUid[event.uid]\n elif event.timeZone.len > 0: event.timeZone\n else: self.timeZone\n\n for token in datesPart.split(','):\n event.exDates.add(parseICalDateTime(token, tzToUse))\n\n # Text fields\n if self.currentFields.hasKey(\"SUMMARY\"):\n event.summary = unescape(getFirstValue(\"SUMMARY\"))\n if self.currentFields.hasKey(\"DESCRIPTION\"):\n event.description = unescape(getFirstValue(\"DESCRIPTION\"))\n if self.currentFields.hasKey(\"LOCATION\"):\n event.location = unescape(getFirstValue(\"LOCATION\"))\n if self.currentFields.hasKey(\"URL\"):\n event.url = unescape(getFirstValue(\"URL\"))\n # if fields.hasKey(\"COMMENT\"):\n # return # Ignore comments\n # if fields.hasKey(\"ORGANIZER\"):\n # assert(false, \"ORGANIZER is not supported\")\n # if fields.hasKey(\"GEO\"):\n # assert(false, \"GEO is not supported\")\n # if fields.hasKey(\"CATEGORIES\"):\n # assert(false, \"CATEGORIES is not supported\")\n\n if self.currentFields.hasKey(\"STATUS\"):\n let s = getFirstValue(\"STATUS\").toUpperAscii()\n case s\n of \"CANCELLED\": event.status = stCancelled\n of \"TENTATIVE\": event.status = stTentative\n of \"CONFIRMED\": event.status = stConfirmed\n else: event.status = stNone\n\n if self.currentFields.hasKey(\"LAST-MODIFIED\"):\n event.lastModified = parseICalDateTime(getFirstValue(\"LAST-MODIFIED\"), \"UTC\")\n if self.currentFields.hasKey(\"SEQUENCE\"):\n try:\n event.sequence = getFirstValue(\"SEQUENCE\").parseInt()\n except ValueError:\n event.sequence = 0\n\n # Attendee and Alarm Properties\n # if fields.hasKey(\"ATTENDEE\"):\n # assert(false, \"ATTENDEE is not supported\")\n # if fields.hasKey(\"CONTACT\"):\n # assert(false, \"CONTACT is not supported\")\n # if fields.hasKey(\"RELATED-TO\"):\n # assert(false, \"RELATED-TO is not supported\")\n # if fields.hasKey(\"RESOURCES\"):\n # assert(false, \"RESOURCES is not supported\")\n # if fields.hasKey(\"VALARM\"):\n # assert(false, \"VALARM is not supported\")\n # if fields.hasKey(\"CLASS\"):\n # assert(false, \"CLASS is not supported\")\n # if fields.hasKey(\"CREATED\"):\n # assert(false, \"CREATED is not supported\")\n # if fields.hasKey(\"LAST-MODIFIED\"):\n # assert(false, \"LAST-MODIFIED is not supported\")\n # if fields.hasKey(\"SEQUENCE\"):\n # assert(false, \"SEQUENCE is not supported\")\n # if fields.hasKey(\"TRANSP\"):\n # assert(false, \"TRANSP is not supported\")\n # if fields.hasKey(\"PRIORITY\"):\n # assert(false, \"PRIORITY is not supported\")\n\n if event.recurrenceId.len == 0 and event.uid.len > 0:\n self.masterTzByUid[event.uid] = event.timeZone\n\n self.events.add(event)\n\nproc processLine*(self: var ParsedCalendar, line: string) =\n if line.startsWith(\"BEGIN:VEVENT\"):\n self.inVEvent = true\n self.currentFields = initTable[string, DoublyLinkedList[string]]()\n elif line.startsWith(\"END:VEVENT\"):\n self.inVEvent = false\n try:\n self.processCurrentFields()\n except CatchableError as e:\n # Skip this single malformed/unsupported event rather than failing the\n # whole calendar fetch. The event is only appended at the end of\n # processCurrentFields, so a raise leaves no partial event behind.\n echo \"Error processing event, skipping: \" & e.msg\n echo self.currentFields\n elif line.startsWith(\"BEGIN:VCALENDAR\"):\n self.inVCalendar = true\n elif line.startsWith(\"END:VCALENDAR\"):\n self.inVCalendar = false\n elif line.startsWith(\"BEGIN:VALARM\"):\n self.inVAlarm = true\n elif line.startsWith(\"END:VALARM\"):\n self.inVAlarm = false\n elif not self.inVAlarm and (self.inVEvent or self.inVCalendar):\n let splitPos = line.find(':')\n if splitPos == -1:\n return\n let arr = line.split({';', ':'}, 1)\n if arr.len > 1:\n let key = arr[0]\n let value = arr[1]\n if self.inVEvent:\n if not self.currentFields.hasKey(key):\n self.currentFields[key] = initDoublyLinkedList[string]()\n self.currentFields[key].add(value)\n else:\n if key == \"X-WR-TIMEZONE\":\n self.timeZone = normalizeTimeZone(unescape(value))\n\nproc reconcileRecurringSeries*(self: var ParsedCalendar)\n\nproc parseICalendar*(content: string, timeZone = \"\"): ParsedCalendar =\n result = ParsedCalendar(timeZone: normalizeTimeZone(timeZone))\n result.timeZone = normalizeTimeZone(timeZone) # Default. Will be overridden by X-WR-TIMEZONE if given\n result.masterTzByUid = initTable[string, string]()\n result.staleSeriesCutoff = initTable[string, Timestamp]()\n var accumulator = \"\"\n for line in content.splitLines():\n if line.len > 0 and (line[0] == ' ' or line[0] == '\\t'):\n accumulator.add(line[1..^1])\n continue\n if accumulator != \"\":\n processLine(result, accumulator.strip())\n accumulator = \"\"\n accumulator = line\n if accumulator != \"\":\n processLine(result, accumulator.strip())\n\n result.events.sort(proc (a: VEvent, b: VEvent): int = cmp(a.startTs, b.startTs))\n result.reconcileRecurringSeries()\n\n####################################################################################################\n# Querying\n\nproc fixDST(self: var Calendar, timeZone: string) =\n self.tzOffset = 0\n self.tzName = \"\"\n self.dstName = \"\"\n let normalized = normalizeTimeZone(timeZone)\n if normalized.len == 0:\n return\n self.shiftTimezone(normalized)\n\nproc eventFreshness*(event: VEvent): (int, float, float, float) =\n (\n event.sequence,\n event.lastModified.float,\n event.dtStamp.float,\n event.startTs.float\n )\n\nproc sameRRule*(a, b: RRule): bool =\n a.freq == b.freq and\n a.interval == b.interval and\n a.byDay == b.byDay and\n a.byMonth == b.byMonth and\n a.byMonthDay == b.byMonthDay and\n a.byYearDay == b.byYearDay and\n a.byWeekNo == b.byWeekNo and\n a.until == b.until and\n a.count == b.count and\n a.weekStart == b.weekStart\n\nproc sameRecurringMasterSignature*(a, b: VEvent): bool =\n if a.uid == b.uid:\n return false\n if a.recurrenceId.len > 0 or b.recurrenceId.len > 0:\n return false\n if a.rrules.len == 0 or b.rrules.len == 0:\n return false\n if a.summary != b.summary:\n return false\n if a.fullDay != b.fullDay:\n return false\n if (a.endTs.float - a.startTs.float) != (b.endTs.float - b.startTs.float):\n return false\n if a.rrules.len != b.rrules.len:\n return false\n for i in 0 ..< a.rrules.len:\n if not sameRRule(a.rrules[i], b.rrules[i]):\n return false\n return true\n\nproc choosePreferredEvent*(current, candidate: VEvent): VEvent =\n if eventFreshness(candidate) > eventFreshness(current):\n return candidate\n return current\n\nproc reconcileRecurringSeries*(self: var ParsedCalendar) =\n for i in 0 ..< self.events.len:\n let older = self.events[i]\n if older.status == stCancelled or older.rrules.len == 0 or older.recurrenceId.len > 0:\n continue\n for j in 0 ..< self.events.len:\n if i == j:\n continue\n let newer = self.events[j]\n if newer.status == stCancelled or newer.rrules.len == 0 or newer.recurrenceId.len > 0:\n continue\n if not sameRecurringMasterSignature(older, newer):\n continue\n\n if older.startTs < newer.startTs and eventFreshness(newer) >= eventFreshness(older):\n if not self.staleSeriesCutoff.hasKey(older.uid) or newer.startTs < self.staleSeriesCutoff[older.uid]:\n self.staleSeriesCutoff[older.uid] = newer.startTs\n elif older.startTs == newer.startTs and eventFreshness(newer) > eventFreshness(older):\n if not self.staleSeriesCutoff.hasKey(older.uid) or newer.startTs < self.staleSeriesCutoff[older.uid]:\n self.staleSeriesCutoff[older.uid] = newer.startTs\n\nproc reconcileExactCollisions*(self: var ParsedCalendar) =\n var winners = initTable[string, (Timestamp, VEvent)]()\n for (ts, event) in self.result:\n let duration = int64(event.endTs.float - event.startTs.float)\n let collisionKey = event.summary & \"|\" & $int64(ts.float) & \"|\" & $duration\n if winners.hasKey(collisionKey):\n let current = winners[collisionKey]\n let preferred = choosePreferredEvent(current[1], event)\n if preferred.uid == event.uid and preferred.startTs == event.startTs and preferred.endTs == event.endTs:\n winners[collisionKey] = (ts, event)\n else:\n winners[collisionKey] = (ts, event)\n\n self.result = @[]\n for (_, winner) in winners.pairs():\n self.result.add(winner)\n\nproc trimDay(self: var Calendar) =\n self.secondFraction = 0.0\n self.second = 0\n self.minute = 0\n self.hour = 0\n\nproc dayOfYear*(cal: Calendar): int =\n # TODO: upstream all of this\n proc leapYear(year: int): bool =\n if year mod 4 == 0:\n if year mod 100 == 0:\n if year mod 400 == 0:\n return true\n else:\n return false\n else:\n return true\n else:\n return false\n proc daysInMonth(m: int, year: int): int =\n if m == 1 or m == 3 or m == 5 or m == 7 or m == 8 or m == 10 or m == 12:\n return 31\n elif m == 4 or m == 6 or m == 9 or m == 11:\n return 30\n elif m == 2:\n if leapYear(year):\n return 29\n else:\n return 28\n var r = cal.day\n for i in 1 ..< cal.month:\n r += daysInMonth(i, cal.year)\n return r\n\n# Just add the interval to the event date\nproc getSimpleNextInterval*(calendar: Calendar, rrule: RRule, timeZone: string): Calendar =\n result = calendar.copy()\n case rrule.freq\n of daily:\n result.add(TimeScale.Day, rrule.interval)\n of weekly:\n result.add(TimeScale.Day, 7 * rrule.interval)\n of monthly:\n result.add(TimeScale.Month, rrule.interval)\n of yearly:\n result.add(TimeScale.Year, rrule.interval)\n # Must do this to preserve the right hour past DST changes\n result.fixDST(timeZone)\n\n# Get the end of this week, month, etc\nproc getEndOfThisInterval*(calendar: Calendar, rrule: RRule, timeZone: string): Timestamp =\n var cal = calendar.copy()\n case rrule.freq\n of daily:\n cal.day += 1\n of weekly:\n let weekStart = (if rrule.weekStart == RRuleDay.none: RRuleDay.mo else: rrule.weekStart).int\n let diff = if weekStart > cal.weekDay: weekStart - cal.weekDay\n else: 7 - cal.weekDay + weekStart\n cal.day += diff\n of monthly:\n cal.day = 1\n cal.month += 1\n of yearly:\n cal.day = 1\n cal.month = 1\n cal.year += 1\n cal.trimDay()\n cal.normalize()\n # Must do this to preserve the right hour past DST changes\n cal.fixDST(timeZone)\n cal.ts\n\nproc getNextIntervalStart*(calendar: Calendar, rrule: RRule, timeZone: string): Calendar =\n result = calendar.copy()\n # result.trimDay()\n case rrule.freq\n of daily:\n result.day += rrule.interval\n of weekly:\n let weekStart = (if rrule.weekStart == RRuleDay.none: RRuleDay.mo else: rrule.weekStart).int\n let diff = if weekStart > result.weekDay: weekStart - result.weekDay\n else: 7 - result.weekDay + weekStart\n result.day += 7 * (rrule.interval - 1) + diff\n of monthly:\n result.day = 1\n result.month += rrule.interval\n of yearly:\n result.day = 1\n result.month = 1\n result.year += rrule.interval\n # Must do this to preserve the right hour past DST changes\n result.normalize()\n result.fixDST(timeZone)\n\nproc matchesRRule*(currentCal: Calendar, rrule: RRule): bool =\n if rrule.byDay.len > 0:\n var found = false\n for (requiredWeekDay, num) in rrule.byDay:\n if num == 0:\n if requiredWeekDay.int == currentCal.weekDay:\n found = true\n break\n elif num > 0: # Every num-th Wednesday of the month\n var count = 0\n var cal = currentCal.copy()\n cal.day = 1\n if rrule.freq == yearly:\n cal.month = 1\n\n while (rrule.freq == yearly and cal.year == currentCal.year) or\n (rrule.freq == monthly and cal.month == currentCal.month):\n if cal.weekDay == requiredWeekDay.int:\n count += 1\n if count == num:\n break\n cal.add(TimeScale.Day, 1)\n if cal.month == currentCal.month and cal.day == currentCal.day:\n found = true\n break\n else: # Every last -num-th Wednesday of the month\n var count = 0\n var cal = currentCal.copy()\n if rrule.freq == yearly:\n cal.month = 12\n cal.day = 31\n else:\n cal.day = cal.daysInMonth\n while (rrule.freq == yearly and cal.year == currentCal.year) or\n (rrule.freq == monthly and cal.month == currentCal.month):\n if cal.weekDay == requiredWeekDay.int:\n count += 1\n if count == -num:\n break\n cal.add(TimeScale.Day, -1)\n if cal.month == currentCal.month and cal.day == currentCal.day:\n found = true\n break\n if not found:\n return false\n if rrule.byMonth.len > 0 and not rrule.byMonth.contains(currentCal.month):\n return false\n if rrule.byMonthDay.len > 0:\n var matchesRule = false\n for day in rrule.byMonthDay:\n if day > 0 and day == currentCal.day:\n matchesRule = true\n break\n elif day < 0 and currentCal.day == (currentCal.daysInMonth + day + 1):\n matchesRule = true\n break\n if not matchesRule:\n return false\n if rrule.byYearDay.len > 0 and not rrule.byYearDay.contains(currentCal.dayOfYear()):\n return false\n\n return true\n\nproc hasThisAndFuture*(value: string): bool =\n let i = value.find(':')\n if i == -1: return false\n for p in value[0 ..< i].split(';'):\n let up = p.toUpperAscii()\n if up.startsWith(\"RANGE=\"):\n let v = up.split(\"=\", 1)[1]\n if v == \"THISANDFUTURE\":\n return true\n return false\n\nproc tzForRid(self: ParsedCalendar, ev: VEvent, ridTzParam: string): string =\n if ridTzParam.len > 0: return normalizeTimeZone(ridTzParam)\n if self.masterTzByUid.hasKey(ev.uid): return self.masterTzByUid[ev.uid]\n if ev.timeZone.len > 0: return ev.timeZone\n return self.timeZone\n\nproc extractTzAndDate*(value: string): (string, string) =\n ## Extract timezone and the actual datetime part from a RECURRENCE-ID line\n var tz = \"\"\n var datePart = value\n let colon = value.find(':')\n if colon != -1:\n let paramsPart = value[0 ..< colon]\n datePart = value[colon + 1 .. ^1]\n for p in paramsPart.split(';'):\n if p.startsWith(\"TZID=\"):\n tz = normalizeTimeZone(p.split(\"=\", 1)[1])\n return (tz, datePart)\n\nproc keyFor(uid: string, ts: Timestamp): string =\n uid & \"/\" & $int64(ts.float) # use toUnix if you have it\n\nproc addMatchedEvent(self: var ParsedCalendar, ts: Timestamp, event: VEvent) =\n if self.result.len() > MAX_RESULT_COUNT:\n raise newException(ValueError, \"Too many events in calendar. Increase MAX_RESULT_COUNT.\")\n\n if event.recurrenceId.len == 0 and event.rrules.len > 0 and self.staleSeriesCutoff.hasKey(event.uid):\n if ts >= self.staleSeriesCutoff[event.uid]:\n return\n\n if event.recurrenceId != \"\":\n var (ridTzParam, ridDate) = extractTzAndDate(event.recurrenceId)\n let ridTz = self.tzForRid(event, ridTzParam)\n let ridTs = parseICalDateTime(ridDate, ridTz)\n\n # If the override defines a child series (has RRULE) or is marked THISANDFUTURE,\n # (a) key by *this occurrence's* ts so it replaces the parent's same slot\n # (b) remember a cutoff so the parent series stops after ridTs.\n if event.rrules.len() > 0 or hasThisAndFuture(event.recurrenceId):\n # record/keep earliest cutoff\n if not self.splitCutoff.hasKey(event.uid) or ridTs < self.splitCutoff[event.uid]:\n self.splitCutoff[event.uid] = ridTs\n let key = keyFor(event.uid, ts)\n self.rRuleProvided[key] = (ts, event)\n else:\n # Single-instance override (move/cancel this one occurrence)\n let key = keyFor(event.uid, ridTs) # key by ORIGINAL occurrence time\n self.rRuleProvided[key] = (ts, event)\n\n elif event.rrules.len() > 0:\n if event.status == stCancelled:\n return\n let key = keyFor(event.uid, ts)\n self.rRuleGenerated[key] = (ts, event)\n\n else:\n if event.status != stCancelled:\n self.result.add((ts, event))\n\nproc applyRRule(self: var ParsedCalendar, startTs: Timestamp, endTs: Timestamp, event: VEvent,\n rrule: RRule) =\n let timeZone = normalizeTimeZone(if event.timeZone == \"\": self.timeZone else: event.timeZone)\n let duration = event.endTs.float - event.startTs.float\n var\n currentTs = event.startTs\n currentCal = currentTs.calendar(timeZone)\n newEndTs = event.endTs\n\n let simpleRepeat = rrule.byDay.len == 0 and rrule.byMonth.len == 0 and rrule.byMonthDay.len == 0 and\n rrule.byYearDay.len == 0 and rrule.byWeekNo.len == 0\n var nextIntervalStart: Calendar\n var counter = 0\n\n # Loop between intervals\n while (rrule.until == 0.Timestamp or currentTs <= rrule.until) and\n (rrule.count == 0 or counter < rrule.count) and\n currentTs <= endTs:\n\n if simpleRepeat:\n nextIntervalStart = getSimpleNextInterval(currentCal, rrule, timeZone)\n # COUNT must be consumed from DTSTART, not just by occurrences that land in\n # the query window — otherwise an expired COUNT series recurs forever in\n # any future window. Count every generated occurrence (EXDATE'd ones are\n # excluded here, matching existing behaviour); only the add is windowed.\n if not event.exDates.contains(currentTs):\n counter += 1\n if currentTs <= endTs and newEndTs >= startTs:\n self.addMatchedEvent(currentTs, event)\n\n # Need to loop over every day to handle BYDAY, BYMONTH, BYMONTHDAY, etc.\n else:\n nextIntervalStart = getNextIntervalStart(currentCal, rrule, timeZone)\n let intervalEnd = getEndOfThisInterval(currentCal, rrule, timeZone)\n while (rrule.until == 0.Timestamp or currentTs <= rrule.until) and\n (rrule.count == 0 or counter < rrule.count) and\n currentTs < intervalEnd and\n currentTs < endTs:\n\n if currentCal.matchesRRule(rrule) and not event.exDates.contains(currentTs):\n # Consume COUNT from DTSTART (see note above); only the add is windowed.\n counter += 1\n if currentTs <= endTs and newEndTs >= startTs:\n self.addMatchedEvent(currentTs, event)\n\n currentCal.add(TimeScale.Day, 1)\n currentCal.fixDST(timeZone)\n currentTs = currentCal.ts\n newEndTs = (currentTs.float + duration).Timestamp\n\n currentCal = nextIntervalStart\n currentTs = currentCal.ts\n newEndTs = (currentTs.float + duration).Timestamp\n\nproc getEvents*(self: var ParsedCalendar, startTs: Timestamp, endTs: Timestamp, search: string = \"\",\n maxCount: int = 1000): EventsSeq =\n\n for event in self.events:\n if search != \"\" and not event.summary.contains(search):\n continue\n\n # ⬅️ Skip whole-series cancellations (master VEVENT without RECURRENCE-ID)\n if event.status == stCancelled and event.recurrenceId.len == 0:\n continue\n\n for rrule in event.rrules:\n self.applyRRule(startTs, endTs, event, rrule)\n\n if event.rrules.len == 0 and event.startTs <= endTs and event.endTs >= startTs:\n self.addMatchedEvent(event.startTs, event)\n\n if event.recurrenceId != \"\":\n var (ridTzParam, ridDate) = extractTzAndDate(event.recurrenceId)\n let ridTz = self.tzForRid(event, ridTzParam) # NEW\n let ridTs = parseICalDateTime(ridDate, ridTz)\n if ridTs <= endTs and ridTs >= startTs:\n let displayTs = if event.startTs == 0.Timestamp: ridTs else: event.startTs\n self.addMatchedEvent(displayTs, event)\n\n var removedParentCounts: Table[string, int]\n\n # Stop original parent series after any THISANDFUTURE split points\n for uid, cutoff in self.splitCutoff.pairs():\n var kill: seq[string] = @[]\n for key, (ts, ev) in self.rRuleGenerated.pairs():\n # ev.recurrenceId == \"\" identifies instances generated from the *parent* series\n if ev.uid == uid and ev.recurrenceId == \"\" and ts >= cutoff:\n kill.add(key)\n if kill.len > 0:\n removedParentCounts[uid] = kill.len\n for k in kill:\n self.rRuleGenerated.del(k)\n\n # Child series here = overrides with RRULE *or* RANGE=THISANDFUTURE\n for uid, removed in removedParentCounts.pairs():\n if removed <= 0: continue\n if not self.splitCutoff.hasKey(uid): continue\n let cutoff = self.splitCutoff[uid]\n\n # collect child instances >= cutoff\n var childKeys: seq[(Timestamp, string)] = @[]\n for key, (ts, ev) in self.rRuleProvided.pairs():\n if ev.uid != uid: continue\n if ts < cutoff: continue\n if ev.rrules.len > 0 or hasThisAndFuture(ev.recurrenceId):\n childKeys.add((ts, key))\n\n # sort by occurrence time and keep only the first `removed`\n childKeys.sort(proc (a, b: (Timestamp, string)): int = cmp(a[0], b[0]))\n if childKeys.len > removed:\n for i in removed ..< childKeys.len:\n let k = childKeys[i][1]\n self.rRuleProvided.del(k)\n\n # dedupe rrule results and standalone results\n for key, (ts, event) in self.rRuleProvided.pairs():\n if self.rRuleGenerated.hasKey(key):\n self.rRuleGenerated.del(key) # remove the RRULE-generated original\n if event.status != stCancelled:\n self.result.add((ts, event)) # add overrides only if not cancelled\n\n for key, (ts, event) in self.rRuleGenerated.pairs():\n self.result.add((ts, event))\n\n self.reconcileExactCollisions()\n self.result.sort(cmp)\n\n if maxCount > 0 and self.result.len > maxCount:\n self.result = self.result[0.. 0:\n result[\"people\"] = %names\n\nproc assetFileExtension*(asset: JsonNode, previewSize: string): string =\n if previewSize == \"fullsize\":\n let name = asset{\"originalFileName\"}.getStr\n let dotIndex = name.rfind('.')\n if dotIndex > 0 and dotIndex < name.len - 1:\n return name[dotIndex..^1].toLowerAscii()\n \".jpg\"\n\nproc assetBaseName*(asset: JsonNode): string =\n result = asset{\"originalFileName\"}.getStr\n let dotIndex = result.rfind('.')\n if dotIndex > 0:\n result = result[0.. 0:\n result.add(\": \" & message[0].getStr($message[0]))\n elif json{\"error\"}.getStr != \"\":\n result.add(\": \" & json{\"error\"}.getStr)\n except CatchableError:\n if response.body.len > 0 and response.body.len < 300:\n result.add(\": \" & response.body)\n\nproc apiHeaders(apiKey: string): seq[SimpleHttpHeader] =\n @[\n (name: \"x-api-key\", value: apiKey),\n (name: \"Accept\", value: \"application/json\"),\n (name: \"Content-Type\", value: \"application/json\"),\n ]\n\nproc requestJson(self: App, apiKey: string, url: string, httpMethod = \"GET\", body = \"\"): BoundedHttpResponse =\n if self.frameConfig.debug:\n self.log(&\"API request: {httpMethod} {url}\")\n boundedRequestWithHeaders(\n url,\n httpMethod = httpMethod,\n body = body,\n headers = apiHeaders(apiKey),\n timeoutMs = RequestTimeoutMs,\n maxBytes = self.maxHttpResponseBytes(),\n maxSeconds = 60\n )\n\nproc fetchRandomAsset(self: App, serverUrl: string, apiKey: string, albumId = \"\", personId = \"\",\n isFavorite = false, emptyMessage = \"Immich returned no image assets.\"): tuple[asset: JsonNode, message: string] =\n var response = self.requestJson(apiKey, searchRandomUrl(serverUrl), httpMethod = \"POST\",\n body = $randomSearchBody(albumId = albumId, personId = personId, isFavorite = isFavorite))\n var usedLegacyFallback = false\n if response.code == 404:\n self.log(\"This Immich server lacks POST /api/search/random; falling back to \" &\n \"/api/assets/random where album/person/favorite filters cannot be applied.\")\n response = self.requestJson(apiKey, legacyRandomUrl(serverUrl))\n usedLegacyFallback = true\n if response.code != 200:\n return (nil, \"Error fetching a random image from Immich: \" & httpErrorMessage(response))\n var assets = imageAssets(parseJson(response.body))\n if usedLegacyFallback and isFavorite:\n var favorites: seq[JsonNode] = @[]\n for asset in assets:\n if asset{\"isFavorite\"}.getBool:\n favorites.add(asset)\n assets = favorites\n if assets.len == 0:\n if usedLegacyFallback:\n return (nil, \"This Immich server returned no matching images from /api/assets/random.\")\n return (nil, emptyMessage)\n (pickRandomAsset(assets), \"\")\n\nproc get*(self: App, context: ExecutionContext): Image =\n self.ensureEmbeddedServiceSettings()\n let serverUrl = normalizeServerUrl(self.frameConfig.settings{\"immich\"}{\"url\"}.getStr)\n if serverUrl == \"\":\n return self.error(context, \"Please provide an Immich server URL in the settings.\")\n let apiKey = self.frameConfig.settings{\"immich\"}{\"apiKey\"}.getStr\n if apiKey == \"\":\n return self.error(context, \"Please provide an Immich API key in the settings.\")\n if self.appConfig.mode == \"album\" and self.appConfig.albumId == \"\":\n return self.error(context, \"Please provide an album ID. It's the UUID visible in the album's URL.\")\n\n let width = self.contextImageWidth(context)\n let height = self.contextImageHeight(context)\n\n try:\n var asset: JsonNode = nil\n case self.appConfig.mode\n of \"album\":\n # Server-side random pick: albums can be huge and the full asset list\n # would blow the response cap (and ESP32 memory)\n let (randomAsset, message) = self.fetchRandomAsset(serverUrl, apiKey,\n albumId = self.appConfig.albumId,\n emptyMessage = \"This album contains no images. Double-check the album ID — it's the UUID in the album's URL.\")\n if randomAsset.isNil:\n return self.error(context, message)\n asset = randomAsset\n of \"memories\":\n let response = self.requestJson(apiKey, memoriesUrl(serverUrl, todayIso()))\n if response.code == 200:\n let assets = memoriesImageAssets(parseJson(response.body))\n if assets.len > 0:\n asset = pickRandomAsset(assets)\n else:\n self.log(\"No Immich memories for today (\" & httpErrorMessage(response) & \"), showing a random image instead.\")\n if asset.isNil:\n let (randomAsset, message) = self.fetchRandomAsset(serverUrl, apiKey)\n if randomAsset.isNil:\n return self.error(context, message)\n asset = randomAsset\n of \"favorites\":\n let (randomAsset, message) = self.fetchRandomAsset(serverUrl, apiKey, isFavorite = true,\n emptyMessage = \"No favorite images found in Immich.\")\n if randomAsset.isNil:\n return self.error(context, message)\n asset = randomAsset\n else:\n let (randomAsset, message) = self.fetchRandomAsset(serverUrl, apiKey, personId = self.appConfig.personId)\n if randomAsset.isNil:\n return self.error(context, message)\n asset = randomAsset\n\n let assetId = asset{\"id\"}.getStr\n if assetId == \"\":\n return self.error(context, \"Immich returned an asset without an ID.\")\n\n let downloadUrl = assetDownloadUrl(serverUrl, assetId, self.appConfig.previewSize)\n if self.frameConfig.debug:\n self.log(&\"Downloading image: {downloadUrl}\")\n let (downloadedImage, imageData) = self.downloadImageWithDataForContext(\n context,\n downloadUrl,\n maxBytes = self.maxImageResponseBytes(),\n headers = @[(name: \"x-api-key\", value: apiKey)],\n fallbackWidth = width,\n fallbackHeight = height\n )\n\n if self.appConfig.metadataStateKey != \"\":\n self.scene.state[self.appConfig.metadataStateKey] = assetMetadata(asset)\n\n if imageData.len > 0 and (self.appConfig.saveAssets == \"auto\" or self.appConfig.saveAssets == \"always\"):\n discard self.saveAsset(assetBaseName(asset), assetFileExtension(asset, self.appConfig.previewSize),\n imageData, self.appConfig.saveAssets == \"auto\")\n\n result = downloadedImage\n except CatchableError as e:\n return self.error(context, \"Error fetching image from Immich: \" & $e.msg)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n mode: params{\"mode\"}.getStr(\"random\"),\n albumId: params{\"albumId\"}.getStr(\"\"),\n personId: params{\"personId\"}.getStr(\"\"),\n metadataStateKey: params{\"metadataStateKey\"}.getStr(\"\"),\n saveAssets: params{\"saveAssets\"}.getStr(\"auto\"),\n previewSize: params{\"previewSize\"}.getStr(\"preview\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n app_module.init(app_module.App(result))\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"mode\":\n app.appConfig.mode = value.asString()\n of \"albumId\":\n app.appConfig.albumId = value.asString()\n of \"personId\":\n app.appConfig.personId = value.asString()\n of \"metadataStateKey\":\n app.appConfig.metadataStateKey = value.asString()\n of \"saveAssets\":\n app.appConfig.saveAssets = value.asString()\n of \"previewSize\":\n app.appConfig.previewSize = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Immich\",\n \"description\": \"Show images from a self-hosted Immich server\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"settings\": [\"immich\"],\n \"fields\": [\n {\n \"name\": \"mode\",\n \"type\": \"select\",\n \"value\": \"random\",\n \"options\": [\"random\", \"album\", \"favorites\", \"memories\"],\n \"required\": false,\n \"label\": \"Mode\",\n \"hint\": \"Which images to show: a random image from the whole library, a random image from one album, a random favorite, or today's memories.\"\n },\n {\n \"name\": \"albumId\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Album ID\",\n \"hint\": \"UUID of the album — visible in the album URL.\",\n \"placeholder\": \"e.g. 7f2d3c8a-1b2c-4d5e-8f90-a1b2c3d4e5f6\",\n \"showIf\": [\n {\n \"field\": \"mode\",\n \"operator\": \"eq\",\n \"value\": \"album\"\n }\n ]\n },\n {\n \"name\": \"personId\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Person ID\",\n \"hint\": \"Optional. UUID of a person to limit random images to — visible in the person's URL.\",\n \"placeholder\": \"e.g. 9c8b7a6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d\",\n \"showIf\": [\n {\n \"field\": \"mode\",\n \"operator\": \"eq\",\n \"value\": \"random\"\n }\n ]\n },\n {\n \"name\": \"metadataStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Metadata state key\",\n \"hint\": \"Enter a state key to persist metadata about the image. Stores: {source, id, originalFileName, camera EXIF fields, people, ...}.\"\n },\n {\n \"name\": \"saveAssets\",\n \"type\": \"select\",\n \"value\": \"auto\",\n \"options\": [\"auto\", \"always\", \"never\"],\n \"label\": \"Save asset\",\n \"hint\": \"Save the downloaded image to disk as an asset. It'll be placed into the frame's assets folder.\\n\\nYou can later use the 'Local image' app to view saved assets.\\n\\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved.\"\n },\n {\n \"name\": \"previewSize\",\n \"type\": \"select\",\n \"value\": \"preview\",\n \"options\": [\"preview\", \"fullsize\"],\n \"label\": \"Image size\",\n \"hint\": \"'preview' is resized server-side and much lighter on frame memory. 'fullsize' downloads the original file.\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"900\"\n }\n}\n" + }, + "data/localImage": { + "app.nim": "import json\nimport pixie\nimport options\nimport algorithm\nimport os\nimport strutils\nimport strformat\nimport random\nimport frameos/utils/image\nimport frameos/utils/app_images\nimport frameos/utils/exif\nimport frameos/apps\nimport frameos/types\nimport frameos/hal/entropy\n\nlet imageExtensions = [\".jpg\", \".jpeg\", \".png\", \".gif\", \".bmp\", \".qoi\", \".ppm\", \".svg\"]\n\ntype\n AppConfig* = object\n path*: string\n order*: string\n counterStateKey*: string\n metadataStateKey*: string\n search*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n lastSearch*: string\n lastPath*: string\n images: seq[string]\n counter: int\n lastImage*: Option[string]\n\n# Function to check if a file is an image\nproc isImage(file: string): bool =\n for ext in imageExtensions:\n if file.toLower().endsWith(ext):\n return true\n return false\n\nproc isInIgnoredDir(path: string): bool =\n let normalized = path.replace('\\\\', '/')\n for dir in [\".thumbs\", \".frameos\"]:\n if normalized.startsWith(dir & \"/\") or normalized.contains(\"/\" & dir & \"/\"):\n return true\n return false\n\nproc compareImagePaths(a, b: string): int =\n result = cmpIgnoreCase(a, b)\n if result == 0:\n result = cmp(a, b)\n\nproc sortImagesAlphabetically(images: var seq[string]) =\n images.sort(compareImagePaths)\n\nproc hasSameImages(a, b: seq[string]): bool =\n if a.len != b.len:\n return false\n\n var left = a\n var right = b\n left.sortImagesAlphabetically()\n right.sortImagesAlphabetically()\n return left == right\n\n# Function to return all images in a folder\nproc getImagesInFolder(folder: string, search: string): seq[string] =\n # if folder is a file\n if fileExists(folder):\n if isImage(folder):\n return @[\"\"]\n return @[]\n if not dirExists(folder):\n return @[]\n\n let searchQuery = search.toLower()\n var images: seq[string] = @[]\n for file in walkDirRec(folder, relative = true):\n if isInIgnoredDir(file):\n continue\n if isImage(file) and (searchQuery == \"\" or file.toLower().contains(searchQuery)):\n images.add(file)\n return images\n\nproc readExifHead*(path: string): string =\n ## First 256KB of a JPEG file: enough for the EXIF segment without\n ## re-reading whole multi-megabyte files.\n let lowerPath = path.toLower()\n if not (lowerPath.endsWith(\".jpg\") or lowerPath.endsWith(\".jpeg\")):\n return \"\"\n var file: File\n if not open(file, path):\n return \"\"\n defer: file.close()\n try:\n result = newString(ExifScanBytes)\n let bytesRead = file.readBuffer(addr result[0], result.len)\n result.setLen(max(bytesRead, 0))\n except CatchableError:\n result = \"\"\n\nproc error*(self: App, context: ExecutionContext, message: string,\n target: Image = nil): Image =\n self.logError(message)\n if not target.isNil:\n # Reuse the canvas the consumer draws onto: an error frame must not\n # allocate a second full-size image on memory-tight devices.\n renderErrorInto(target, target.width, target.height, message)\n return target\n return renderError(\n if context.hasImage: context.image.width else: self.frameConfig.renderWidth(),\n if context.hasImage: context.image.height else: self.frameConfig.renderHeight(),\n message\n )\n\nproc init*(self: App) =\n let folder = if self.appConfig.path == \"\": self.frameConfig.assetsPath else: self.appConfig.path\n self.images = getImagesInFolder(folder, self.appConfig.search)\n self.lastPath = self.appConfig.path\n self.lastSearch = self.appConfig.search\n self.counter = 0\n self.lastImage = none(string)\n if self.appConfig.search != \"\":\n self.log(\"Search query: \" & self.appConfig.search)\n self.log(\"Found \" & $self.images.len & \" images in the folder: \" & folder)\n if self.appConfig.order == \"random\":\n randomizeSafe()\n self.images.shuffle()\n else:\n self.images.sortImagesAlphabetically()\n if self.appConfig.order != \"random\" and self.appConfig.counterStateKey != \"\" and self.images.len > 0:\n self.counter = self.scene.state{self.appConfig.counterStateKey}.getInt() mod self.images.len\n\nproc refreshImages(self: App) =\n let folder = if self.appConfig.path == \"\": self.frameConfig.assetsPath else: self.appConfig.path\n let previousImage = self.lastImage\n let newImages = getImagesInFolder(folder, self.appConfig.search)\n var nextImages = newImages\n var hasChanged = false\n self.lastPath = self.appConfig.path\n self.lastSearch = self.appConfig.search\n\n if self.appConfig.order == \"random\":\n hasChanged = not hasSameImages(newImages, self.images)\n if hasChanged:\n randomizeSafe()\n nextImages.shuffle()\n else:\n nextImages = self.images\n else:\n nextImages.sortImagesAlphabetically()\n hasChanged = nextImages != self.images\n\n self.images = nextImages\n\n if self.images.len == 0:\n return\n\n if previousImage.isSome:\n let lastIndex = self.images.find(previousImage.get())\n if lastIndex >= 0:\n self.counter = (lastIndex + 1) mod self.images.len\n elif self.counter >= self.images.len:\n self.counter = self.counter mod self.images.len\n elif self.counter >= self.images.len:\n self.counter = self.counter mod self.images.len\n\nproc decodeBoundsForContext(self: App, context: ExecutionContext):\n tuple[maxEdge: int, maxPixels: int] =\n ## Decode at full resolution whenever memory allows: downstream consumers\n ## (resize crops, transforms) need native detail, and pre-scaling to the\n ## context size degraded them — the context can even be a small split\n ## cell. The live memory budget in displayDecodeDimensions still scales\n ## oversized decodes down on constrained devices (ESP32, low-RAM Pi).\n (0, 0)\n\nproc get*(self: App, context: ExecutionContext): Image =\n # Consume the decode-into-canvas hint up front so every path below —\n # including error frames — can reuse the canvas instead of allocating a\n # second full-size image.\n let decodeTarget = context.decodeTargetImage\n let decodeScalingMode = context.decodeTargetScalingMode\n if not decodeTarget.isNil:\n context.decodeTargetImage = nil\n context.decodeTargetScalingMode = \"\"\n\n if self.appConfig.search != self.lastSearch or self.appConfig.path != self.lastPath:\n self.init() # re-init if the query changes\n\n self.refreshImages()\n\n if self.images.len() == 0:\n if self.appConfig.search != \"\":\n return self.error(context, &\"No images matching the search query \\\"{self.appConfig.search}\\\" found in the folder: {self.appConfig.path}\", decodeTarget)\n return self.error(context, &\"No images found in the folder: {self.appConfig.path}\", decodeTarget)\n\n let folder = if self.appConfig.path == \"\": self.frameConfig.assetsPath else: self.appConfig.path\n let currentIndex = self.counter\n let currentImage = self.images[currentIndex]\n var nextImage: Option[Image] = none(Image)\n let path = joinPath(folder, currentImage)\n self.log(\"Loading image: \" & path)\n self.counter = (self.counter + 1) mod len(self.images)\n if self.appConfig.counterStateKey != \"\":\n self.scene.state[self.appConfig.counterStateKey] = %*(self.counter)\n\n # When the consumer draws this image full-frame onto the canvas, decode\n # straight into the canvas: peak memory stays at decode intermediates\n # instead of canvas + full decoded copy + compressed file.\n if not decodeTarget.isNil:\n try:\n if readImageIntoTarget(path, decodeTarget, decodeScalingMode):\n nextImage = some(decodeTarget)\n except CatchableError as e:\n return self.error(context, \"An error occurred while loading the image: \" & path & \"\\n\" & e.msg, decodeTarget)\n\n if nextImage.isNone:\n try:\n let decodeBounds = self.decodeBoundsForContext(context)\n nextImage = some(readImageWithDisplayBounds(\n path,\n maxEdge = decodeBounds.maxEdge,\n maxPixels = decodeBounds.maxPixels\n ))\n except CatchableError as e:\n return self.error(context, \"An error occurred while loading the image: \" & path & \"\\n\" & e.msg, decodeTarget)\n\n let image = nextImage.get()\n if self.appConfig.metadataStateKey != \"\":\n var metadata = %*{\n \"path\": path,\n \"filename\": currentImage,\n \"index\": currentIndex,\n \"total\": self.images.len,\n \"width\": image.width,\n \"height\": image.height\n }\n let exifMetadata = getExifMetadataFromPath(path)\n if exifMetadata.isSome:\n metadata[\"exif\"] = exifMetadata.get()\n mergeParsedExif(metadata, readExifHead(path))\n self.scene.state[self.appConfig.metadataStateKey] = metadata\n\n self.lastImage = some(currentImage)\n return image\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n path: params{\"path\"}.getStr(\"\"),\n order: params{\"order\"}.getStr(\"random\"),\n counterStateKey: params{\"counterStateKey\"}.getStr(\"\"),\n metadataStateKey: params{\"metadataStateKey\"}.getStr(\"\"),\n search: params{\"search\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n app_module.init(app_module.App(result))\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"path\":\n app.appConfig.path = value.asString()\n of \"order\":\n app.appConfig.order = value.asString()\n of \"counterStateKey\":\n app.appConfig.counterStateKey = value.asString()\n of \"metadataStateKey\":\n app.appConfig.metadataStateKey = value.asString()\n of \"search\":\n app.appConfig.search = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Local Image\",\n \"description\": \"Show an image from the SD card\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"path\",\n \"type\": \"text\",\n \"value\": \"\",\n \"required\": true,\n \"label\": \"Image filename or folder\",\n \"placeholder\": \"Defaults to the assets folder (e.g. /srv/assets)\"\n },\n {\n \"name\": \"order\",\n \"type\": \"select\",\n \"options\": [\"random\", \"alphabetical\"],\n \"value\": \"random\",\n \"required\": true,\n \"label\": \"Order of images\"\n },\n {\n \"name\": \"counterStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Optional state key for persistence\",\n \"hint\": \"Enter a state key to persist the current image index between restarts.\"\n },\n {\n \"name\": \"metadataStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Optional metadata state key\",\n \"hint\": \"Enter a state key to persist metadata about the last image. Stores: {path, filename, index, total, width, height, exif: {make, model, lensModel, exposureTime, fNumber, iso, focalLength, dateTimeOriginal, gpsLatitude, ...}, exifSummary}. EXIF is parsed from JPEG files; exifSummary is a one-line photographer credit.\"\n },\n {\n \"name\": \"search\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Filter filenames by keyword\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"900\"\n }\n}\n" + }, + "data/log": { + "app.nim": "import json\nimport frameos/apps\nimport frameos/types\n\ntype\n AppConfig* = object\n inputJson*: JsonNode\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): JsonNode =\n if self.appConfig.inputJson != nil and self.appConfig.inputJson.kind != JNull and self.appConfig.inputJson.kind != JNull:\n self.log(%*({\"event\": \"log\", \"message\": self.appConfig.inputJson}))\n return self.appConfig.inputJson\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n inputJson: params{\"inputJson\"},\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"inputJson\":\n app.appConfig.inputJson = value.asJson()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Log JSON\",\n \"description\": \"Log a JSON object\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"inputJson\",\n \"type\": \"json\",\n \"required\": false,\n \"label\": \"Input JSON\"\n }\n ],\n \"output\": [\n {\n \"name\": \"outputJson\",\n \"type\": \"json\"\n }\n ]\n}\n" + }, + "data/newImage": { + "app.nim": "import pixie\nimport frameos/apps\nimport frameos/types\n\ntype\n AppConfig* = object\n color*: Color\n width*: int\n height*: int\n opacity*: float\n renderNext*: NodeId\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): Image =\n let width = if self.appConfig.width != 0:\n self.appConfig.width\n elif context.hasImage:\n context.image.width\n else:\n self.frameConfig.renderWidth()\n let height = if self.appConfig.height != 0:\n self.appConfig.height\n elif context.hasImage:\n context.image.height\n else:\n self.frameConfig.renderHeight()\n let image = newImage(width, height)\n if self.appConfig.opacity != 1.0:\n var color = Color(r: self.appConfig.color.r, g: self.appConfig.color.g, b: self.appConfig.color.b,\n a: float32(self.appConfig.opacity))\n image.fill(color)\n elif self.appConfig.opacity != 0.0:\n image.fill(self.appConfig.color)\n\n if self.appConfig.renderNext != 0.NodeId:\n var nextContext = ExecutionContext(\n scene: context.scene,\n image: image,\n hasImage: true,\n event: context.event,\n payload: context.payload,\n parent: context,\n nextSleep: context.nextSleep\n )\n self.scene.execNode(self.appConfig.renderNext, nextContext)\n if nextContext.nextSleep != context.nextSleep:\n context.nextSleep = nextContext.nextSleep\n return image\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n width: block:\n var v = 0\n if params.hasKey(\"width\"):\n let n = params{\"width\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n height: block:\n var v = 0\n if params.hasKey(\"height\"):\n let n = params{\"height\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n color: block:\n var v: Color = parseHtmlColor(\"#ffffff\")\n if params.hasKey(\"color\"):\n let n = params{\"color\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n opacity: block:\n var v = 1.0\n if params.hasKey(\"opacity\"):\n let n = params{\"opacity\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n renderNext: block:\n var v: NodeId = 0.NodeId\n if params.hasKey(\"renderNext\"):\n let n = params{\"renderNext\"}\n if n.kind == JInt:\n v = n.getInt().int.NodeId\n elif n.kind == JFloat:\n v = int(n.getFloat()).NodeId\n elif n.kind == JString:\n try: v = int(parseFloat(n.getStr())).NodeId\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"width\":\n app.appConfig.width = value.asInt().int\n of \"height\":\n app.appConfig.height = value.asInt().int\n of \"color\":\n app.appConfig.color = value.asColor()\n of \"opacity\":\n app.appConfig.opacity = value.asFloat()\n of \"renderNext\":\n app.appConfig.renderNext = value.asNode()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"New Image\",\n \"description\": \"Create a new image with a single color background\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"width\",\n \"type\": \"integer\",\n \"required\": false,\n \"label\": \"Width\",\n \"placeholder\": \"auto\"\n },\n {\n \"name\": \"height\",\n \"type\": \"integer\",\n \"required\": false,\n \"label\": \"Height\",\n \"placeholder\": \"auto\"\n },\n {\n \"name\": \"color\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"required\": true,\n \"label\": \"Color\"\n },\n {\n \"name\": \"opacity\",\n \"type\": \"float\",\n \"required\": false,\n \"label\": \"Opacity (0-1)\",\n \"value\": \"1\"\n },\n {\n \"name\": \"renderNext\",\n \"type\": \"node\",\n \"required\": false,\n \"label\": \"Render next\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true\n }\n}\n" + }, + "data/openaiImage": { + "app.nim": "import pixie\nimport options\nimport json\nimport base64\nimport strutils\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/app_images\nimport frameos/utils/http_client\nimport frameos/utils/image\n\nwhen defined(frameosEmbedded):\n import pixie/fileformats/jpeg\n\ntype\n AppConfig* = object\n prompt*: string\n model*: string\n style*: string\n quality*: string\n size*: string\n outputFormat*: string\n outputCompression*: int\n saveAssets*: string\n metadataStateKey*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc error*(self: App, context: ExecutionContext, message: string): Image =\n self.logError(message)\n result = self.renderErrorForContext(context, message)\n\nwhen defined(frameosEmbedded):\n proc bestEmbeddedGptImage2Size(width, height: int): string =\n if width > height:\n \"1024x640\"\n elif width < height:\n \"640x1024\"\n else:\n \"1024x1024\"\n\nproc isGptImageModel(model: string): bool =\n model.startsWith(\"gpt-image-\")\n\nproc get*(self: App, context: ExecutionContext): Image =\n let prompt = self.appConfig.prompt\n if prompt == \"\":\n return self.error(context, \"No prompt provided in app config.\")\n self.ensureEmbeddedServiceSettings()\n let apiKey = self.frameConfig.settings{\"openAI\"}{\"apiKey\"}.getStr\n if apiKey == \"\":\n return self.error(context, \"Please provide an OpenAI API key in the settings.\")\n\n let imageWidth = self.contextImageWidth(context)\n let imageHeight = self.contextImageHeight(context)\n let defaultSize = \"1024x1024\"\n let dalle3Sizes = @[\"1024x1024\", \"1792x1024\", \"1024x1792\"]\n let dalle2Sizes = @[\"256x256\", \"512x512\", \"1024x1024\"]\n let gptImageSizes = @[\"1024x1024\", \"1536x1024\", \"1024x1536\"]\n let gptImage2Sizes = @[\"1024x640\", \"640x1024\", \"1024x1024\", \"1536x1024\", \"1024x1536\"]\n let size = if self.appConfig.size == \"best for orientation\":\n case self.appConfig.model\n of \"dall-e-3\":\n if imageWidth > imageHeight: \"1792x1024\"\n elif imageWidth < imageHeight: \"1024x1792\"\n else: defaultSize\n of \"gpt-image-2\":\n when defined(frameosEmbedded):\n bestEmbeddedGptImage2Size(imageWidth, imageHeight)\n else:\n if imageWidth > imageHeight: \"1536x1024\"\n elif imageWidth < imageHeight: \"1024x1536\"\n else: defaultSize\n of \"gpt-image-1\", \"gpt-image-1.5\":\n if imageWidth > imageHeight: \"1536x1024\"\n elif imageWidth < imageHeight: \"1024x1536\"\n else: defaultSize\n else:\n defaultSize\n elif self.appConfig.size != \"\":\n case self.appConfig.model\n of \"dall-e-3\":\n if self.appConfig.size in dalle3Sizes: self.appConfig.size else: defaultSize\n of \"dall-e-2\":\n if self.appConfig.size in dalle2Sizes: self.appConfig.size else: defaultSize\n of \"gpt-image-2\":\n if self.appConfig.size in gptImage2Sizes: self.appConfig.size else: defaultSize\n of \"gpt-image-1\", \"gpt-image-1.5\":\n if self.appConfig.size in gptImageSizes: self.appConfig.size else: defaultSize\n else:\n defaultSize\n else:\n defaultSize\n var body = %*{\n \"prompt\": prompt,\n \"n\": 1,\n \"size\": size,\n \"model\": self.appConfig.model\n }\n if self.appConfig.model == \"dall-e-3\":\n if self.appConfig.style != \"\":\n body[\"style\"] = %self.appConfig.style\n if self.appConfig.quality != \"\":\n body[\"quality\"] = %self.appConfig.quality\n elif isGptImageModel(self.appConfig.model):\n if self.appConfig.quality in [\"low\", \"medium\", \"high\", \"auto\"]:\n body[\"quality\"] = %self.appConfig.quality\n when defined(frameosEmbedded):\n body[\"quality\"] = %\"low\"\n var outputFormat = self.appConfig.outputFormat\n if outputFormat == \"\" or outputFormat == \"auto\":\n outputFormat = \"jpeg\"\n if outputFormat in [\"jpeg\", \"png\", \"webp\"]:\n body[\"output_format\"] = %outputFormat\n if outputFormat in [\"jpeg\", \"webp\"]:\n let compression = if self.appConfig.outputCompression > 0: self.appConfig.outputCompression else: 50\n body[\"output_compression\"] = %min(max(compression, 0), 100)\n else:\n if self.appConfig.outputFormat in [\"jpeg\", \"png\", \"webp\"]:\n body[\"output_format\"] = %self.appConfig.outputFormat\n if self.appConfig.outputFormat in [\"jpeg\", \"webp\"] and self.appConfig.outputCompression > 0:\n body[\"output_compression\"] = %min(max(self.appConfig.outputCompression, 0), 100)\n try:\n var revisedPrompt = \"\"\n var imageDataBody = \"\"\n block requestScope:\n let response = boundedRequestWithHeaders(\n \"https://api.openai.com/v1/images/generations\",\n httpMethod = \"POST\",\n body = $body,\n headers = @[\n (name: \"Authorization\", value: \"Bearer \" & apiKey),\n (name: \"Content-Type\", value: \"application/json\"),\n ],\n timeoutMs = 300000,\n maxBytes = self.maxHttpResponseBytes(),\n maxSeconds = 300\n )\n if response.code != 200:\n try:\n let json = parseJson(response.body)\n let error = json{\"error\"}{\"message\"}.getStr(json{\"error\"}.getStr($json))\n return self.error(context, \"Error making request \" & $response.status & \": \" & error)\n except:\n return self.error(context, \"Error making request \" & $response.status & \": \" & response.body)\n let json = parseJson(response.body)\n let imageNode = json{\"data\"}{0}\n revisedPrompt = imageNode{\"revised_prompt\"}.getStr\n let imageBase64 = imageNode{\"b64_json\"}.getStr\n if imageBase64 != \"\":\n imageDataBody = imageBase64.decode\n else:\n let imageUrl = imageNode{\"url\"}.getStr\n if imageUrl == \"\":\n return self.error(context, \"No image data returned from OpenAI.\")\n let (downloadedImage, downloadedData) = self.downloadImageWithDataForContext(\n context,\n imageUrl,\n maxBytes = self.maxImageResponseBytes(),\n fallbackWidth = imageWidth,\n fallbackHeight = imageHeight\n )\n imageDataBody = downloadedData\n if self.appConfig.metadataStateKey != \"\":\n var metadata = %*{\n \"source\": \"openai\",\n \"prompt\": prompt,\n \"generatedPrompt\": if revisedPrompt != \"\": revisedPrompt else: prompt,\n \"model\": self.appConfig.model,\n \"size\": size,\n }\n if revisedPrompt != \"\":\n metadata[\"revisedPrompt\"] = %revisedPrompt\n self.scene.state[self.appConfig.metadataStateKey] = metadata\n if imageDataBody.len > 0 and (self.appConfig.saveAssets == \"auto\" or self.appConfig.saveAssets == \"always\"):\n discard self.saveAsset(prompt, \".jpg\", imageDataBody, self.appConfig.saveAssets == \"auto\")\n return downloadedImage\n if imageDataBody.len > 0 and (self.appConfig.saveAssets == \"auto\" or self.appConfig.saveAssets == \"always\"):\n discard self.saveAsset(prompt, \".jpg\", imageDataBody, self.appConfig.saveAssets == \"auto\")\n if self.appConfig.metadataStateKey != \"\":\n var metadata = %*{\n \"source\": \"openai\",\n \"prompt\": prompt,\n \"generatedPrompt\": if revisedPrompt != \"\": revisedPrompt else: prompt,\n \"model\": self.appConfig.model,\n \"size\": size,\n }\n if revisedPrompt != \"\":\n metadata[\"revisedPrompt\"] = %revisedPrompt\n self.scene.state[self.appConfig.metadataStateKey] = metadata\n\n when defined(frameosEmbedded):\n if imageDataBody.len <= 0:\n return self.error(context, \"No image data returned from OpenAI.\")\n GC_fullCollect()\n let bytes = cast[ptr UncheckedArray[uint8]](unsafeAddr imageDataBody[0])\n if imageDataBody.len > 2 and bytes[0] == 0xFF'u8 and bytes[1] == 0xD8'u8:\n let target = context.contextImage()\n if not target.isNil:\n when compiles(decodeJpegScaledInto(imageDataBody, target)):\n decodeJpegScaledInto(imageDataBody, target)\n result = target\n else:\n result = decodeImageWithFallback(unsafeAddr imageDataBody[0], imageDataBody.len, target)\n else:\n when compiles(decodeJpegScaled(imageDataBody, imageWidth, imageHeight)):\n result = decodeJpegScaled(\n imageDataBody,\n imageWidth,\n imageHeight\n )\n else:\n result = decodeImageWithFallback(unsafeAddr imageDataBody[0], imageDataBody.len)\n else:\n result = decodeImageWithFallback(unsafeAddr imageDataBody[0], imageDataBody.len)\n else:\n result = decodeImageWithDisplayBounds(imageDataBody)\n except CatchableError as e:\n return self.error(context, \"Error fetching image from OpenAI: \" & $e.msg)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n prompt: params{\"prompt\"}.getStr(\"\"),\n model: params{\"model\"}.getStr(\"gpt-image-2\"),\n size: params{\"size\"}.getStr(\"best for orientation\"),\n style: params{\"style\"}.getStr(\"vivid\"),\n quality: params{\"quality\"}.getStr(\"standard\"),\n outputFormat: params{\"outputFormat\"}.getStr(\"auto\"),\n outputCompression: block:\n var v = 0\n if params.hasKey(\"outputCompression\"):\n let n = params{\"outputCompression\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n metadataStateKey: params{\"metadataStateKey\"}.getStr(\"\"),\n saveAssets: params{\"saveAssets\"}.getStr(\"auto\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"prompt\":\n app.appConfig.prompt = value.asString()\n of \"model\":\n app.appConfig.model = value.asString()\n of \"size\":\n app.appConfig.size = value.asString()\n of \"style\":\n app.appConfig.style = value.asString()\n of \"quality\":\n app.appConfig.quality = value.asString()\n of \"outputFormat\":\n app.appConfig.outputFormat = value.asString()\n of \"outputCompression\":\n app.appConfig.outputCompression = value.asInt().int\n of \"metadataStateKey\":\n app.appConfig.metadataStateKey = value.asString()\n of \"saveAssets\":\n app.appConfig.saveAssets = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"OpenAI Image\",\n \"description\": \"Random AI generated art from OpenAI's image models\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"settings\": [\"openAI\"],\n \"fields\": [\n {\n \"name\": \"prompt\",\n \"type\": \"text\",\n \"rows\": 4,\n \"value\": \"\",\n \"required\": true,\n \"label\": \"Prompt\",\n \"placeholder\": \"e.g. pumpkin pyjama party, digital art\"\n },\n {\n \"name\": \"model\",\n \"type\": \"select\",\n \"options\": [\"gpt-image-2\", \"gpt-image-1.5\", \"gpt-image-1\", \"dall-e-3\", \"dall-e-2\"],\n \"value\": \"gpt-image-2\",\n \"required\": true,\n \"label\": \"Model\"\n },\n {\n \"name\": \"size\",\n \"type\": \"select\",\n \"options\": [\n \"best for orientation\",\n \"1024x640\",\n \"640x1024\",\n \"1024x1024\",\n \"1536x1024\",\n \"1024x1536\"\n ],\n \"value\": \"best for orientation\",\n \"label\": \"Size\",\n \"showIf\": [\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-2\"\n }\n ]\n },\n {\n \"name\": \"size\",\n \"type\": \"select\",\n \"options\": [\n \"best for orientation\",\n \"1024x1024\",\n \"1536x1024\",\n \"1024x1536\"\n ],\n \"value\": \"best for orientation\",\n \"label\": \"Size\",\n \"showIf\": [\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-1\"\n },\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-1.5\"\n }\n ]\n },\n {\n \"name\": \"size\",\n \"type\": \"select\",\n \"options\": [\n \"best for orientation\",\n \"1024x1024\",\n \"1792x1024\",\n \"1024x1792\"\n ],\n \"value\": \"best for orientation\",\n \"label\": \"Size\",\n \"showIf\": [\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"dall-e-3\"\n }\n ]\n },\n {\n \"name\": \"size\",\n \"type\": \"select\",\n \"options\": [\n \"best for orientation\",\n \"1024x1024\",\n \"512x512\",\n \"256x256\"\n ],\n \"value\": \"best for orientation\",\n \"label\": \"Size\",\n \"showIf\": [\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"dall-e-2\"\n }\n ]\n },\n {\n \"name\": \"style\",\n \"type\": \"select\",\n \"options\": [\"vivid\", \"natural\", \"\"],\n \"value\": \"vivid\",\n \"label\": \"Style\",\n \"showIf\": [\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"dall-e-3\"\n }\n ]\n },\n {\n \"name\": \"quality\",\n \"type\": \"select\",\n \"options\": [\"standard\", \"hd\", \"\"],\n \"value\": \"standard\",\n \"label\": \"Quality\",\n \"showIf\": [\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"dall-e-3\"\n }\n ]\n },\n {\n \"name\": \"quality\",\n \"type\": \"select\",\n \"options\": [\"auto\", \"low\", \"medium\", \"high\", \"\"],\n \"value\": \"auto\",\n \"label\": \"Quality\",\n \"showIf\": [\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-2\"\n },\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-1\"\n },\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-1.5\"\n }\n ]\n },\n {\n \"name\": \"outputFormat\",\n \"type\": \"select\",\n \"options\": [\"auto\", \"png\", \"jpeg\", \"webp\"],\n \"value\": \"auto\",\n \"label\": \"Output format\",\n \"showIf\": [\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-2\"\n },\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-1\"\n },\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-1.5\"\n }\n ]\n },\n {\n \"name\": \"outputCompression\",\n \"type\": \"integer\",\n \"value\": 0,\n \"min\": 0,\n \"max\": 100,\n \"label\": \"Output compression\",\n \"hint\": \"Compression for JPEG/WebP outputs. Use 0 for the runtime default.\",\n \"showIf\": [\n {\n \"and\": [\n {\n \"field\": \"outputFormat\",\n \"operator\": \"ne\",\n \"value\": \"png\"\n },\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-2\"\n }\n ]\n },\n {\n \"and\": [\n {\n \"field\": \"outputFormat\",\n \"operator\": \"ne\",\n \"value\": \"png\"\n },\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-1\"\n }\n ]\n },\n {\n \"and\": [\n {\n \"field\": \"outputFormat\",\n \"operator\": \"ne\",\n \"value\": \"png\"\n },\n {\n \"field\": \"model\",\n \"operator\": \"eq\",\n \"value\": \"gpt-image-1.5\"\n }\n ]\n }\n ]\n },\n {\n \"name\": \"metadataStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Metadata state key\",\n \"hint\": \"Enter a state key to persist metadata about the image. Stores: {prompt, generatedPrompt, model, size}.\"\n },\n {\n \"name\": \"saveAssets\",\n \"type\": \"select\",\n \"value\": \"auto\",\n \"options\": [\"auto\", \"always\", \"never\"],\n \"label\": \"Save asset\",\n \"hint\": \"Save the generated image to disk as an asset. It'll be placed into the frame's assets folder.\\n\\nYou can later use the 'Local image' app to view saved assets.\\n\\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved.\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"3600\"\n }\n}\n" + }, + "data/openaiText": { + "app.nim": "import pixie\nimport options\nimport json\nimport strformat\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/http_client\n\ntype\n AppConfig* = object\n model*: string\n system*: string\n user*: string\n stateKey*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc error*(self: App, message: string) =\n self.logError(message)\n self.scene.state[self.appConfig.stateKey] = %*(&\"Error: {message}\")\n\nproc get*(self: App, context: ExecutionContext): string =\n if self.appConfig.user == \"\" and self.appConfig.system == \"\":\n self.error(\"No system or user prompt provided in app config.\")\n return\n self.ensureEmbeddedServiceSettings()\n let apiKey = self.frameConfig.settings{\"openAI\"}{\"apiKey\"}.getStr\n if apiKey == \"\":\n self.error(\"Please provide an OpenAI API key in the settings.\")\n return\n\n let body = %*{\n \"model\": self.appConfig.model,\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": self.appConfig.system\n },\n {\n \"role\": \"user\",\n \"content\": self.appConfig.user\n }\n ]\n }\n try:\n self.log(%*{\"user\": self.appConfig.user, \"system\": self.appConfig.system})\n let response = boundedRequestWithHeaders(\n \"https://api.openai.com/v1/chat/completions\",\n httpMethod = \"POST\",\n body = $body,\n headers = @[\n (name: \"Authorization\", value: \"Bearer \" & apiKey),\n (name: \"Content-Type\", value: \"application/json\"),\n ],\n timeoutMs = 60000,\n maxBytes = self.maxHttpResponseBytes(),\n maxSeconds = 60\n )\n if response.code != 200:\n try:\n let json = parseJson(response.body)\n let error = json{\"error\"}{\"message\"}.getStr(json{\"error\"}.getStr($json))\n self.error(\"Error making request \" & $response.status & \": \" & error)\n except:\n self.error \"Error making request \" & $response.status & \": \" & response.body\n return\n let json = parseJson(response.body)\n let reply = json{\"choices\"}{0}{\"message\"}{\"content\"}.getStr\n self.log(%*{\"reply\": reply})\n result = reply\n except CatchableError as e:\n self.error \"OpenAI API error: \" & $e.msg\n result = \"OpenAI API error: \" & $e.msg\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n system: params{\"system\"}.getStr(\"You're a smart e-ink frame running FrameOS. Reply with plain text only. Space is very limited.\"),\n user: params{\"user\"}.getStr(\"\"),\n model: params{\"model\"}.getStr(\"gpt-5.5\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"system\":\n app.appConfig.system = value.asString()\n of \"user\":\n app.appConfig.user = value.asString()\n of \"model\":\n app.appConfig.model = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"OpenAI Text\",\n \"description\": \"Text response from ChatGPT and friends\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"settings\": [\"openAI\"],\n \"fields\": [\n {\n \"name\": \"system\",\n \"type\": \"text\",\n \"rows\": 6,\n \"value\": \"You're a smart e-ink frame running FrameOS. Reply with plain text only. Space is very limited.\",\n \"required\": true,\n \"label\": \"System Prompt\",\n \"placeholder\": \"\"\n },\n {\n \"name\": \"user\",\n \"type\": \"text\",\n \"rows\": 6,\n \"value\": \"\",\n \"required\": true,\n \"label\": \"User Prompt\",\n \"placeholder\": \"Write your prompt here. Keep it short and clear.\"\n },\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"value\": \"gpt-5.5\",\n \"required\": true,\n \"label\": \"Model\"\n }\n ],\n \"output\": [\n {\n \"name\": \"reply\",\n \"type\": \"string\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"600\"\n }\n}\n" + }, + "data/parseJson": { + "app.nim": "import frameos/types\nimport std/json\n\ntype\n AppConfig* = object\n text*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): JsonNode =\n try:\n result = parseJson(self.appConfig.text)\n except JsonParsingError as err:\n raise newException(ValueError, \"Failed to parse JSON: \" & err.msg)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n text: params{\"text\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"text\":\n app.appConfig.text = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Parse JSON\",\n \"description\": \"Parse a text string and convert to JSON\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"text\",\n \"type\": \"text\",\n \"required\": true,\n \"label\": \"Text\"\n }\n ],\n \"output\": [\n {\n \"name\": \"json\",\n \"type\": \"json\"\n }\n ]\n}\n" + }, + "data/prettyJson": { + "app.nim": "import frameos/types\nimport json\n\ntype\n AppConfig* = object\n json*: JsonNode\n ident*: int\n prettify*: bool\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): string =\n if self.appConfig.prettify:\n result = pretty(self.appConfig.json, self.appConfig.ident)\n else:\n result = $self.appConfig.json\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n json: params{\"json\"},\n ident: block:\n var v = 2\n if params.hasKey(\"ident\"):\n let n = params{\"ident\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n prettify: block:\n var v = true\n if params.hasKey(\"prettify\"):\n let n = params{\"prettify\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"json\":\n app.appConfig.json = value.asJson()\n of \"ident\":\n app.appConfig.ident = value.asInt().int\n of \"prettify\":\n app.appConfig.prettify = value.asBool()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Pretty JSON\",\n \"description\": \"Pretty print JSON into a string\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"json\",\n \"type\": \"json\",\n \"required\": true,\n \"label\": \"JSON\"\n },\n {\n \"name\": \"ident\",\n \"type\": \"integer\",\n \"value\": \"2\",\n \"required\": false,\n \"label\": \"Indent\"\n },\n {\n \"name\": \"prettify\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": false,\n \"label\": \"Prettify\"\n }\n ],\n \"output\": [\n {\n \"name\": \"result\",\n \"type\": \"string\"\n }\n ]\n}\n" + }, + "data/qr": { + "app.nim": "import pixie\nimport QRgen\nimport QRgen/renderer\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/url\n\ntype\n AppConfig* = object\n codeType*: string\n code*: string\n size*: float\n sizeUnit*: string\n alRad*: float\n moRad*: float\n moSep*: float\n padding*: int\n qrCodeColor*: Color\n backgroundColor*: Color\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): Image =\n let code = if self.appConfig.codeType == \"Frame Control URL\":\n authenticatedFrameUrl(self.frameConfig, \"/c\")\n elif self.appConfig.codeType == \"Frame Image URL\":\n authenticatedFrameUrl(self.frameConfig, \"/\", requireWriteAccess = false)\n else:\n self.appConfig.code\n\n let myQR = newQR(code)\n\n let width = case self.appConfig.sizeUnit\n of \"percent\": self.appConfig.size / 100.0 * min(if context.hasImage: context.image.width else: self.frameConfig.renderWidth(),\n if context.hasImage: context.image.height else: self.frameConfig.renderHeight()).float\n of \"pixels per dot\": self.appConfig.size * (myQR.drawing.size.int + self.appConfig.padding * 2).float\n else: self.appConfig.size\n\n result = myQR.renderImg(\n light = self.appConfig.backgroundColor.toHtmlHex,\n dark = self.appConfig.qrCodeColor.toHtmlHex,\n alRad = self.appConfig.alRad,\n moRad = self.appConfig.moRad,\n moSep = self.appConfig.moSep,\n pixels = width.uint32,\n padding = self.appConfig.padding.uint8\n )\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n codeType: params{\"codeType\"}.getStr(\"Frame Control URL\"),\n code: params{\"code\"}.getStr(\"\"),\n size: block:\n var v = 2.0\n if params.hasKey(\"size\"):\n let n = params{\"size\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n sizeUnit: params{\"sizeUnit\"}.getStr(\"pixels per dot\"),\n alRad: block:\n var v = 30.0\n if params.hasKey(\"alRad\"):\n let n = params{\"alRad\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n moRad: block:\n var v = 0.0\n if params.hasKey(\"moRad\"):\n let n = params{\"moRad\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n moSep: block:\n var v = 0.0\n if params.hasKey(\"moSep\"):\n let n = params{\"moSep\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n padding: block:\n var v = 1\n if params.hasKey(\"padding\"):\n let n = params{\"padding\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n qrCodeColor: block:\n var v: Color = parseHtmlColor(\"#000000\")\n if params.hasKey(\"qrCodeColor\"):\n let n = params{\"qrCodeColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n backgroundColor: block:\n var v: Color = parseHtmlColor(\"#ffffff\")\n if params.hasKey(\"backgroundColor\"):\n let n = params{\"backgroundColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"codeType\":\n app.appConfig.codeType = value.asString()\n of \"code\":\n app.appConfig.code = value.asString()\n of \"size\":\n app.appConfig.size = value.asFloat()\n of \"sizeUnit\":\n app.appConfig.sizeUnit = value.asString()\n of \"alRad\":\n app.appConfig.alRad = value.asFloat()\n of \"moRad\":\n app.appConfig.moRad = value.asFloat()\n of \"moSep\":\n app.appConfig.moSep = value.asFloat()\n of \"padding\":\n app.appConfig.padding = value.asInt().int\n of \"qrCodeColor\":\n app.appConfig.qrCodeColor = value.asColor()\n of \"backgroundColor\":\n app.appConfig.backgroundColor = value.asColor()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"QR Code\",\n \"description\": \"QR codes. Default to a link to the frame control URL.\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"codeType\",\n \"label\": \"Code Type\",\n \"type\": \"select\",\n \"options\": [\"Frame Control URL\", \"Frame Image URL\", \"Custom\"],\n \"value\": \"Frame Control URL\",\n \"required\": false\n },\n {\n \"name\": \"code\",\n \"label\": \"Custom code\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"showIf\": [\n {\n \"field\": \"codeType\",\n \"operator\": \"eq\",\n \"value\": \"Custom\"\n }\n ]\n },\n {\n \"name\": \"size\",\n \"type\": \"float\",\n \"value\": \"2\",\n \"label\": \"Size\"\n },\n {\n \"name\": \"sizeUnit\",\n \"type\": \"select\",\n \"options\": [\"pixels per dot\", \"pixels total\", \"percent\"],\n \"value\": \"pixels per dot\",\n \"required\": true,\n \"label\": \"Size unit\"\n },\n {\n \"name\": \"alRad\",\n \"type\": \"float\",\n \"value\": \"30\",\n \"label\": \"Alignment pattern radius %\"\n },\n {\n \"name\": \"moRad\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"label\": \"Module radius %\"\n },\n {\n \"name\": \"moSep\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"label\": \"Module separation %\"\n },\n {\n \"name\": \"padding\",\n \"type\": \"integer\",\n \"value\": \"1\",\n \"required\": true,\n \"label\": \"Padding in dots\",\n \"placeholder\": \"1\"\n },\n {\n \"name\": \"qrCodeColor\",\n \"type\": \"color\",\n \"value\": \"#000000\",\n \"required\": true,\n \"label\": \"QR Code Color\",\n \"placeholder\": \"#000000\"\n },\n {\n \"name\": \"backgroundColor\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"required\": true,\n \"label\": \"Background Color\",\n \"placeholder\": \"#ffffff\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true\n }\n}\n" + }, + "data/resizeImage": { + "app.nim": "import pixie\nimport frameos/types\nimport frameos/utils/image\n\ntype\n AppConfig* = object\n image*: Image\n width*: int\n height*: int\n scalingMode*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): Image =\n let image = newImage(self.appConfig.width, self.appConfig.height)\n var scalingMode = self.appConfig.scalingMode\n if scalingMode == \"contain\" and\n self.appConfig.image.width <= self.appConfig.width and\n self.appConfig.image.height <= self.appConfig.height:\n scalingMode = \"center\"\n image.scaleAndDrawImage(self.appConfig.image, scalingMode)\n return image\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n image: nil,\n width: block:\n var v = 0\n if params.hasKey(\"width\"):\n let n = params{\"width\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n height: block:\n var v = 0\n if params.hasKey(\"height\"):\n let n = params{\"height\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n scalingMode: params{\"scalingMode\"}.getStr(\"contain\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"image\":\n app.appConfig.image = value.asImage()\n of \"width\":\n app.appConfig.width = value.asInt().int\n of \"height\":\n app.appConfig.height = value.asInt().int\n of \"scalingMode\":\n app.appConfig.scalingMode = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Resize\",\n \"description\": \"Scale or stretch an image\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n { \"name\": \"image\", \"type\": \"image\", \"required\": true, \"label\": \"Image\" },\n { \"name\": \"width\", \"type\": \"integer\", \"required\": true, \"label\": \"New Width\", \"placeholder\": \"e.g., 1024\" },\n { \"name\": \"height\", \"type\": \"integer\", \"required\": true, \"label\": \"New Height\", \"placeholder\": \"e.g., 1024\" },\n {\n \"name\": \"scalingMode\",\n \"type\": \"select\",\n \"options\": [\"cover\", \"contain\", \"stretch\", \"center\"],\n \"value\": \"contain\",\n \"required\": true,\n \"label\": \"Scaling mode\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "data/rotateImage": { + "app.nim": "import pixie\nimport frameos/types\n\ntype\n AppConfig* = object\n image*: Image\n rotationDegree*: float\n scalingMode*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): Image =\n let originalImage = self.appConfig.image\n let rotationAngle = degToRad(self.appConfig.rotationDegree).float32\n\n # Calculate the new dimensions after rotation\n let cosAngle = abs(cos(rotationAngle))\n let sinAngle = abs(sin(rotationAngle))\n let newWidth = int(ceil(originalImage.width.float32 * cosAngle +\n originalImage.height.float32 * sinAngle))\n let newHeight = int(ceil(originalImage.width.float32 * sinAngle +\n originalImage.height.float32 * cosAngle))\n\n # Create a new target image with the calculated dimensions\n let targetImage = newImage(newWidth, newHeight)\n # targetImage.fill(self.scene.backgroundColor)\n\n # Calculate the center of the original and target images\n let originalCenterX = originalImage.width.float32 / 2\n let originalCenterY = originalImage.height.float32 / 2\n let targetCenterX = newWidth.float32 / 2\n let targetCenterY = newHeight.float32 / 2\n\n # Create a transformation that translates the image to the center of the target image, rotates it, and then translates it back\n let transform =\n translate(vec2(targetCenterX, targetCenterY)) *\n rotate(rotationAngle) *\n translate(vec2(-originalCenterX, -originalCenterY))\n\n targetImage.draw(\n originalImage,\n transform,\n OverwriteBlend\n )\n\n return targetImage\n\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n image: nil,\n rotationDegree: block:\n var v = 0.0\n if params.hasKey(\"rotationDegree\"):\n let n = params{\"rotationDegree\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"image\":\n app.appConfig.image = value.asImage()\n of \"rotationDegree\":\n app.appConfig.rotationDegree = value.asFloat()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Rotate\",\n \"description\": \"Rotate an image\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n { \"name\": \"image\", \"type\": \"image\", \"required\": true, \"label\": \"Image\" },\n {\n \"name\": \"rotationDegree\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"required\": true,\n \"label\": \"Rotation Degree\",\n \"placeholder\": \"e.g., 45\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "data/rstpSnapshot": { + "app.nim": "import os\nimport pixie\nimport json\nimport math\nimport sequtils\nimport strutils\nimport times\nimport strformat\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/image\nimport frameos/utils/process\n\nconst\n DefaultFfmpegTimeoutSeconds = 15\n MaxFfmpegOutputBytes = 50 * 1024 * 1024\n\ntype\n RtspSnapshotFfmpegRunHook* = proc(command: string, timeoutMs: int): tuple[data: string, exitCode: int]\n\n AppConfig* = object\n url*: string\n timeoutSeconds*: int\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nvar rtspSnapshotFfmpegRunHook*: RtspSnapshotFfmpegRunHook = nil\n\nproc renderError(self: App, context: ExecutionContext, message: string): Image =\n return renderError(\n if context.hasImage: context.image.width else: self.frameConfig.renderWidth(),\n if context.hasImage: context.image.height else: self.frameConfig.renderHeight(),\n message\n )\n\nproc ffmpegTimeoutMs(self: App): int =\n max(1, (if self.appConfig.timeoutSeconds > 0: self.appConfig.timeoutSeconds else: DefaultFfmpegTimeoutSeconds)) * 1000\n\nproc ffmpegArgs(url: string, outputPath: string): seq[string] =\n result = @[\n \"-loglevel\", \"quiet\",\n \"-nostdin\",\n \"-y\",\n \"-threads\", \"1\",\n \"-i\", url,\n \"-an\",\n \"-vframes\", \"1\",\n \"-f\", \"image2\",\n \"-c:v\", \"bmp\",\n outputPath\n ]\n\nproc shellDisplayArg(arg: string): string =\n for ch in arg:\n if ch in {' ', '\\'', '\"', '$', '&', '?', '*', '(', ')', ';', '<', '>', '|', '\\\\'}:\n return quoteShell(arg)\n arg\n\nproc ffmpegCommandForLog(url: string, outputPath = \"\"): string =\n let args = ffmpegArgs(url, outputPath).filterIt(it.len > 0)\n \"ffmpeg \" & args.mapIt(shellDisplayArg(it)).join(\" \")\n\nproc runFfmpeg(command: string, url: string, timeoutMs: int): tuple[data: string, exitCode: int] =\n if rtspSnapshotFfmpegRunHook != nil:\n return rtspSnapshotFfmpegRunHook(command, timeoutMs)\n\n let processResult = runProcessPiped(\n \"ffmpeg\",\n ffmpegArgs(url, \"pipe:1\"),\n timeoutMs = timeoutMs,\n maxOutputBytes = MaxFfmpegOutputBytes\n )\n result.exitCode = processResult.exitCode\n if processResult.timedOut:\n result.exitCode = -1\n return\n if processResult.outputExceeded:\n raise newException(IOError, \"ffmpeg output exceeded \" & $MaxFfmpegOutputBytes & \" bytes\")\n if result.exitCode != 0:\n return\n result.data = processResult.output\n\nproc get*(self: App, context: ExecutionContext): Image =\n try:\n let url = self.appConfig.url\n let command = ffmpegCommandForLog(url, \"pipe:1\")\n let timeoutMs = self.ffmpegTimeoutMs()\n\n if self.frameConfig.debug:\n self.log(%*{\n \"event\": \"ffmpeg:start\",\n \"message\": \"Running: \" & command,\n \"timeoutMs\": timeoutMs\n })\n\n let startedAt = epochTime()\n var (data, exitCode) = runFfmpeg(command, url, timeoutMs)\n let elapsedMs = round((epochTime() - startedAt) * 1000, 3)\n\n if exitCode != 0:\n let reason = if exitCode == -1: \"timeout after \" & $(timeoutMs div 1000) & \"s\" else: \"exit code \" & $exitCode\n self.logError \"ffmpeg failed: \" & reason & \" after \" & $elapsedMs & \"ms\"\n return renderError(self, context, \"ffmpeg failed to run (\" & reason & \")\")\n\n if data.len > MaxFfmpegOutputBytes:\n raise newException(IOError, &\"ffmpeg output exceeded {MaxFfmpegOutputBytes} bytes\")\n\n if self.frameConfig.debug:\n self.log(%*{\n \"event\": \"ffmpeg:done\",\n \"ms\": elapsedMs,\n \"bytes\": data.len\n })\n\n try:\n # Bound the decode to the render target so oversized camera frames\n # cannot exhaust memory; never below the display decode defaults.\n let targetWidth = if context.hasImage: context.image.width else: self.frameConfig.renderWidth()\n let targetHeight = if context.hasImage: context.image.height else: self.frameConfig.renderHeight()\n return decodeImageWithDisplayBounds(\n data,\n maxEdge = max(DisplayDecodeMaxEdge, max(targetWidth, targetHeight)),\n maxPixels = max(DisplayDecodeMaxPixels, targetWidth * targetHeight)\n )\n except CatchableError as decodeErr:\n self.logError \"Failed to decode image: \" & decodeErr.msg\n return renderError(self, context, \"Could not decode image from ffmpeg output\")\n\n except OSError as osErr:\n self.logError \"OS error when starting ffmpeg: \" & osErr.msg\n return renderError(self, context, \"ffmpeg not found or not executable\")\n\n except CatchableError as e:\n self.logError \"Unexpected error: \" & e.msg\n return renderError(self, context, \"An unexpected error occurred\")\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n url: params{\"url\"}.getStr(\"\"),\n timeoutSeconds: block:\n var v = 15\n if params.hasKey(\"timeoutSeconds\"):\n let n = params{\"timeoutSeconds\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"url\":\n app.appConfig.url = value.asString()\n of \"timeoutSeconds\":\n app.appConfig.timeoutSeconds = value.asInt().int\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"RSTP Snapshot\",\n \"description\": \"Take a still image from a webcam feed with ffmpeg\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"apt\": [\"ffmpeg\"],\n \"fields\": [\n {\n \"name\": \"url\",\n \"type\": \"text\",\n \"value\": \"\",\n \"required\": true,\n \"label\": \"RTSP URL\",\n \"placeholder\": \"rstp://domain/cam\"\n },\n {\n \"name\": \"timeoutSeconds\",\n \"type\": \"integer\",\n \"value\": 15,\n \"required\": true,\n \"label\": \"FFmpeg timeout in seconds\",\n \"placeholder\": \"15\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"900\"\n }\n}\n" + }, + "data/unsplash": { + "app.nim": "import pixie\nimport json\nimport uri\nimport strformat\nimport strutils\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/app_images\nimport frameos/utils/http_client\n\ntype\n AppConfig* = object\n search*: string\n orientation*: string\n saveAssets*: string\n metadataStateKey*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc init*(self: App) =\n self.appConfig.search = self.appConfig.search.strip()\n self.appConfig.metadataStateKey = self.appConfig.metadataStateKey.strip()\n\nproc error*(self: App, context: ExecutionContext, message: string): Image =\n self.logError(message)\n result = self.renderErrorForContext(context, message)\n\nproc get*(self: App, context: ExecutionContext): Image =\n self.ensureEmbeddedServiceSettings()\n let apiKey = self.frameConfig.settings{\"unsplash\"}{\"accessKey\"}.getStr\n if apiKey == \"\":\n return self.error(context, \"Please provide an Unsplash API key in the settings.\")\n\n let width = self.contextImageWidth(context)\n let height = self.contextImageHeight(context)\n let search = self.appConfig.search\n let orientation = if self.appConfig.orientation == \"auto\":\n if height > width: \"portrait\"\n elif width > height: \"landscape\"\n else: \"squarish\"\n elif self.appConfig.orientation == \"any\": \"\"\n else: self.appConfig.orientation\n\n try:\n let url = &\"https://api.unsplash.com/photos/random/?orientation={encodeUrl(orientation)}&query={encodeUrl(search)}\"\n if self.frameConfig.debug:\n self.log(&\"API request: {url}\")\n let response = boundedRequestWithHeaders(\n url,\n headers = @[\n (name: \"Authorization\", value: \"Client-ID \" & apiKey),\n (name: \"Accept-Version\", value: \"v1\"),\n (name: \"Content-Type\", value: \"application/json\"),\n ],\n timeoutMs = 60000,\n maxBytes = self.maxHttpResponseBytes(),\n maxSeconds = 60\n )\n\n if response.code != 200:\n try:\n let json = parseJson(response.body)\n let error = json{\"error\"}{\"message\"}.getStr(json{\"error\"}.getStr($json))\n return self.error(context, \"Error making request \" & $response.status & \": \" & error)\n except:\n return self.error(context, \"Error making request \" & $response.status & \": \" & response.body)\n\n let json = parseJson(response.body)\n let imageUrl = json{\"urls\"}{\"raw\"}.getStr\n if imageUrl == \"\":\n return self.error(context, \"No image URL returned from Unsplash.\")\n let realImageUrl = &\"{imageUrl}&w={width}&h={height}&fit=crop&crop=faces,edges\"\n if self.frameConfig.debug:\n self.log(&\"Downloading image: {realImageUrl}\")\n let (downloadedImage, imageData) = self.downloadImageWithDataForContext(\n context,\n realImageUrl,\n maxBytes = self.maxImageResponseBytes(),\n fallbackWidth = width,\n fallbackHeight = height\n )\n\n if self.appConfig.metadataStateKey != \"\":\n let description = json{\"description\"}.getStr(json{\"alt_description\"}.getStr(\"\"))\n self.scene.state[self.appConfig.metadataStateKey] = %*{\n \"source\": \"unsplash\",\n \"search\": search,\n \"orientation\": orientation,\n \"imageUrl\": realImageUrl,\n \"id\": json{\"id\"}.getStr,\n \"title\": json{\"slug\"}.getStr(description),\n \"description\": description,\n \"altDescription\": json{\"alt_description\"}.getStr,\n \"location\": json{\"location\"}{\"name\"}.getStr,\n \"createdAt\": json{\"created_at\"}.getStr,\n \"photoUrl\": json{\"links\"}{\"html\"}.getStr,\n \"author\": json{\"user\"}{\"name\"}.getStr,\n \"authorUsername\": json{\"user\"}{\"username\"}.getStr,\n \"authorUrl\": json{\"user\"}{\"links\"}{\"html\"}.getStr\n }\n\n if imageData.len > 0 and (self.appConfig.saveAssets == \"auto\" or self.appConfig.saveAssets == \"always\"):\n discard self.saveAsset(&\"{search} {width}x{height}\", \".jpg\", imageData, self.appConfig.saveAssets == \"auto\")\n\n result = downloadedImage\n except CatchableError as e:\n return self.error(context, \"Error fetching image from Unsplash: \" & $e.msg)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n search: params{\"search\"}.getStr(\"nature\"),\n orientation: params{\"orientation\"}.getStr(\"auto\"),\n metadataStateKey: params{\"metadataStateKey\"}.getStr(\"\"),\n saveAssets: params{\"saveAssets\"}.getStr(\"auto\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n app_module.init(app_module.App(result))\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"search\":\n app.appConfig.search = value.asString()\n of \"orientation\":\n app.appConfig.orientation = value.asString()\n of \"metadataStateKey\":\n app.appConfig.metadataStateKey = value.asString()\n of \"saveAssets\":\n app.appConfig.saveAssets = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Unsplash\",\n \"description\": \"Random unsplash image\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"settings\": [\"unsplash\"],\n \"fields\": [\n {\n \"name\": \"search\",\n \"type\": \"string\",\n \"value\": \"nature\",\n \"required\": false,\n \"label\": \"Search\",\n \"placeholder\": \"e.g. pineapple, nature, birds, power\"\n },\n {\n \"name\": \"orientation\",\n \"type\": \"select\",\n \"value\": \"auto\",\n \"options\": [\"auto\", \"any\", \"landscape\", \"portrait\", \"squarish\"],\n \"required\": false,\n \"label\": \"Orientation\",\n \"placeholder\": \"landscape, portrait, square\"\n },\n {\n \"name\": \"metadataStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Metadata state key\",\n \"hint\": \"Enter a state key to persist metadata about the image. Stores: {search, title, description, location, author, links, ...}.\"\n },\n {\n \"name\": \"saveAssets\",\n \"type\": \"select\",\n \"value\": \"auto\",\n \"options\": [\"auto\", \"always\", \"never\"],\n \"label\": \"Save asset\",\n \"hint\": \"Save the generated image to disk as an asset. It'll be placed into the frame's assets folder.\\n\\nYou can later use the 'Local image' app to view saved assets.\\n\\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved.\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"900\"\n }\n}\n" + }, + "data/weather": { + "app.nim": "import std/json\nimport std/strformat\nimport std/strutils\nimport std/times\nimport std/uri\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/http_client\n\ntype\n AppConfig* = object\n location*: string\n date*: string\n forecastDays*: int\n temperatureUnit*: string\n windSpeedUnit*: string\n precipitationUnit*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc fetchJson(self: App, url: string): JsonNode =\n parseJson(boundedGetContent(url, maxBytes = self.maxHttpResponseBytes()))\n\nproc buildError(location: string, message: string): JsonNode =\n %*{\n \"location\": location,\n \"error\": message\n }\n\nproc get*(self: App, context: ExecutionContext): JsonNode =\n if self.appConfig.location.len == 0:\n return buildError(\"\", \"Location is required.\")\n\n let requestedDate = if self.appConfig.date.len > 0:\n self.appConfig.date\n else:\n now().format(\"yyyy-MM-dd\")\n\n let encodedLocation = encodeUrl(self.appConfig.location)\n let geocodeUrl = fmt\"https://geocoding-api.open-meteo.com/v1/search?name={encodedLocation}&count=1&language=en&format=json\"\n\n try:\n let geocodeJson = self.fetchJson(geocodeUrl)\n if not geocodeJson.hasKey(\"results\") or geocodeJson[\"results\"].len == 0:\n return buildError(self.appConfig.location, \"No matching locations found.\")\n\n let resultNode = geocodeJson[\"results\"][0]\n let latitude = resultNode[\"latitude\"].getFloat\n let longitude = resultNode[\"longitude\"].getFloat\n let defaultTimezone = if resultNode.hasKey(\"timezone\"):\n resultNode[\"timezone\"].getStr\n else:\n \"auto\"\n let timezone = if self.frameConfig.timeZone.len > 0:\n self.frameConfig.timeZone\n else:\n defaultTimezone\n\n var params = @[\n \"latitude=\" & $latitude,\n \"longitude=\" & $longitude,\n \"current_weather=true\",\n \"hourly=temperature_2m,apparent_temperature,precipitation,weathercode,windspeed_10m,winddirection_10m\",\n \"daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weathercode,sunrise,sunset,windspeed_10m_max\",\n \"timezone=\" & encodeUrl(timezone),\n \"temperature_unit=\" & self.appConfig.temperatureUnit,\n \"windspeed_unit=\" & self.appConfig.windSpeedUnit,\n \"precipitation_unit=\" & self.appConfig.precipitationUnit\n ]\n\n if self.appConfig.date.len > 0:\n params.add(\"start_date=\" & requestedDate)\n params.add(\"end_date=\" & requestedDate)\n else:\n params.add(\"forecast_days=\" & $max(1, min(16, self.appConfig.forecastDays)))\n\n let forecastUrl = \"https://api.open-meteo.com/v1/forecast?\" & params.join(\"&\")\n let forecastJson = self.fetchJson(forecastUrl)\n\n var locationNode = %*{\n \"name\": resultNode[\"name\"].getStr,\n \"latitude\": latitude,\n \"longitude\": longitude,\n \"timezone\": timezone\n }\n\n if resultNode.hasKey(\"country\"):\n locationNode[\"country\"] = %*resultNode[\"country\"].getStr\n if resultNode.hasKey(\"country_code\"):\n locationNode[\"countryCode\"] = %*resultNode[\"country_code\"].getStr\n if resultNode.hasKey(\"admin1\"):\n locationNode[\"admin1\"] = %*resultNode[\"admin1\"].getStr\n if resultNode.hasKey(\"admin2\"):\n locationNode[\"admin2\"] = %*resultNode[\"admin2\"].getStr\n\n return %*{\n \"provider\": \"open-meteo\",\n \"forecastModes\": [\"current\", \"hourly\", \"daily\"],\n \"date\": requestedDate,\n \"location\": locationNode,\n \"forecast\": forecastJson\n }\n except CatchableError as err:\n return buildError(self.appConfig.location, err.msg)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n location: params{\"location\"}.getStr(\"\"),\n date: params{\"date\"}.getStr(\"\"),\n forecastDays: block:\n var v = 1\n if params.hasKey(\"forecastDays\"):\n let n = params{\"forecastDays\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n temperatureUnit: params{\"temperatureUnit\"}.getStr(\"celsius\"),\n windSpeedUnit: params{\"windSpeedUnit\"}.getStr(\"kmh\"),\n precipitationUnit: params{\"precipitationUnit\"}.getStr(\"mm\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"location\":\n app.appConfig.location = value.asString()\n of \"date\":\n app.appConfig.date = value.asString()\n of \"forecastDays\":\n app.appConfig.forecastDays = value.asInt().int\n of \"temperatureUnit\":\n app.appConfig.temperatureUnit = value.asString()\n of \"windSpeedUnit\":\n app.appConfig.windSpeedUnit = value.asString()\n of \"precipitationUnit\":\n app.appConfig.precipitationUnit = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Weather (Open-Meteo)\",\n \"description\": \"Fetch current, hourly, and daily forecasts from Open-Meteo for a location and date.\",\n \"category\": \"data\",\n \"version\": \"1.1.0\",\n \"fields\": [\n {\n \"name\": \"location\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": true,\n \"label\": \"Location\",\n \"placeholder\": \"City, state, or country\"\n },\n {\n \"name\": \"date\",\n \"type\": \"date\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Date\",\n \"placeholder\": \"2024-01-30\"\n },\n {\n \"name\": \"forecastDays\",\n \"type\": \"integer\",\n \"value\": \"1\",\n \"required\": false,\n \"label\": \"Forecast days (1-16)\",\n \"hint\": \"How many days of hourly and daily forecast to fetch. Ignored when a specific date is set.\",\n \"showIf\": [{ \"field\": \"date\", \"operator\": \"empty\" }]\n },\n {\n \"name\": \"temperatureUnit\",\n \"type\": \"select\",\n \"options\": [\"celsius\", \"fahrenheit\"],\n \"value\": \"celsius\",\n \"required\": true,\n \"label\": \"Temperature unit\"\n },\n {\n \"name\": \"windSpeedUnit\",\n \"type\": \"select\",\n \"options\": [\"kmh\", \"ms\", \"mph\", \"kn\"],\n \"value\": \"kmh\",\n \"required\": true,\n \"label\": \"Wind speed unit\"\n },\n {\n \"name\": \"precipitationUnit\",\n \"type\": \"select\",\n \"options\": [\"mm\", \"inch\"],\n \"value\": \"mm\",\n \"required\": true,\n \"label\": \"Precipitation unit\"\n }\n ],\n \"output\": [\n {\n \"name\": \"weather\",\n \"type\": \"json\",\n \"example\": \"{\\n \\\"provider\\\": \\\"open-meteo\\\",\\n \\\"forecastModes\\\": [\\n \\\"current\\\",\\n \\\"hourly\\\",\\n \\\"daily\\\"\\n ],\\n \\\"date\\\": \\\"2026-01-18\\\",\\n \\\"location\\\": {\\n \\\"name\\\": \\\"Brussels\\\",\\n \\\"latitude\\\": 50.85045,\\n \\\"longitude\\\": 4.34878,\\n \\\"timezone\\\": \\\"auto\\\",\\n \\\"country\\\": \\\"Belgium\\\",\\n \\\"countryCode\\\": \\\"BE\\\",\\n \\\"admin1\\\": \\\"Brussels Capital\\\",\\n \\\"admin2\\\": \\\"Bruxelles-Capitale\\\"\\n },\\n \\\"forecast\\\": {\\n \\\"latitude\\\": 50.854,\\n \\\"longitude\\\": 4.35,\\n \\\"generationtime_ms\\\": 0.3679990768432617,\\n \\\"utc_offset_seconds\\\": 3600,\\n \\\"timezone\\\": \\\"Europe/Brussels\\\",\\n \\\"timezone_abbreviation\\\": \\\"GMT+1\\\",\\n \\\"elevation\\\": 27,\\n \\\"current_weather_units\\\": {\\n \\\"time\\\": \\\"iso8601\\\",\\n \\\"interval\\\": \\\"seconds\\\",\\n \\\"temperature\\\": \\\"\\\\u00b0C\\\",\\n \\\"windspeed\\\": \\\"km/h\\\",\\n \\\"winddirection\\\": \\\"\\\\u00b0\\\",\\n \\\"is_day\\\": \\\"\\\",\\n \\\"weathercode\\\": \\\"wmo code\\\"\\n },\\n \\\"current_weather\\\": {\\n \\\"time\\\": \\\"2026-01-18T22:15\\\",\\n \\\"interval\\\": 900,\\n \\\"temperature\\\": 5.7,\\n \\\"windspeed\\\": 5.4,\\n \\\"winddirection\\\": 132,\\n \\\"is_day\\\": 0,\\n \\\"weathercode\\\": 0\\n },\\n \\\"hourly_units\\\": {\\n \\\"time\\\": \\\"iso8601\\\",\\n \\\"temperature_2m\\\": \\\"\\\\u00b0C\\\",\\n \\\"apparent_temperature\\\": \\\"\\\\u00b0C\\\",\\n \\\"precipitation\\\": \\\"mm\\\",\\n \\\"weathercode\\\": \\\"wmo code\\\",\\n \\\"windspeed_10m\\\": \\\"km/h\\\",\\n \\\"winddirection_10m\\\": \\\"\\\\u00b0\\\"\\n },\\n \\\"hourly\\\": {\\n \\\"time\\\": [\\n \\\"2026-01-18T00:00\\\",\\n \\\"2026-01-18T01:00\\\",\\n \\\"2026-01-18T02:00\\\",\\n \\\"2026-01-18T03:00\\\",\\n \\\"2026-01-18T04:00\\\",\\n \\\"2026-01-18T05:00\\\",\\n \\\"2026-01-18T06:00\\\",\\n \\\"2026-01-18T07:00\\\",\\n \\\"2026-01-18T08:00\\\",\\n \\\"2026-01-18T09:00\\\",\\n \\\"2026-01-18T10:00\\\",\\n \\\"2026-01-18T11:00\\\",\\n \\\"2026-01-18T12:00\\\",\\n \\\"2026-01-18T13:00\\\",\\n \\\"2026-01-18T14:00\\\",\\n \\\"2026-01-18T15:00\\\",\\n \\\"2026-01-18T16:00\\\",\\n \\\"2026-01-18T17:00\\\",\\n \\\"2026-01-18T18:00\\\",\\n \\\"2026-01-18T19:00\\\",\\n \\\"2026-01-18T20:00\\\",\\n \\\"2026-01-18T21:00\\\",\\n \\\"2026-01-18T22:00\\\",\\n \\\"2026-01-18T23:00\\\"\\n ],\\n \\\"temperature_2m\\\": [\\n 9.2,\\n 8.2,\\n 8.3,\\n 8.1,\\n 7.2,\\n 7.1,\\n 7.3,\\n 7.1,\\n 6.7,\\n 7.3,\\n 8.5,\\n 8.5,\\n 9.7,\\n 10.9,\\n 11.1,\\n 11.3,\\n 11.1,\\n 9.7,\\n 8.7,\\n 8,\\n 6.8,\\n 6.2,\\n 5.8,\\n 5.5\\n ],\\n \\\"apparent_temperature\\\": [\\n 7.5,\\n 6.3,\\n 6.5,\\n 6.2,\\n 5.1,\\n 4.8,\\n 4.9,\\n 4.8,\\n 3.7,\\n 4.4,\\n 5.6,\\n 5.6,\\n 6.9,\\n 8.7,\\n 8,\\n 8.6,\\n 8.3,\\n 6.7,\\n 5.9,\\n 5,\\n 3.6,\\n 3.5,\\n 3.3,\\n 2.4\\n ],\\n \\\"precipitation\\\": [\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0,\\n 0\\n ],\\n \\\"weathercode\\\": [\\n 0,\\n 1,\\n 3,\\n 3,\\n 3,\\n 3,\\n 3,\\n 3,\\n 3,\\n 3,\\n 3,\\n 0,\\n 0,\\n 1,\\n 2,\\n 2,\\n 0,\\n 3,\\n 3,\\n 2,\\n 0,\\n 0,\\n 0,\\n 0\\n ],\\n \\\"windspeed_10m\\\": [\\n 4,\\n 3.6,\\n 2.9,\\n 3.6,\\n 5.8,\\n 5,\\n 6.5,\\n 5,\\n 9.4,\\n 9,\\n 8.6,\\n 9,\\n 9.4,\\n 5.8,\\n 9.7,\\n 7.9,\\n 8.6,\\n 9.4,\\n 10.4,\\n 8.6,\\n 9.7,\\n 5.8,\\n 5,\\n 7.2\\n ],\\n \\\"winddirection_10m\\\": [\\n 166,\\n 59,\\n 353,\\n 67,\\n 78,\\n 52,\\n 71,\\n 68,\\n 101,\\n 54,\\n 87,\\n 80,\\n 62,\\n 66,\\n 69,\\n 74,\\n 92,\\n 73,\\n 77,\\n 110,\\n 117,\\n 202,\\n 132,\\n 168\\n ]\\n },\\n \\\"daily_units\\\": {\\n \\\"time\\\": \\\"iso8601\\\",\\n \\\"temperature_2m_max\\\": \\\"\\\\u00b0C\\\",\\n \\\"temperature_2m_min\\\": \\\"\\\\u00b0C\\\",\\n \\\"precipitation_sum\\\": \\\"mm\\\",\\n \\\"weathercode\\\": \\\"wmo code\\\",\\n \\\"sunrise\\\": \\\"iso8601\\\",\\n \\\"sunset\\\": \\\"iso8601\\\",\\n \\\"windspeed_10m_max\\\": \\\"km/h\\\"\\n },\\n \\\"daily\\\": {\\n \\\"time\\\": [\\n \\\"2026-01-18\\\"\\n ],\\n \\\"temperature_2m_max\\\": [\\n 11.3\\n ],\\n \\\"temperature_2m_min\\\": [\\n 5.5\\n ],\\n \\\"precipitation_sum\\\": [\\n 0\\n ],\\n \\\"weathercode\\\": [\\n 3\\n ],\\n \\\"sunrise\\\": [\\n \\\"2026-01-18T08:35\\\"\\n ],\\n \\\"sunset\\\": [\\n \\\"2026-01-18T17:10\\\"\\n ],\\n \\\"windspeed_10m_max\\\": [\\n 10.4\\n ]\\n }\\n }\\n}\"\n }\n ]\n}\n" + }, + "data/wikicommons": { + "app.nim": "import pixie\nimport std/[json, options, random, sequtils, strformat, strutils, times, uri]\nimport frameos/apps\nimport frameos/hal/entropy\nimport frameos/types\nimport frameos/utils/app_images\nimport frameos/utils/http_client\nimport frameos/utils/image\n\nwhen defined(frameosEmbedded):\n import std/math\n\nconst\n CommonsApiUrl = \"https://commons.wikimedia.org/w/api.php\"\n CommonsUserAgent = \"FrameOS Wikimedia Commons app (https://github.com/FrameOS/frameos)\"\n FirstPotdYear = 2008\n\nwhen defined(frameosEmbedded):\n const\n EmbeddedCommonsThumbnailWidth = 900\n EmbeddedMaxDecodePixels = 640_000\n EmbeddedMaxDecodeSide = 1000\n\ntype\n AppConfig* = object\n mode*: string\n submode*: string\n saveAssets*: string\n metadataStateKey*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\n CommonsDate = object\n year: int\n month: int\n day: int\n\n CommonsImage = object\n title: string\n imageUrl: string\n pageUrl: string\n description: string\n author: string\n license: string\n mime: string\n width: int\n height: int\n\nproc init*(self: App) =\n randomizeSafe()\n self.appConfig.mode = self.appConfig.mode.strip()\n self.appConfig.submode = self.appConfig.submode.strip()\n self.appConfig.metadataStateKey = self.appConfig.metadataStateKey.strip()\n\nproc error*(self: App, context: ExecutionContext, message: string): Image =\n self.logError(message)\n result = self.renderErrorForContext(context, message)\n\nproc commonsHeaders(): seq[SimpleHttpHeader] =\n @[\n (name: \"Accept\", value: \"application/json\"),\n (name: \"User-Agent\", value: CommonsUserAgent),\n ]\n\nproc imageHeaders(): seq[SimpleHttpHeader] =\n @[(name: \"User-Agent\", value: CommonsUserAgent)]\n\nproc queryString(params: openArray[(string, string)]): string =\n params.mapIt(encodeUrl(it[0]) & \"=\" & encodeUrl(it[1])).join(\"&\")\n\nproc fetchCommonsJson(self: App, params: openArray[(string, string)]): JsonNode =\n var allParams = @[\n (\"format\", \"json\"),\n (\"formatversion\", \"2\")\n ]\n allParams.add(params)\n let response = boundedRequestWithHeaders(\n CommonsApiUrl & \"?\" & queryString(allParams),\n headers = commonsHeaders(),\n timeoutMs = 60000,\n maxBytes = self.maxHttpResponseBytes(),\n maxSeconds = 60\n )\n if response.code >= 400:\n raise newException(CatchableError, \"Wikimedia Commons API HTTP \" & response.status)\n let body = response.body\n result = parseJson(body)\n if result.hasKey(\"error\"):\n let message = result[\"error\"]{\"info\"}.getStr($result[\"error\"])\n raise newException(CatchableError, \"Wikimedia Commons API error: \" & message)\n\nproc isLeapYear(year: int): bool =\n (year mod 4 == 0 and year mod 100 != 0) or year mod 400 == 0\n\nproc daysInMonth(year: int, month: int): int =\n case month\n of 1, 3, 5, 7, 8, 10, 12: 31\n of 4, 6, 9, 11: 30\n of 2: (if isLeapYear(year): 29 else: 28)\n else: 0\n\nproc todayDate(): CommonsDate =\n let current = now()\n CommonsDate(year: current.year, month: current.month.int, day: current.monthday.int)\n\nproc dateString(date: CommonsDate): string =\n &\"{date.year}-{date.month:02d}-{date.day:02d}\"\n\nproc randomPreviousDate(today: CommonsDate): CommonsDate =\n let year = rand(FirstPotdYear..today.year)\n let maxMonth = if year == today.year: today.month else: 12\n let month = rand(1..maxMonth)\n var maxDay = daysInMonth(year, month)\n if year == today.year and month == today.month:\n maxDay = min(maxDay, today.day)\n CommonsDate(year: year, month: month, day: rand(1..maxDay))\n\nproc randomOnThisDay(today: CommonsDate): CommonsDate =\n let maxYear = max(FirstPotdYear, today.year - 1)\n for _ in 0 ..< 100:\n let year = rand(FirstPotdYear..maxYear)\n if today.day <= daysInMonth(year, today.month):\n return CommonsDate(year: year, month: today.month, day: today.day)\n raise newException(CatchableError, \"No previous Wikimedia Commons picture of the day exists for this date.\")\n\nproc stripHtml(value: string): string =\n var inTag = false\n for ch in value:\n case ch\n of '<':\n inTag = true\n of '>':\n inTag = false\n else:\n if not inTag:\n result.add(ch)\n result = result.replace(\""\", \"\\\"\")\n result = result.replace(\"&\", \"&\")\n result = result.replace(\"'\", \"'\")\n result = result.replace(\"'\", \"'\")\n result = result.replace(\"<\", \"<\")\n result = result.replace(\">\", \">\")\n result = result.strip()\n\nproc metadataValue(imageInfo: JsonNode, key: string): string =\n let metadata = imageInfo{\"extmetadata\"}\n if metadata.kind == JObject and metadata.hasKey(key):\n return metadata[key]{\"value\"}.getStr().stripHtml()\n \"\"\n\nproc imageExtension(image: CommonsImage): string =\n let urlPath = image.imageUrl.split(\"?\")[0].toLowerAscii()\n for ext in [\".jpg\", \".jpeg\", \".png\", \".gif\", \".webp\", \".svg\"]:\n if urlPath.endsWith(ext):\n return if ext == \".jpeg\": \".jpg\" else: ext\n case image.mime\n of \"image/jpeg\": \".jpg\"\n of \"image/png\": \".png\"\n of \"image/gif\": \".gif\"\n of \"image/webp\": \".webp\"\n of \"image/svg+xml\": \".svg\"\n else: \".img\"\n\nproc imageFromPage(page: JsonNode): Option[CommonsImage] =\n let info = page{\"imageinfo\"}{0}\n if info.kind != JObject:\n return none(CommonsImage)\n\n let mime = info{\"mime\"}.getStr()\n if not mime.startsWith(\"image/\"):\n return none(CommonsImage)\n\n let imageUrl = info{\"thumburl\"}.getStr(info{\"url\"}.getStr())\n if imageUrl == \"\":\n return none(CommonsImage)\n let imageWidth = info{\"thumbwidth\"}.getInt(info{\"width\"}.getInt(0))\n let imageHeight = info{\"thumbheight\"}.getInt(info{\"height\"}.getInt(0))\n\n let title = page{\"title\"}.getStr()\n let description = metadataValue(info, \"ImageDescription\")\n result = some(CommonsImage(\n title: title,\n imageUrl: imageUrl,\n pageUrl: info{\"descriptionurl\"}.getStr(),\n description: if description != \"\": description else: metadataValue(info, \"ObjectName\"),\n author: metadataValue(info, \"Artist\"),\n license: metadataValue(info, \"LicenseShortName\"),\n mime: mime,\n width: imageWidth,\n height: imageHeight\n ))\n\nproc firstImageFromQuery(json: JsonNode): CommonsImage =\n let pages = json{\"query\"}{\"pages\"}\n if pages.kind == JArray:\n for page in pages:\n let image = imageFromPage(page)\n if image.isSome:\n return image.get()\n raise newException(CatchableError, \"No supported image returned from Wikimedia Commons.\")\n\nproc fetchPotdImage(self: App, date: CommonsDate, thumbnailWidth: int): CommonsImage =\n firstImageFromQuery(fetchCommonsJson(self, [\n (\"action\", \"query\"),\n (\"generator\", \"images\"),\n (\"titles\", \"Template:Potd/\" & date.dateString()),\n (\"gimlimit\", \"20\"),\n (\"prop\", \"imageinfo\"),\n (\"iiprop\", \"url|mime|size|extmetadata\"),\n (\"iiurlwidth\", $thumbnailWidth)\n ]))\n\nproc fetchRandomImage(self: App, thumbnailWidth: int): CommonsImage =\n firstImageFromQuery(fetchCommonsJson(self, [\n (\"action\", \"query\"),\n (\"generator\", \"random\"),\n (\"grnnamespace\", \"6\"),\n (\"grnlimit\", \"20\"),\n (\"prop\", \"imageinfo\"),\n (\"iiprop\", \"url|mime|size|extmetadata\"),\n (\"iiurlwidth\", $thumbnailWidth)\n ]))\n\nproc normalizedMode(self: App): string =\n case self.appConfig.mode\n of \"\", \"potd\", \"pictureOfTheDay\":\n case self.appConfig.submode\n of \"\", \"day\": \"pictureOfTheDay\"\n of \"onthisday\", \"onThisDay\": \"onThisDay\"\n of \"month\", \"random\", \"randomPotd\", \"randomPictureOfTheDay\": \"randomPictureOfTheDay\"\n else: self.appConfig.mode\n of \"random\": \"randomImage\"\n else: self.appConfig.mode\n\nproc fetchImageForMode(self: App, thumbnailWidth: int): CommonsImage =\n let today = todayDate()\n case self.normalizedMode()\n of \"pictureOfTheDay\":\n self.fetchPotdImage(today, thumbnailWidth)\n of \"onThisDay\":\n self.fetchPotdImage(randomOnThisDay(today), thumbnailWidth)\n of \"randomPictureOfTheDay\":\n var lastError = \"\"\n for _ in 0 ..< 10:\n try:\n return self.fetchPotdImage(randomPreviousDate(today), thumbnailWidth)\n except CatchableError as err:\n lastError = err.msg\n raise newException(CatchableError, \"Could not find a random Wikimedia Commons picture of the day: \" & lastError)\n of \"randomImage\":\n var lastError = \"\"\n for _ in 0 ..< 10:\n try:\n return self.fetchRandomImage(thumbnailWidth)\n except CatchableError as err:\n lastError = err.msg\n raise newException(CatchableError, \"Could not find a random Wikimedia Commons image: \" & lastError)\n else:\n raise newException(ValueError, \"Invalid Wikimedia Commons mode: \" & self.appConfig.mode)\n\nproc thumbnailWidthForContext(width, height: int): int =\n result = max(max(width, height), 1)\n when defined(frameosEmbedded):\n result = min(result, EmbeddedCommonsThumbnailWidth)\n\nwhen defined(frameosEmbedded):\n proc embeddedDecodeDimensions(image: CommonsImage, fallbackWidth, fallbackHeight: int): tuple[width: int, height: int] =\n var width = if image.width > 0: image.width else: fallbackWidth\n var height = if image.height > 0: image.height else: fallbackHeight\n width = max(width, 1)\n height = max(height, 1)\n\n let sideScale =\n if max(width, height) > EmbeddedMaxDecodeSide:\n EmbeddedMaxDecodeSide.float / max(width, height).float\n else:\n 1.0\n let pixelCount = width.float * height.float\n let pixelScale =\n if pixelCount > EmbeddedMaxDecodePixels.float:\n sqrt(EmbeddedMaxDecodePixels.float / pixelCount)\n else:\n 1.0\n let ratio = min(sideScale, pixelScale)\n if ratio < 1.0:\n width = max(1, floor(width.float * ratio).int)\n height = max(1, floor(height.float * ratio).int)\n\n (width, height)\n\nproc downloadCommonsImage(self: App, context: ExecutionContext, image: CommonsImage,\n fallbackWidth, fallbackHeight: int): tuple[image: Image, data: string] =\n when defined(frameosEmbedded):\n let dimensions = embeddedDecodeDimensions(image, fallbackWidth, fallbackHeight)\n let target = newImage(dimensions.width, dimensions.height)\n return downloadImageWithDataInto(\n image.imageUrl,\n target,\n maxBytes = self.maxImageResponseBytes(),\n headers = imageHeaders()\n )\n else:\n return self.downloadImageWithDataForContext(\n context,\n image.imageUrl,\n maxBytes = self.maxImageResponseBytes(),\n headers = imageHeaders(),\n fallbackWidth = fallbackWidth,\n fallbackHeight = fallbackHeight\n )\n\nproc get*(self: App, context: ExecutionContext): Image =\n let width = self.contextImageWidth(context)\n let height = self.contextImageHeight(context)\n\n try:\n let commonsImage = self.fetchImageForMode(thumbnailWidthForContext(width, height))\n\n if self.frameConfig.debug:\n self.log(&\"Downloading Wikimedia Commons image: {commonsImage.imageUrl}\")\n\n let (downloadedImage, imageData) = self.downloadCommonsImage(\n context,\n commonsImage,\n width,\n height\n )\n\n if self.appConfig.metadataStateKey != \"\":\n self.scene.state[self.appConfig.metadataStateKey] = %*{\n \"source\": \"wikimedia-commons\",\n \"mode\": self.normalizedMode(),\n \"title\": commonsImage.title,\n \"description\": commonsImage.description,\n \"author\": commonsImage.author,\n \"license\": commonsImage.license,\n \"pageUrl\": commonsImage.pageUrl,\n \"imageUrl\": commonsImage.imageUrl,\n \"mime\": commonsImage.mime\n }\n\n if imageData.len > 0 and (self.appConfig.saveAssets == \"auto\" or self.appConfig.saveAssets == \"always\"):\n discard self.saveAsset(commonsImage.title.replace(\"File:\", \"\"), commonsImage.imageExtension(),\n imageData, self.appConfig.saveAssets == \"auto\")\n\n result = downloadedImage\n except CatchableError as e:\n return self.error(context, \"Error fetching image from Wikimedia Commons: \" & e.msg)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n mode: params{\"mode\"}.getStr(\"pictureOfTheDay\"),\n saveAssets: params{\"saveAssets\"}.getStr(\"auto\"),\n metadataStateKey: params{\"metadataStateKey\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n app_module.init(app_module.App(result))\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"mode\":\n app.appConfig.mode = value.asString()\n of \"saveAssets\":\n app.appConfig.saveAssets = value.asString()\n of \"metadataStateKey\":\n app.appConfig.metadataStateKey = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Wikimedia Commons\",\n \"description\": \"Images from Wikimedia Commons\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"mode\",\n \"type\": \"select\",\n \"value\": \"pictureOfTheDay\",\n \"options\": [\"pictureOfTheDay\", \"onThisDay\", \"randomPictureOfTheDay\", \"randomImage\"],\n \"required\": false,\n \"label\": \"Mode\",\n \"hint\": \"Choose today's Picture of the Day, the Picture of the Day from this date in a previous year, a random previous Picture of the Day, or a random Commons image.\"\n },\n {\n \"name\": \"saveAssets\",\n \"type\": \"select\",\n \"value\": \"auto\",\n \"options\": [\"auto\", \"always\", \"never\"],\n \"label\": \"Save asset\",\n \"hint\": \"Save the generated image to disk as an asset. It'll be placed into the frame's assets folder.\\n\\nYou can later use the 'Local image' app to view saved assets.\\n\\nIf set to 'auto', the image will be saved if the frame is set to save assets. If set to 'always', the image will always be saved. If set to 'never', the image will never be saved.\"\n },\n {\n \"name\": \"metadataStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Metadata state key\",\n \"placeholder\": \"e.g. wikimediaMetadata\",\n \"hint\": \"Enter a state key to persist metadata about the image. Stores: {title, description, author, license, pageUrl, imageUrl, mime}.\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ],\n \"cache\": {\n \"enabled\": true,\n \"inputEnabled\": true,\n \"durationEnabled\": true,\n \"duration\": \"3600\"\n }\n}\n" + }, + "data/xmlToJson": { + "app.nim": "import frameos/types\nimport std/[json, strtabs, strutils, xmlparser, xmltree]\n\nproc xmlNodeToJson(node: XmlNode): JsonNode =\n case node.kind\n of xnElement:\n var element = newJObject()\n element[\"type\"] = newJString(\"element\")\n element[\"name\"] = newJString(node.tag)\n\n var attributes = newJObject()\n let attrs = node.attrs\n if not attrs.isNil:\n for key, value in pairs(attrs):\n attributes[key] = newJString(value)\n element[\"attributes\"] = attributes\n\n var children = newJArray()\n for child in items(node):\n let childJson = xmlNodeToJson(child)\n if childJson.kind != JNull:\n children.add(childJson)\n element[\"children\"] = children\n\n result = element\n of xnText, xnVerbatimText:\n let textValue = node.text\n if textValue.strip.len == 0:\n result = newJNull()\n else:\n var textNode = newJObject()\n textNode[\"type\"] = newJString(\"text\")\n textNode[\"text\"] = newJString(textValue)\n result = textNode\n of xnCData:\n var cdataNode = newJObject()\n cdataNode[\"type\"] = newJString(\"cdata\")\n cdataNode[\"text\"] = newJString(node.text)\n result = cdataNode\n of xnComment:\n var commentNode = newJObject()\n commentNode[\"type\"] = newJString(\"comment\")\n commentNode[\"text\"] = newJString(node.text)\n result = commentNode\n of xnEntity:\n var entityNode = newJObject()\n entityNode[\"type\"] = newJString(\"entity\")\n entityNode[\"text\"] = newJString(node.text)\n result = entityNode\n\nproc toJsonTree(xml: string): JsonNode =\n let document = parseXml(xml)\n if document.isNil:\n return newJNull()\n\n let jsonDoc = xmlNodeToJson(document)\n if jsonDoc.kind == JNull:\n return newJNull()\n\n result = newJObject()\n result[\"type\"] = newJString(\"document\")\n result[\"root\"] = jsonDoc\n\ntype\n AppConfig* = object\n xml*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): JsonNode =\n try:\n result = toJsonTree(self.appConfig.xml)\n except XmlError as err:\n raise newException(ValueError, \"Failed to parse XML: \" & err.msg)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n xml: params{\"xml\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"xml\":\n app.appConfig.xml = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"XML to JSON\",\n \"description\": \"Convert XML into a JSON tree using Nim's xmlparser\",\n \"category\": \"data\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"xml\",\n \"type\": \"string\",\n \"required\": true,\n \"label\": \"XML\"\n }\n ],\n \"output\": [\n {\n \"name\": \"json\",\n \"type\": \"json\"\n }\n ]\n}\n" + }, + "legacy/clock": { + "app.nim": "import pixie, options, times\n\nimport frameos/types\nimport frameos/utils/font\n\ntype\n AppConfig* = object\n format*: string\n formatCustom*: string\n position*: string\n offsetX*: float\n offsetY*: float\n padding*: float\n fontColor*: Color\n fontSize*: float\n borderColor*: Color\n borderWidth*: int\n\n RenderData* = object\n text*: string\n position*: string\n width*: int\n height*: int\n padding*: float\n fontColor*: Color\n fontSize*: float\n borderColor*: Color\n borderWidth*: int\n\n CachedRender* = ref object\n renderData*: RenderData\n typeset*: Arrangement\n borderTypeset*: Option[Arrangement]\n\n App* = ref object\n nodeId*: NodeId\n scene*: FrameScene\n appConfig*: AppConfig\n frameConfig*: FrameConfig\n typeface*: Typeface\n cachedRender*: Option[CachedRender]\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n let typeface = getDefaultTypeface()\n result = App(\n nodeId: nodeId,\n scene: scene,\n frameConfig: scene.frameConfig,\n appConfig: appConfig,\n typeface: typeface,\n cachedRender: none(CachedRender),\n )\n\nproc `==`(obj1, obj2: RenderData): bool =\n obj1.text == obj2.text and obj1.position == obj2.position and\n obj1.width == obj2.width and obj1.height == obj2.height and\n obj1.padding == obj2.padding and obj1.fontColor == obj2.fontColor and\n obj1.fontSize == obj2.fontSize and obj1.borderColor ==\n obj2.borderColor and obj1.borderWidth == obj2.borderWidth\n\nproc generateTypeset(typeface: Typeface, renderData: RenderData,\n border: bool): Arrangement =\n\n let font = if border:\n newFont(typeface, renderData.fontSize, renderData.borderColor)\n else:\n newFont(typeface, renderData.fontSize, renderData.fontColor)\n\n let hAlign = case renderData.position:\n of \"top-right\", \"center-right\", \"bottom-right\": RightAlign\n of \"top-left\", \"center-left\", \"bottom-left\": LeftAlign\n else: CenterAlign\n let vAlign = case renderData.position:\n of \"top-left\", \"top-center\", \"top-right\": TopAlign\n of \"bottom-left\", \"bottom-center\", \"bottom-right\": BottomAlign\n else: MiddleAlign\n\n result = typeset(\n spans = [newSpan(renderData.text, font)],\n bounds = vec2(renderData.width.toFloat() - 2 * renderData.padding,\n renderData.height.toFloat() - 2 * renderData.padding),\n hAlign = hAlign,\n vAlign = vAlign,\n )\n\nproc run*(self: App, context: ExecutionContext) =\n let text = now().format(case self.appConfig.format:\n of \"custom\": self.appConfig.formatCustom\n else: self.appConfig.format)\n\n let renderData = RenderData(\n text: text,\n position: self.appConfig.position,\n width: context.image.width,\n height: context.image.height,\n padding: self.appConfig.padding,\n fontColor: self.appConfig.fontColor,\n fontSize: self.appConfig.fontSize,\n borderColor: self.appConfig.borderColor,\n borderWidth: self.appConfig.borderWidth,\n )\n\n let cacheMatch = self.cachedRender.isSome and self.cachedRender.get().renderData == renderData\n let textTypeset = if cacheMatch: self.cachedRender.get().typeset\n else: generateTypeset(self.typeface, renderData, false)\n let borderTypeset = if renderData.borderWidth > 0:\n if cacheMatch:\n self.cachedRender.get().borderTypeset\n else: some(generateTypeset(self.typeface, renderData, true))\n else: none(Arrangement)\n\n if not cacheMatch:\n self.cachedRender = some(CachedRender(\n renderData: renderData,\n typeset: textTypeset,\n borderTypeset: borderTypeset,\n ))\n\n if renderData.borderWidth > 0 and borderTypeset.isSome:\n context.image.strokeText(\n borderTypeset.get(),\n translate(vec2(\n renderData.padding + self.appConfig.offsetX,\n renderData.padding + self.appConfig.offsetY)),\n strokeWidth = float(renderData.borderWidth)\n )\n context.image.fillText(\n textTypeset,\n translate(vec2(renderData.padding + self.appConfig.offsetX,\n renderData.padding + self.appConfig.offsetY))\n )\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n format: params{\"format\"}.getStr(\"HH:mm:ss\"),\n formatCustom: params{\"formatCustom\"}.getStr(\"\"),\n position: params{\"position\"}.getStr(\"center-center\"),\n offsetX: block:\n var v = 0.0\n if params.hasKey(\"offsetX\"):\n let n = params{\"offsetX\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n offsetY: block:\n var v = 0.0\n if params.hasKey(\"offsetY\"):\n let n = params{\"offsetY\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n padding: block:\n var v = 10.0\n if params.hasKey(\"padding\"):\n let n = params{\"padding\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n fontColor: block:\n var v: Color = parseHtmlColor(\"#ffffff\")\n if params.hasKey(\"fontColor\"):\n let n = params{\"fontColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n fontSize: block:\n var v = 32.0\n if params.hasKey(\"fontSize\"):\n let n = params{\"fontSize\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n borderColor: block:\n var v: Color = parseHtmlColor(\"#000000\")\n if params.hasKey(\"borderColor\"):\n let n = params{\"borderColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n borderWidth: block:\n var v = 2\n if params.hasKey(\"borderWidth\"):\n let n = params{\"borderWidth\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"format\":\n app.appConfig.format = value.asString()\n of \"formatCustom\":\n app.appConfig.formatCustom = value.asString()\n of \"position\":\n app.appConfig.position = value.asString()\n of \"offsetX\":\n app.appConfig.offsetX = value.asFloat()\n of \"offsetY\":\n app.appConfig.offsetY = value.asFloat()\n of \"padding\":\n app.appConfig.padding = value.asFloat()\n of \"fontColor\":\n app.appConfig.fontColor = value.asColor()\n of \"fontSize\":\n app.appConfig.fontSize = value.asFloat()\n of \"borderColor\":\n app.appConfig.borderColor = value.asColor()\n of \"borderWidth\":\n app.appConfig.borderWidth = value.asInt().int\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Clock (legacy)\",\n \"description\": \"Overlay current time on the image\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"markdown\": \"[Date format syntax](https://nim-lang.org/2.0.2/times.html)\"\n },\n {\n \"name\": \"format\",\n \"type\": \"select\",\n \"options\": [\"yyyy-MM-dd\", \"yyyy-MM-dd HH:mm:ss\", \"HH:mm:ss:fff\", \"HH:mm:ss\", \"HH:mm\", \"custom\"],\n \"value\": \"HH:mm:ss\",\n \"required\": true,\n \"label\": \"Format\",\n \"placeholder\": \"HH:mm:ss\"\n },\n {\n \"name\": \"formatCustom\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Custom format\",\n \"placeholder\": \"\"\n },\n {\n \"name\": \"position\",\n \"type\": \"select\",\n \"options\": [\n \"top-left\",\n \"top-center\",\n \"top-right\",\n \"center-left\",\n \"center-center\",\n \"center-right\",\n \"bottom-left\",\n \"bottom-center\",\n \"bottom-right\"\n ],\n \"value\": \"center-center\",\n \"required\": true,\n \"label\": \"Position\",\n \"placeholder\": \"center-center\"\n },\n {\n \"name\": \"offsetX\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"required\": true,\n \"label\": \"Offset X\",\n \"placeholder\": \"0\"\n },\n {\n \"name\": \"offsetY\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"required\": true,\n \"label\": \"Offset Y\",\n \"placeholder\": \"0\"\n },\n {\n \"name\": \"padding\",\n \"type\": \"float\",\n \"value\": \"10\",\n \"required\": true,\n \"label\": \"Padding\",\n \"placeholder\": \"10\"\n },\n {\n \"name\": \"fontColor\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"required\": true,\n \"label\": \"Font Color\",\n \"placeholder\": \"#ffffff\"\n },\n {\n \"name\": \"fontSize\",\n \"type\": \"float\",\n \"value\": \"32\",\n \"required\": true,\n \"label\": \"Font Size\",\n \"placeholder\": \"32\"\n },\n {\n \"name\": \"borderColor\",\n \"type\": \"color\",\n \"value\": \"#000000\",\n \"required\": true,\n \"label\": \"Border Color\",\n \"placeholder\": \"#000000\"\n },\n {\n \"name\": \"borderWidth\",\n \"type\": \"integer\",\n \"value\": \"2\",\n \"required\": true,\n \"label\": \"Border width\",\n \"placeholder\": \"2\"\n }\n ]\n}\n" + }, + "legacy/downloadImage": { + "app.nim": "import json\nimport pixie\nimport times\nimport options\nimport frameos/utils/image\nimport frameos/types\nimport frameos/apps\n\ntype\n AppConfig* = object\n url*: string\n scalingMode*: string\n cacheSeconds*: float\n\n App* = ref object\n nodeId*: NodeId\n scene*: FrameScene\n appConfig*: AppConfig\n frameConfig*: FrameConfig\n\n cacheExpiry: float\n cachedImage: Option[Image]\n cachedUrl: string\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n result = App(\n nodeId: nodeId,\n scene: scene,\n frameConfig: scene.frameConfig,\n appConfig: appConfig,\n cachedImage: none(Image),\n cacheExpiry: 0.0,\n cachedUrl: \"\",\n )\n\nproc log*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": \"legacy/downloadImage:log\", \"message\": message})\n\nproc error*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": \"legacy/downloadImage:error\", \"error\": message})\n\nproc run*(self: App, context: ExecutionContext) =\n try:\n let url = self.appConfig.url\n\n var downloadedImage: Option[Image] = none(Image)\n if self.appConfig.cacheSeconds > 0 and self.cachedImage.isSome and\n self.cacheExpiry > epochTime() and self.cachedUrl == url:\n downloadedImage = self.cachedImage\n else:\n downloadedImage = some(downloadImage(url, maxBytes = self.frameConfig.maxImageResponseBytes()))\n if self.appConfig.cacheSeconds > 0:\n self.cachedImage = downloadedImage\n self.cachedUrl = url\n self.cacheExpiry = epochTime() + self.appConfig.cacheSeconds\n\n if context.image.width > 0 and context.image.height > 0:\n scaleAndDrawImage(context.image, downloadedImage.get(), self.appConfig.scalingMode)\n except:\n self.error \"An error occurred while downloading the image.\"\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n url: params{\"url\"}.getStr(\"\"),\n scalingMode: params{\"scalingMode\"}.getStr(\"cover\"),\n cacheSeconds: block:\n var v = 3600.0\n if params.hasKey(\"cacheSeconds\"):\n let n = params{\"cacheSeconds\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"url\":\n app.appConfig.url = value.asString()\n of \"scalingMode\":\n app.appConfig.scalingMode = value.asString()\n of \"cacheSeconds\":\n app.appConfig.cacheSeconds = value.asFloat()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Download Image (legacy)\",\n \"description\": \"Download image from an URL\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"url\",\n \"type\": \"text\",\n \"value\": \"\",\n \"required\": true,\n \"label\": \"Image URL\",\n \"placeholder\": \"https://domain/image\"\n },\n {\n \"name\": \"scalingMode\",\n \"type\": \"select\",\n \"options\": [\"cover\", \"contain\", \"stretch\", \"center\"],\n \"value\": \"cover\",\n \"required\": true,\n \"label\": \"Scaling mode\"\n },\n {\n \"name\": \"cacheSeconds\",\n \"type\": \"float\",\n \"value\": \"3600\",\n \"required\": false,\n \"label\": \"Seconds to cache the result\",\n \"placeholder\": \"Default: 3600 (1h). Use 0 for no cache\"\n }\n ]\n}\n" + }, + "legacy/frameOSGallery": { + "app.nim": "import pixie, times, options, json, strformat\nimport frameos/utils/image\nimport frameos/types\nimport frameos/apps\n\nconst BASE_URL = \"https://gallery.frameos.net/image\"\n\ntype\n AppConfig* = object\n category*: string\n scalingMode*: string\n cacheSeconds*: float\n\n App* = ref object\n nodeId*: NodeId\n scene*: FrameScene\n appConfig*: AppConfig\n frameConfig*: FrameConfig\n\n cacheExpiry: float\n cachedImage: Option[Image]\n cachedUrl: string\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n result = App(\n nodeId: nodeId,\n scene: scene,\n frameConfig: scene.frameConfig,\n appConfig: appConfig,\n cachedImage: none(Image),\n cacheExpiry: 0.0,\n cachedUrl: \"\",\n )\n\nproc run*(self: App, context: ExecutionContext) =\n let url = &\"{BASE_URL}?category={self.appConfig.category}\"\n\n self.scene.logger.log(%*{\"event\": \"legacy/frameOSGallery\", \"category\": self.appConfig.category})\n\n var downloadedImage: Option[Image] = none(Image)\n if self.appConfig.cacheSeconds > 0 and self.cachedImage.isSome and self.cacheExpiry > epochTime() and\n self.cachedUrl == url:\n downloadedImage = self.cachedImage\n else:\n downloadedImage = some(downloadImage(url, maxBytes = self.frameConfig.maxImageResponseBytes()))\n if self.appConfig.cacheSeconds > 0:\n self.cachedImage = downloadedImage\n self.cachedUrl = url\n self.cacheExpiry = epochTime() + self.appConfig.cacheSeconds\n\n if context.image.width > 0 and context.image.height > 0:\n scaleAndDrawImage(context.image, downloadedImage.get(), self.appConfig.scalingMode)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n category: params{\"category\"}.getStr(\"cute\"),\n scalingMode: params{\"scalingMode\"}.getStr(\"cover\"),\n cacheSeconds: block:\n var v = 3600.0\n if params.hasKey(\"cacheSeconds\"):\n let n = params{\"cacheSeconds\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"category\":\n app.appConfig.category = value.asString()\n of \"scalingMode\":\n app.appConfig.scalingMode = value.asString()\n of \"cacheSeconds\":\n app.appConfig.cacheSeconds = value.asFloat()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"FrameOS Gallery (legacy)\",\n \"description\": \"Random image from the FrameOS gallery\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"settings\": [\"frameOS\"],\n \"fields\": [\n {\n \"markdown\": \"[Click here](https://gallery.frameos.net/) to see all the galleries.\"\n },\n {\n \"name\": \"category\",\n \"type\": \"select\",\n \"options\": [\"building-art-styles\", \"cute\", \"cyberpunk-europe\", \"masterpieces\", \"space-gallery\", \"space-odyssey\"],\n \"value\": \"cute\",\n \"required\": false,\n \"label\": \"Category\"\n },\n {\n \"name\": \"scalingMode\",\n \"type\": \"select\",\n \"options\": [\"cover\", \"contain\", \"stretch\", \"center\"],\n \"value\": \"cover\",\n \"required\": true,\n \"label\": \"Scaling mode\"\n },\n {\n \"name\": \"cacheSeconds\",\n \"type\": \"float\",\n \"value\": \"3600\",\n \"required\": false,\n \"label\": \"Seconds to cache the result\",\n \"placeholder\": \"Default: 3600 (1h). Use 0 for no cache\"\n }\n ]\n}\n" + }, + "legacy/haSensor": { + "app.nim": "import json, strformat, options, times, strutils, httpclient\nimport frameos/types\nimport frameos/apps\nimport frameos/utils/http_client\n\nconst\n RequestTimeoutMs = 10000\n MaxResponseSeconds = 15.0\n\ntype\n AppConfig* = object\n entityId*: string\n stateKey*: string\n cacheSeconds*: float\n debug*: bool\n\n App* = ref object\n nodeId*: NodeId\n scene*: FrameScene\n appConfig*: AppConfig\n frameConfig*: FrameConfig\n lastFetchAt*: float\n json: Option[JsonNode]\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n result = App(\n nodeId: nodeId,\n scene: scene,\n appConfig: appConfig,\n frameConfig: scene.frameConfig,\n lastFetchAt: 0,\n json: none(JsonNode),\n )\n\nproc log*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": \"legacy/haSensor:log\", \"message\": message})\n\nproc error*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": \"legacy/haSensor:error\", \"error\": message})\n\nproc run*(self: App, context: ExecutionContext) =\n let haUrl = self.frameConfig.settings{\"homeAssistant\"}{\"url\"}.getStr\n if haUrl == \"\":\n self.error(\"Please provide a Home Assistant URL in the settings.\")\n return\n let accessToken = self.frameConfig.settings{\"homeAssistant\"}{\n \"accessToken\"}.getStr\n if accessToken == \"\":\n self.error(\"Please provide a Home Assistant access token in the settings.\")\n return\n\n let headers = newHttpHeaders([\n (\"Authorization\", \"Bearer \" & accessToken),\n (\"Accept\", \"application/json\"),\n (\"Accept-Encoding\", \"identity\"),\n (\"Connection\", \"close\")\n ])\n var slashlessUrl = haUrl\n slashlessUrl.removeSuffix(\"/\")\n let url = &\"{slashlessUrl}/api/states/{self.appConfig.entityId}\"\n if self.appConfig.debug:\n self.log(\"Fetching Home Assistant status from \" & url)\n\n if self.json.isNone or self.lastFetchAt == 0 or self.lastFetchAt +\n self.appConfig.cacheSeconds < epochTime():\n try:\n let responseBody = boundedGetContent(url,\n headers = headers,\n timeoutMs = RequestTimeoutMs,\n maxBytes = self.frameConfig.maxHttpResponseBytes(),\n maxSeconds = MaxResponseSeconds)\n self.json = some(parseJson(responseBody))\n self.lastFetchAt = epochTime()\n except CatchableError as e:\n self.error \"Error fetching Home Assistant status: \" & $e.msg\n return\n\n if self.json.isSome:\n let stateKey = if self.appConfig.stateKey ==\n \"\": \"state\" else: self.appConfig.stateKey\n self.scene.state[stateKey] = self.json.get()\n if self.appConfig.debug:\n self.log($self.scene.state[stateKey])\n else:\n self.error \"No JSON response from Home Assistant\"\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n entityId: params{\"entityId\"}.getStr(\"\"),\n stateKey: params{\"stateKey\"}.getStr(\"sensor\"),\n cacheSeconds: block:\n var v = 60.0\n if params.hasKey(\"cacheSeconds\"):\n let n = params{\"cacheSeconds\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n debug: block:\n var v = false\n if params.hasKey(\"debug\"):\n let n = params{\"debug\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"entityId\":\n app.appConfig.entityId = value.asString()\n of \"stateKey\":\n app.appConfig.stateKey = value.asString()\n of \"cacheSeconds\":\n app.appConfig.cacheSeconds = value.asFloat()\n of \"debug\":\n app.appConfig.debug = value.asBool()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"HA Sensor (legacy)\",\n \"description\": \"Store the state of a Home Assistant entity in the scene's state\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"settings\": [\"homeAssistant\"],\n \"fields\": [\n {\n \"markdown\": \"Find the [entity id here](http://homeassistant.local:8123/config/entities). Then use code like:\\n\\nscene.state{\\\"water_heater\\\"}{\\\"state\\\"}.getStr\"\n },\n {\n \"name\": \"entityId\",\n \"type\": \"text\",\n \"required\": true,\n \"label\": \"Entity ID\",\n \"placeholder\": \"Home Assistant entity name. Example: sensor.home_solar_percentage or water_heater.hot_water\"\n },\n {\n \"name\": \"stateKey\",\n \"type\": \"text\",\n \"required\": true,\n \"label\": \"State key to store the json in\",\n \"value\": \"sensor\",\n \"placeholder\": \"\"\n },\n {\n \"name\": \"cacheSeconds\",\n \"type\": \"float\",\n \"value\": \"60\",\n \"required\": false,\n \"label\": \"Seconds to cache the result\",\n \"placeholder\": \"Default: 60. Use 0 for no cache\"\n },\n {\n \"name\": \"debug\",\n \"type\": \"boolean\",\n \"value\": \"false\",\n \"required\": false,\n \"label\": \"Debug logging\"\n }\n ]\n}\n" + }, + "legacy/localImage": { + "app.nim": "import json\nimport strformat\nimport pixie\nimport times\nimport options\nimport algorithm\nimport frameos/utils/image\nimport frameos/types\nimport os, strutils\nimport std/random\n\nlet imageExtensions = [\".jpg\", \".jpeg\", \".png\", \".gif\", \".bmp\", \".qoi\", \".ppm\", \".svg\"]\n\ntype\n AppConfig* = object\n path*: string\n order*: string\n scalingMode*: string\n seconds*: float\n counterStateKey*: string\n\n App* = ref object\n nodeId*: NodeId\n scene*: FrameScene\n appConfig*: AppConfig\n frameConfig*: FrameConfig\n lastExpiry: float\n lastImage: Option[Image]\n cachedUrl: string\n images: seq[string]\n counter: int\n\n# Function to check if a file is an image\nproc isImage(file: string): bool =\n for ext in imageExtensions:\n if file.endsWith(ext):\n return true\n return false\n\nproc compareImagePaths(a, b: string): int =\n result = cmpIgnoreCase(a, b)\n if result == 0:\n result = cmp(a, b)\n\nproc sortImagesAlphabetically(images: var seq[string]) =\n images.sort(compareImagePaths)\n\n# Function to return all images in a folder\nproc getImagesInFolder(folder: string): seq[string] =\n # if folder is a file\n if fileExists(folder):\n if isImage(folder):\n return @[\"\"]\n return @[]\n\n var images: seq[string] = @[]\n for file in walkDirRec(folder, relative = true):\n if isImage(file):\n images.add(file)\n return images\n\nproc log*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": &\"{self.nodeId}:log\", \"message\": message})\n\nproc error*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": &\"{self.nodeId}:error\", \"error\": message})\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n result = App(\n nodeId: nodeId,\n scene: scene,\n frameConfig: scene.frameConfig,\n appConfig: appConfig,\n lastImage: none(Image),\n lastExpiry: 0.0,\n images: getImagesInFolder(appConfig.path),\n counter: 0\n )\n result.log(\"Found \" & $result.images.len & \" images in the folder: \" & appConfig.path)\n result.log(result.images.join(\", \"))\n if appConfig.order == \"random\":\n randomize()\n result.images.shuffle()\n else:\n result.images.sortImagesAlphabetically()\n if appConfig.order != \"random\" and appConfig.counterStateKey != \"\" and result.images.len > 0:\n result.counter = scene.state{appConfig.counterStateKey}.getInt() mod result.images.len\n\nproc run*(self: App, context: ExecutionContext) =\n if self.images.len == 0:\n context.image.draw(\n renderError(context.image.width, context.image.height, \"No images found in: \" & self.appConfig.path)\n )\n self.error \"No images found in: \" & self.appConfig.path\n return\n\n var nextImage: Option[Image] = none(Image)\n if self.appConfig.seconds > 0 and self.lastImage.isSome and self.lastExpiry > epochTime():\n nextImage = self.lastImage\n else:\n let path = joinPath(self.appConfig.path, self.images[self.counter])\n self.log(\"Loading image: \" & path)\n self.counter = (self.counter + 1) mod len(self.images)\n if self.appConfig.counterStateKey != \"\":\n self.scene.state[self.appConfig.counterStateKey] = %*(self.counter)\n\n try:\n # SVG has no dimensions probe; everything else reads memory-bounded.\n if path.endsWith(\".svg\"):\n nextImage = some(readImage(path))\n else:\n nextImage = some(readImageWithDisplayBounds(path))\n if self.appConfig.seconds > 0:\n self.lastImage = nextImage\n self.lastExpiry = epochTime() + self.appConfig.seconds\n except CatchableError as e:\n nextImage = some(renderError(context.image.width, context.image.height,\n \"An error occurred while loading the image: \" & path & \"\\n\" & e.msg))\n self.error \"An error occurred while loading the image: \" & path & \"\\n\" & e.msg\n\n context.image.scaleAndDrawImage(nextImage.get(), self.appConfig.scalingMode)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n path: params{\"path\"}.getStr(\"/srv/images\"),\n order: params{\"order\"}.getStr(\"random\"),\n seconds: block:\n var v = 900.0\n if params.hasKey(\"seconds\"):\n let n = params{\"seconds\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n scalingMode: params{\"scalingMode\"}.getStr(\"cover\"),\n counterStateKey: params{\"counterStateKey\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"path\":\n app.appConfig.path = value.asString()\n of \"order\":\n app.appConfig.order = value.asString()\n of \"seconds\":\n app.appConfig.seconds = value.asFloat()\n of \"scalingMode\":\n app.appConfig.scalingMode = value.asString()\n of \"counterStateKey\":\n app.appConfig.counterStateKey = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Local Image (legacy)\",\n \"description\": \"Show an image from the SD card\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"path\",\n \"type\": \"text\",\n \"value\": \"/srv/images\",\n \"required\": true,\n \"label\": \"Image filename or folder\",\n \"placeholder\": \"/srv/images\"\n },\n {\n \"name\": \"order\",\n \"type\": \"select\",\n \"options\": [\"random\", \"alphabetical\"],\n \"value\": \"random\",\n \"required\": true,\n \"label\": \"Order of images\"\n },\n {\n \"name\": \"seconds\",\n \"type\": \"float\",\n \"value\": \"900\",\n \"required\": false,\n \"label\": \"Seconds to show one image\",\n \"placeholder\": \"Default: 900 (15min)\"\n },\n {\n \"name\": \"scalingMode\",\n \"type\": \"select\",\n \"options\": [\"cover\", \"contain\", \"stretch\", \"center\"],\n \"value\": \"cover\",\n \"required\": true,\n \"label\": \"Scaling mode\"\n },\n {\n \"name\": \"counterStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Optional state key for persistence\"\n }\n ]\n}\n" + }, + "legacy/openai": { + "app.nim": "import pixie\nimport times\nimport options\nimport json\nimport strformat\nimport httpclient\nimport base64\nimport frameos/utils/image\nimport frameos/types\n\ntype\n AppConfig* = object\n prompt*: string\n model*: string\n scalingMode*: string\n cacheSeconds*: float\n style*: string\n quality*: string\n size*: string\n\n App* = ref object\n nodeId*: NodeId\n scene*: FrameScene\n appConfig*: AppConfig\n frameConfig*: FrameConfig\n\n cacheExpiry: float\n cachedImage: Option[Image]\n cachedPrompt: string\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n result = App(\n nodeId: nodeId,\n scene: scene,\n frameConfig: scene.frameConfig,\n appConfig: appConfig,\n cachedImage: none(Image),\n cacheExpiry: 0.0,\n cachedPrompt: \"\",\n )\n\nproc log*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": &\"{self.nodeId}:log\", \"message\": message})\n\nproc error*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": &\"{self.nodeId}:error\", \"error\": message})\n\nproc run*(self: App, context: ExecutionContext) =\n let prompt = self.appConfig.prompt\n if prompt == \"\":\n self.error(\"No prompt provided in app config.\")\n return\n let apiKey = self.frameConfig.settings{\"openAI\"}{\"apiKey\"}.getStr\n if apiKey == \"\":\n self.error(\"Please provide an OpenAI API key in the settings.\")\n return\n\n var downloadedImage: Option[Image] = none(Image)\n if self.appConfig.cacheSeconds > 0 and self.cachedImage.isSome and\n self.cacheExpiry > epochTime() and self.cachedPrompt == prompt:\n downloadedImage = self.cachedImage\n else:\n var client = newHttpClient(timeout = 60000)\n client.headers = newHttpHeaders([\n (\"Authorization\", \"Bearer \" & apiKey),\n (\"Content-Type\", \"application/json\"),\n ])\n let defaultSize = \"1024x1024\"\n let gptImageSizes = @[\"1024x1024\", \"1536x1024\", \"1024x1536\"]\n let dalle3Sizes = @[\"1024x1024\", \"1792x1024\", \"1024x1792\"]\n let dalle2Sizes = @[\"256x256\", \"512x512\", \"1024x1024\"]\n let size = if self.appConfig.size == \"best for orientation\":\n case self.appConfig.model\n of \"gpt-image-1\", \"gpt-image-1.5\", \"gpt-image-2\":\n if context.image.width > context.image.height: \"1536x1024\"\n elif context.image.width < context.image.height: \"1024x1536\"\n else: defaultSize\n of \"dall-e-3\":\n if context.image.width > context.image.height: \"1792x1024\"\n elif context.image.width < context.image.height: \"1024x1792\"\n else: defaultSize\n else:\n defaultSize\n elif self.appConfig.size != \"\":\n case self.appConfig.model\n of \"gpt-image-1\", \"gpt-image-1.5\", \"gpt-image-2\":\n if self.appConfig.size in gptImageSizes: self.appConfig.size else: defaultSize\n of \"dall-e-3\":\n if self.appConfig.size in dalle3Sizes: self.appConfig.size else: defaultSize\n of \"dall-e-2\":\n if self.appConfig.size in dalle2Sizes: self.appConfig.size else: defaultSize\n else:\n defaultSize\n else:\n defaultSize\n var body = %*{\n \"prompt\": prompt,\n \"n\": 1,\n \"size\": size,\n \"model\": self.appConfig.model\n }\n if self.appConfig.model == \"dall-e-3\":\n if self.appConfig.style != \"\":\n body[\"style\"] = %self.appConfig.style\n if self.appConfig.quality != \"\":\n body[\"quality\"] = %self.appConfig.quality\n try:\n let response = client.request(\"https://api.openai.com/v1/images/generations\",\n httpMethod = HttpPost, body = $body)\n if response.code != Http200:\n try:\n let json = parseJson(response.body)\n let error = json{\"error\"}{\"message\"}.getStr(json{\"error\"}.getStr($json))\n self.error(\"Error making request \" & $response.status & \": \" & error)\n except:\n self.error \"Error making request \" & $response.status & \": \" & response.body\n return\n let json = parseJson(response.body)\n let imageNode = json{\"data\"}{0}\n let imageBase64 = imageNode{\"b64_json\"}.getStr\n var imageDataBody = \"\"\n if imageBase64 != \"\":\n imageDataBody = imageBase64.decode\n else:\n let imageUrl = imageNode{\"url\"}.getStr\n if imageUrl == \"\":\n self.error(\"No image data returned from OpenAI.\")\n return\n var client2 = newHttpClient(timeout = 60000)\n try:\n let imageData = client2.request(imageUrl, httpMethod = HttpGet)\n if imageData.code != Http200:\n self.error \"Error fetching image \" & $imageData.status\n return\n imageDataBody = imageData.body\n finally:\n client2.close()\n\n downloadedImage = some(decodeImageWithDisplayBounds(imageDataBody))\n except CatchableError as e:\n self.error \"Error fetching image from OpenAI: \" & $e.msg\n finally:\n client.close()\n\n if self.appConfig.cacheSeconds > 0:\n self.cachedImage = downloadedImage\n self.cachedPrompt = prompt\n self.cacheExpiry = epochTime() + self.appConfig.cacheSeconds\n\n if downloadedImage.isSome:\n let image = downloadedImage.get\n let scalingMode = self.appConfig.scalingMode\n context.image.scaleAndDrawImage(image, scalingMode)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n prompt: params{\"prompt\"}.getStr(\"\"),\n model: params{\"model\"}.getStr(\"gpt-image-2\"),\n size: params{\"size\"}.getStr(\"best for orientation\"),\n scalingMode: params{\"scalingMode\"}.getStr(\"cover\"),\n style: params{\"style\"}.getStr(\"vivid\"),\n quality: params{\"quality\"}.getStr(\"standard\"),\n cacheSeconds: block:\n var v = 3600.0\n if params.hasKey(\"cacheSeconds\"):\n let n = params{\"cacheSeconds\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"prompt\":\n app.appConfig.prompt = value.asString()\n of \"model\":\n app.appConfig.model = value.asString()\n of \"size\":\n app.appConfig.size = value.asString()\n of \"scalingMode\":\n app.appConfig.scalingMode = value.asString()\n of \"style\":\n app.appConfig.style = value.asString()\n of \"quality\":\n app.appConfig.quality = value.asString()\n of \"cacheSeconds\":\n app.appConfig.cacheSeconds = value.asFloat()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"OpenAI Image (legacy)\",\n \"description\": \"Random AI generated art from OpenAI's image models\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"settings\": [\"openAI\"],\n \"fields\": [\n {\n \"name\": \"prompt\",\n \"type\": \"text\",\n \"rows\": 6,\n \"value\": \"\",\n \"required\": true,\n \"label\": \"Prompt\",\n \"placeholder\": \"e.g. pumpkin pyjama party, digital art\"\n },\n {\n \"name\": \"model\",\n \"type\": \"select\",\n \"options\": [\"gpt-image-2\", \"gpt-image-1.5\", \"gpt-image-1\", \"dall-e-3\", \"dall-e-2\"],\n \"value\": \"gpt-image-2\",\n \"required\": true,\n \"label\": \"Model\"\n },\n {\n \"name\": \"size\",\n \"type\": \"select\",\n \"options\": [\"best for orientation\", \"1024x1024\", \"1536x1024\", \"1024x1536\"],\n \"value\": \"best for orientation\",\n \"label\": \"Size\"\n },\n {\n \"name\": \"scalingMode\",\n \"type\": \"select\",\n \"options\": [\"cover\", \"contain\", \"stretch\", \"center\"],\n \"value\": \"cover\",\n \"required\": true,\n \"label\": \"Scaling mode\"\n },\n {\n \"name\": \"style\",\n \"type\": \"select\",\n \"options\": [\"vivid\", \"natural\", \"\"],\n \"value\": \"vivid\",\n \"label\": \"Style\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"select\",\n \"options\": [\"standard\", \"hd\", \"\"],\n \"value\": \"standard\",\n \"label\": \"Quality\"\n },\n {\n \"name\": \"cacheSeconds\",\n \"type\": \"float\",\n \"value\": \"3600\",\n \"required\": false,\n \"label\": \"Seconds to cache each prompt\",\n \"placeholder\": \"Default: 3600 (1h). Use 0 for no cache\"\n }\n ]\n}\n" + }, + "legacy/openaiText": { + "app.nim": "import pixie\nimport times\nimport options\nimport json\nimport strformat\nimport httpclient\nimport frameos/types\n\ntype\n AppConfig* = object\n model*: string\n system*: string\n user*: string\n cacheSeconds*: float\n stateKey*: string\n\n App* = ref object\n nodeId*: NodeId\n scene*: FrameScene\n appConfig*: AppConfig\n frameConfig*: FrameConfig\n\n cacheExpiry: float\n cachedReply: string\n cachedPrompt: string\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n result = App(\n nodeId: nodeId,\n scene: scene,\n frameConfig: scene.frameConfig,\n appConfig: appConfig,\n cachedReply: \"\",\n cacheExpiry: 0.0,\n cachedPrompt: \"\",\n )\n\nproc log*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": &\"openai:{self.nodeId}:log\", \"message\": message})\n\nproc error*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": &\"openai:{self.nodeId}:error\", \"error\": message})\n self.scene.state[self.appConfig.stateKey] = %*(&\"Error: {message}\")\n\nproc run*(self: App, context: ExecutionContext) =\n if self.appConfig.user == \"\" and self.appConfig.system == \"\":\n self.error(\"No system or user prompt provided in app config.\")\n return\n let apiKey = self.frameConfig.settings{\"openAI\"}{\"apiKey\"}.getStr\n if apiKey == \"\":\n self.error(\"Please provide an OpenAI API key in the settings.\")\n return\n\n var reply = \"\"\n if self.appConfig.cacheSeconds > 0 and self.cachedReply != \"\" and\n self.cacheExpiry > epochTime() and self.cachedPrompt == self.appConfig.system & \" // \" & self.appConfig.user:\n reply = self.cachedReply\n else:\n var client = newHttpClient(timeout = 60000)\n client.headers = newHttpHeaders([\n (\"Authorization\", \"Bearer \" & apiKey),\n (\"Content-Type\", \"application/json\"),\n ])\n let body = %*{\n \"model\": self.appConfig.model,\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": self.appConfig.system\n },\n {\n \"role\": \"user\",\n \"content\": self.appConfig.user\n }\n ]\n }\n try:\n self.scene.logger.log(%*{\"event\": &\"openai:{self.nodeId}:request\", \"user\": self.appConfig.user,\n \"system\": self.appConfig.system})\n let response = client.request(\"https://api.openai.com/v1/chat/completions\",\n httpMethod = HttpPost, body = $body)\n if response.code != Http200:\n try:\n let json = parseJson(response.body)\n let error = json{\"error\"}{\"message\"}.getStr(json{\"error\"}.getStr($json))\n self.error(\"Error making request \" & $response.status & \": \" & error)\n except:\n self.error \"Error making request \" & $response.status & \": \" & response.body\n return\n let json = parseJson(response.body)\n reply = json{\"choices\"}{0}{\"message\"}{\"content\"}.getStr\n self.scene.logger.log(%*{\"event\": &\"openai:{self.nodeId}:reply\", \"reply\": reply})\n except CatchableError as e:\n self.error \"OpenAI API error: \" & $e.msg\n finally:\n client.close()\n\n if self.appConfig.cacheSeconds > 0:\n self.cachedReply = reply\n self.cachedPrompt = self.appConfig.system & \" // \" & self.appConfig.user\n self.cacheExpiry = epochTime() + self.appConfig.cacheSeconds\n\n if reply != \"\":\n self.scene.state[self.appConfig.stateKey] = %*(reply)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n system: params{\"system\"}.getStr(\"You're a smart e-ink frame running FrameOS. Reply with plain text only. Space is very limited.\"),\n user: params{\"user\"}.getStr(\"\"),\n model: params{\"model\"}.getStr(\"gpt-5.5\"),\n stateKey: params{\"stateKey\"}.getStr(\"reply\"),\n cacheSeconds: block:\n var v = 3600.0\n if params.hasKey(\"cacheSeconds\"):\n let n = params{\"cacheSeconds\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"system\":\n app.appConfig.system = value.asString()\n of \"user\":\n app.appConfig.user = value.asString()\n of \"model\":\n app.appConfig.model = value.asString()\n of \"stateKey\":\n app.appConfig.stateKey = value.asString()\n of \"cacheSeconds\":\n app.appConfig.cacheSeconds = value.asFloat()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"OpenAI Text (legacy)\",\n \"description\": \"Text response from ChatGPT and friends\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"settings\": [\"openAI\"],\n \"fields\": [\n {\n \"name\": \"system\",\n \"type\": \"text\",\n \"rows\": 6,\n \"value\": \"You're a smart e-ink frame running FrameOS. Reply with plain text only. Space is very limited.\",\n \"required\": true,\n \"label\": \"System Prompt\",\n \"placeholder\": \"\"\n },\n {\n \"name\": \"user\",\n \"type\": \"text\",\n \"rows\": 6,\n \"value\": \"\",\n \"required\": true,\n \"label\": \"User Prompt\",\n \"placeholder\": \"Write your prompt here. Keep it short and clear.\"\n },\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"value\": \"gpt-5.5\",\n \"required\": true,\n \"label\": \"Model\"\n },\n {\n \"name\": \"stateKey\",\n \"type\": \"string\",\n \"value\": \"reply\",\n \"required\": true,\n \"label\": \"State key for reply\"\n },\n {\n \"name\": \"cacheSeconds\",\n \"type\": \"float\",\n \"value\": \"3600\",\n \"required\": false,\n \"label\": \"Seconds to cache each prompt\",\n \"placeholder\": \"Default: 3600 (1h). Use 0 for no cache\"\n }\n ]\n}\n" + }, + "legacy/qr": { + "app.nim": "import json, strformat\nimport pixie\nimport frameos/types\nimport frameos/utils/url\nimport QRgen\nimport QRgen/renderer\n\ntype\n AppConfig* = object\n codeType*: string\n code*: string\n size*: float\n sizeUnit*: string\n alRad*: float\n moRad*: float\n moSep*: float\n position*: string\n offsetX*: float\n offsetY*: float\n padding*: int\n qrCodeColor*: Color\n backgroundColor*: Color\n\n App* = ref object\n nodeId*: NodeId\n scene*: FrameScene\n frameConfig*: FrameConfig\n appConfig*: AppConfig\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n result = App(\n nodeId: nodeId,\n scene: scene,\n frameConfig: scene.frameConfig,\n appConfig: appConfig,\n )\n\nproc log*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": &\"{self.nodeId}:log\", \"message\": message})\n\nproc error*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": &\"{self.nodeId}:error\", \"error\": message})\n\nproc run*(self: App, context: ExecutionContext) =\n let code = if self.appConfig.codeType == \"Frame Control URL\":\n authenticatedFrameUrl(self.frameConfig, \"/c\")\n elif self.appConfig.codeType == \"Frame Image URL\":\n authenticatedFrameUrl(self.frameConfig, \"/\", requireWriteAccess = false)\n else:\n self.appConfig.code\n\n let myQR = newQR(code)\n\n let width = case self.appConfig.sizeUnit\n of \"percent\": self.appConfig.size / 100.0 * min(context.image.width, context.image.height).float\n of \"pixels per dot\": self.appConfig.size * (myQR.drawing.size.int + self.appConfig.padding * 2).float\n else: self.appConfig.size\n\n let qrImage = myQR.renderImg(\n light = self.appConfig.backgroundColor.toHtmlHex,\n dark = self.appConfig.qrCodeColor.toHtmlHex,\n alRad = self.appConfig.alRad,\n moRad = self.appConfig.moRad,\n moSep = self.appConfig.moSep,\n pixels = width.uint32,\n padding = self.appConfig.padding.uint8\n )\n\n let xAlign = case self.appConfig.position:\n of \"top-left\", \"center-left\", \"bottom-left\": self.appConfig.offsetX\n of \"top-right\", \"center-right\", \"bottom-right\": context.image.width.float - qrImage.width.float +\n self.appConfig.offsetX\n else: (context.image.width.float - qrImage.width.float) / 2.0 + self.appConfig.offsetX\n\n let yAlign = case self.appConfig.position:\n of \"top-left\", \"top-center\", \"top-right\": self.appConfig.offsetY\n of \"bottom-left\", \"bottom-center\", \"bottom-right\": context.image.height.float - qrImage.height.float +\n self.appConfig.offsetY\n else: (context.image.height.float - qrImage.height.float) / 2.0 + self.appConfig.offsetY\n\n context.image.draw(\n qrImage,\n translate(vec2(xAlign, yAlign))\n )\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n codeType: params{\"codeType\"}.getStr(\"Frame Control URL\"),\n code: params{\"code\"}.getStr(\"\"),\n size: block:\n var v = 2.0\n if params.hasKey(\"size\"):\n let n = params{\"size\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n sizeUnit: params{\"sizeUnit\"}.getStr(\"pixels per dot\"),\n alRad: block:\n var v = 30.0\n if params.hasKey(\"alRad\"):\n let n = params{\"alRad\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n moRad: block:\n var v = 0.0\n if params.hasKey(\"moRad\"):\n let n = params{\"moRad\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n moSep: block:\n var v = 0.0\n if params.hasKey(\"moSep\"):\n let n = params{\"moSep\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n position: params{\"position\"}.getStr(\"center-center\"),\n offsetX: block:\n var v = 0.0\n if params.hasKey(\"offsetX\"):\n let n = params{\"offsetX\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n offsetY: block:\n var v = 0.0\n if params.hasKey(\"offsetY\"):\n let n = params{\"offsetY\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n padding: block:\n var v = 1\n if params.hasKey(\"padding\"):\n let n = params{\"padding\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n qrCodeColor: block:\n var v: Color = parseHtmlColor(\"#000000\")\n if params.hasKey(\"qrCodeColor\"):\n let n = params{\"qrCodeColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n backgroundColor: block:\n var v: Color = parseHtmlColor(\"#ffffff\")\n if params.hasKey(\"backgroundColor\"):\n let n = params{\"backgroundColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"codeType\":\n app.appConfig.codeType = value.asString()\n of \"code\":\n app.appConfig.code = value.asString()\n of \"size\":\n app.appConfig.size = value.asFloat()\n of \"sizeUnit\":\n app.appConfig.sizeUnit = value.asString()\n of \"alRad\":\n app.appConfig.alRad = value.asFloat()\n of \"moRad\":\n app.appConfig.moRad = value.asFloat()\n of \"moSep\":\n app.appConfig.moSep = value.asFloat()\n of \"position\":\n app.appConfig.position = value.asString()\n of \"offsetX\":\n app.appConfig.offsetX = value.asFloat()\n of \"offsetY\":\n app.appConfig.offsetY = value.asFloat()\n of \"padding\":\n app.appConfig.padding = value.asInt().int\n of \"qrCodeColor\":\n app.appConfig.qrCodeColor = value.asColor()\n of \"backgroundColor\":\n app.appConfig.backgroundColor = value.asColor()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"QR Code (legacy)\",\n \"description\": \"Display QR codes. Default to link to self.\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"codeType\",\n \"label\": \"Code Type\",\n \"type\": \"select\",\n \"options\": [\"Frame Control URL\", \"Frame Image URL\", \"Custom\"],\n \"value\": \"Frame Control URL\",\n \"required\": false\n },\n {\n \"name\": \"code\",\n \"label\": \"Code (if Custom above)\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false\n },\n {\n \"name\": \"size\",\n \"type\": \"float\",\n \"value\": \"2\",\n \"label\": \"Size\"\n },\n {\n \"name\": \"sizeUnit\",\n \"type\": \"select\",\n \"options\": [\"percent\", \"pixels per dot\", \"pixels total\"],\n \"value\": \"pixels per dot\",\n \"required\": true,\n \"label\": \"Size unit\"\n },\n {\n \"name\": \"alRad\",\n \"type\": \"float\",\n \"value\": \"30\",\n \"label\": \"Alignment pattern radius %\"\n },\n {\n \"name\": \"moRad\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"label\": \"Module radius %\"\n },\n {\n \"name\": \"moSep\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"label\": \"Module separation %\"\n },\n {\n \"name\": \"position\",\n \"type\": \"select\",\n \"options\": [\n \"top-left\",\n \"top-center\",\n \"top-right\",\n \"center-left\",\n \"center-center\",\n \"center-right\",\n \"bottom-left\",\n \"bottom-center\",\n \"bottom-right\"\n ],\n \"value\": \"center-center\",\n \"required\": true,\n \"label\": \"Position\",\n \"placeholder\": \"center-center\"\n },\n {\n \"name\": \"offsetX\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"required\": true,\n \"label\": \"Offset X\",\n \"placeholder\": \"0\"\n },\n {\n \"name\": \"offsetY\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"required\": true,\n \"label\": \"Offset Y\",\n \"placeholder\": \"0\"\n },\n {\n \"name\": \"padding\",\n \"type\": \"integer\",\n \"value\": \"1\",\n \"required\": true,\n \"label\": \"Padding in dots\",\n \"placeholder\": \"1\"\n },\n {\n \"name\": \"qrCodeColor\",\n \"type\": \"color\",\n \"value\": \"#000000\",\n \"required\": true,\n \"label\": \"QR Code Color\",\n \"placeholder\": \"#000000\"\n },\n {\n \"name\": \"backgroundColor\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"required\": true,\n \"label\": \"Background Color\",\n \"placeholder\": \"#ffffff\"\n }\n ]\n}\n" + }, + "legacy/resize": { + "app.nim": "import pixie\nimport frameos/types\nimport frameos/utils/image\n\ntype\n AppConfig* = object\n scalingMode*: string\n width*: int\n height*: int\n\n App* = ref object\n nodeId*: NodeId\n frameConfig*: FrameConfig\n scene*: FrameScene\n appConfig*: AppConfig\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n result = App(\n nodeId: nodeId,\n scene: scene,\n frameConfig: scene.frameConfig,\n appConfig: appConfig,\n )\n\nproc run*(self: App, context: ExecutionContext) =\n let image = newImage(self.appConfig.width, self.appConfig.height)\n case self.appConfig.scalingMode:\n of \"center\", \"contain\", \"\":\n image.fill(parseHtmlColor(self.frameConfig.color))\n image.scaleAndDrawImage(context.image, self.appConfig.scalingMode)\n context.image = image\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n width: block:\n var v = 0\n if params.hasKey(\"width\"):\n let n = params{\"width\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n height: block:\n var v = 0\n if params.hasKey(\"height\"):\n let n = params{\"height\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n scalingMode: params{\"scalingMode\"}.getStr(\"contain\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"width\":\n app.appConfig.width = value.asInt().int\n of \"height\":\n app.appConfig.height = value.asInt().int\n of \"scalingMode\":\n app.appConfig.scalingMode = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Resize (legacy)\",\n \"description\": \"Scale or stretch the image\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"fields\": [\n { \"name\": \"width\", \"type\": \"integer\", \"required\": true, \"label\": \"New Width\", \"placeholder\": \"e.g., 1024\" },\n { \"name\": \"height\", \"type\": \"integer\", \"required\": true, \"label\": \"New Height\", \"placeholder\": \"e.g., 1024\" },\n {\n \"name\": \"scalingMode\",\n \"type\": \"select\",\n \"options\": [\"cover\", \"contain\", \"stretch\", \"center\"],\n \"value\": \"contain\",\n \"required\": true,\n \"label\": \"Scaling mode\"\n }\n ]\n}\n" + }, + "legacy/rotate": { + "app.nim": "import pixie\nimport json\nimport strformat\nimport frameos/types\nimport frameos/utils/image\n\ntype\n AppConfig* = object\n rotationDegree*: float\n scalingMode*: string\n\n App* = ref object\n nodeId*: NodeId\n scene*: FrameScene\n frameConfig*: FrameConfig\n appConfig*: AppConfig\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n result = App(\n nodeId: nodeId,\n scene: scene,\n frameConfig: scene.frameConfig,\n appConfig: appConfig,\n )\n\nproc log*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": &\"{self.nodeId}:log\", \"message\": message})\n\nproc run*(self: App, context: ExecutionContext) =\n let originalImage = context.image\n let rotationAngle = degToRad(self.appConfig.rotationDegree).float32\n\n # Calculate the new dimensions after rotation\n let cosAngle = abs(cos(rotationAngle))\n let sinAngle = abs(sin(rotationAngle))\n let newWidth = int(ceil(originalImage.width.float32 * cosAngle +\n originalImage.height.float32 * sinAngle))\n let newHeight = int(ceil(originalImage.width.float32 * sinAngle +\n originalImage.height.float32 * cosAngle))\n\n # Create a new target image with the calculated dimensions\n let targetImage = newImage(newWidth, newHeight)\n targetImage.fill(self.scene.backgroundColor)\n\n # Calculate the center of the original and target images\n let originalCenterX = originalImage.width.float32 / 2\n let originalCenterY = originalImage.height.float32 / 2\n let targetCenterX = newWidth.float32 / 2\n let targetCenterY = newHeight.float32 / 2\n\n # Create a transformation that translates the image to the center of the target image, rotates it, and then translates it back\n let transform =\n translate(vec2(targetCenterX, targetCenterY)) *\n rotate(rotationAngle) *\n translate(vec2(-originalCenterX, -originalCenterY))\n\n targetImage.draw(\n originalImage,\n transform,\n OverwriteBlend\n )\n\n if self.appConfig.scalingMode == \"expand\":\n context.image = targetImage\n else:\n context.image.scaleAndDrawImage(targetImage, self.appConfig.scalingMode)\n\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n rotationDegree: block:\n var v = 0.0\n if params.hasKey(\"rotationDegree\"):\n let n = params{\"rotationDegree\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n scalingMode: params{\"scalingMode\"}.getStr(\"cover\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"rotationDegree\":\n app.appConfig.rotationDegree = value.asFloat()\n of \"scalingMode\":\n app.appConfig.scalingMode = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Rotate (legacy)\",\n \"description\": \"Rotate the image\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"rotationDegree\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"required\": true,\n \"label\": \"Rotation Degree\",\n \"placeholder\": \"e.g., 45\"\n },\n {\n \"name\": \"scalingMode\",\n \"type\": \"select\",\n \"options\": [\"expand\", \"cover\", \"contain\", \"stretch\", \"center\"],\n \"value\": \"cover\",\n \"required\": true,\n \"label\": \"Scaling mode\"\n }\n ]\n}\n" + }, + "legacy/unsplash": { + "app.nim": "import pixie\nimport json\nimport std/strformat\nimport std/strutils\nimport times\nimport options\nimport frameos/utils/image\nimport frameos/types\nimport frameos/apps\n\ntype\n AppConfig* = object\n keyword*: string\n cacheSeconds*: float\n\n App* = ref object\n nodeId*: NodeId\n scene*: FrameScene\n appConfig*: AppConfig\n frameConfig*: FrameConfig\n\n cacheExpiry: float\n cachedImage: Option[Image]\n cachedUrl: string\n\nproc log*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": \"unsplash:log\", \"message\": message})\n\nproc error*(self: App, message: string) =\n self.scene.logger.log(%*{\"event\": \"unsplash:error\", \"error\": message})\n\nproc init*(nodeId: NodeId, scene: FrameScene, appConfig: AppConfig): App =\n result = App(\n nodeId: nodeId,\n scene: scene,\n frameConfig: scene.frameConfig,\n appConfig: appConfig,\n cachedImage: none(Image),\n cacheExpiry: 0.0,\n cachedUrl: \"\",\n )\n if result.appConfig.keyword == \"\":\n result.appConfig.keyword = \"random\"\n result.appConfig.keyword = result.appConfig.keyword.strip()\n\nproc run*(self: App, context: ExecutionContext) =\n let image = context.image\n let url = &\"https://source.unsplash.com/random/{image.width}x{image.height}/?{self.appConfig.keyword}\"\n\n if self.frameConfig.debug:\n self.scene.logger.log(\n %*{\n \"event\": \"unsplash:run\",\n \"keyword\": self.appConfig.keyword,\n \"url\": url,\n \"cacheSeconds\": self.appConfig.cacheSeconds,\n \"cacheExpiry\": self.cacheExpiry,\n \"cachedUrl\": self.cachedUrl,\n }\n )\n\n var unsplashImage: Option[Image] = none(Image)\n if self.appConfig.cacheSeconds > 0 and self.cachedImage.isSome and\n self.cacheExpiry > epochTime() and self.cachedUrl == url:\n unsplashImage = self.cachedImage\n if self.frameConfig.debug:\n self.log(\"Using cached image\")\n else:\n if self.frameConfig.debug:\n self.log(\"Downloading image\")\n unsplashImage = some(downloadImage(url, maxBytes = self.frameConfig.maxImageResponseBytes()))\n if self.frameConfig.debug:\n self.log(\"Image downloaded\")\n\n if self.appConfig.cacheSeconds > 0:\n self.cachedImage = unsplashImage\n self.cachedUrl = url\n self.cacheExpiry = epochTime() + self.appConfig.cacheSeconds\n if self.frameConfig.debug:\n self.log(\"Caching image\")\n else:\n if self.frameConfig.debug:\n self.log(\"Not caching image, cacheSeconds is 0\")\n\n image.draw(unsplashImage.get())\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n keyword: params{\"keyword\"}.getStr(\"nature\"),\n cacheSeconds: block:\n var v = 3600.0\n if params.hasKey(\"cacheSeconds\"):\n let n = params{\"cacheSeconds\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"keyword\":\n app.appConfig.keyword = value.asString()\n of \"cacheSeconds\":\n app.appConfig.cacheSeconds = value.asFloat()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Unsplash (legacy/broken)\",\n \"description\": \"Random unsplash image\",\n \"category\": \"legacy\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"keyword\",\n \"type\": \"string\",\n \"value\": \"nature\",\n \"required\": false,\n \"label\": \"Random keyword (one word)\",\n \"placeholder\": \"e.g. pineapple, nature, birds, power\"\n },\n {\n \"name\": \"cacheSeconds\",\n \"type\": \"float\",\n \"value\": \"3600\",\n \"required\": false,\n \"label\": \"Seconds to cache the result\",\n \"placeholder\": \"Default: 3600 (1h). Use 0 to refetch on every render.\"\n }\n ]\n}\n" + }, + "logic/breakIfRendering": { + "app.nim": "import frameos/types\n\ntype\n AppConfig* = object\n discard\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc run*(self: App, context: ExecutionContext) =\n if self.scene.isRendering:\n raise newException(Exception, \"Abording run because scene is rendering\")\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Break if rendering\",\n \"description\": \"Cancel execution if the event is dispatched when the scene is rendering\",\n \"category\": \"logic\",\n \"version\": \"1.0.0\",\n \"fields\": []\n}\n" + }, + "logic/ifElse": { + "app.nim": "import frameos/types\n\ntype\n AppConfig* = object\n condition*: bool\n thenNode*: NodeId\n elseNode*: NodeId\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc run*(self: App, context: ExecutionContext) =\n if self.appConfig.condition:\n if self.appConfig.thenNode != 0:\n self.scene.execNode(self.appConfig.thenNode, context)\n else:\n if self.appConfig.elseNode != 0:\n self.scene.execNode(self.appConfig.elseNode, context)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n condition: block:\n var v = true\n if params.hasKey(\"condition\"):\n let n = params{\"condition\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n thenNode: block:\n var v: NodeId = 0.NodeId\n if params.hasKey(\"thenNode\"):\n let n = params{\"thenNode\"}\n if n.kind == JInt:\n v = n.getInt().int.NodeId\n elif n.kind == JFloat:\n v = int(n.getFloat()).NodeId\n elif n.kind == JString:\n try: v = int(parseFloat(n.getStr())).NodeId\n except CatchableError: discard\n v,\n elseNode: block:\n var v: NodeId = 0.NodeId\n if params.hasKey(\"elseNode\"):\n let n = params{\"elseNode\"}\n if n.kind == JInt:\n v = n.getInt().int.NodeId\n elif n.kind == JFloat:\n v = int(n.getFloat()).NodeId\n elif n.kind == JString:\n try: v = int(parseFloat(n.getStr())).NodeId\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"condition\":\n app.appConfig.condition = value.asBool()\n of \"thenNode\":\n app.appConfig.thenNode = value.asNode()\n of \"elseNode\":\n app.appConfig.elseNode = value.asNode()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"If-Else\",\n \"description\": \"If Condition Then Node Else Node\",\n \"category\": \"logic\",\n \"version\": \"1.0.0\",\n \"settings\": [],\n \"fields\": [\n {\n \"label\": \"Condition\",\n \"name\": \"condition\",\n \"value\": \"true\",\n \"type\": \"boolean\",\n \"required\": true\n },\n {\n \"label\": \"Truthy\",\n \"name\": \"thenNode\",\n \"type\": \"node\"\n },\n {\n \"label\": \"Falsy\",\n \"name\": \"elseNode\",\n \"type\": \"node\"\n }\n ]\n}\n" + }, + "logic/nextSleepDuration": { + "app.nim": "import frameos/apps\nimport frameos/types\n\ntype\n AppConfig* = object\n duration*: float\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc run*(self: App, context: ExecutionContext) =\n context.nextSleep = self.appConfig.duration\n self.log(\"Set sleep duration between renders to \" & $context.nextSleep & \" seconds\")\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n duration: block:\n var v = 0.0\n if params.hasKey(\"duration\"):\n let n = params{\"duration\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"duration\":\n app.appConfig.duration = value.asFloat()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Next sleep duration\",\n \"description\": \"Override the delay between renders\",\n \"category\": \"logic\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"duration\",\n \"type\": \"float\",\n \"required\": true,\n \"label\": \"Duration in seconds\"\n }\n ]\n}\n" + }, + "logic/setAsState": { + "app.nim": "import json\nimport frameos/apps\nimport frameos/types\n\ntype\n AppConfig* = object\n valueString*: string\n valueJson*: JsonNode\n stateKey*: string\n debugLog*: bool\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc run*(self: App, context: ExecutionContext) =\n if self.appConfig.valueString != \"\" and self.appConfig.valueJson != nil:\n self.logError(\"Both valueString and valueJson are set. Only one can be set.\")\n return\n if self.appConfig.valueJson != nil and self.appConfig.valueJson.kind != JNull and self.appConfig.valueJson.kind != JNull:\n self.scene.state[self.appConfig.stateKey] = self.appConfig.valueJson\n elif self.appConfig.valueString != \"\":\n self.scene.state[self.appConfig.stateKey] = %*(self.appConfig.valueString)\n\n if self.appConfig.debugLog:\n let value = if self.scene.state.hasKey(self.appConfig.stateKey) and self.scene.state[self.appConfig.stateKey] != nil:\n self.scene.state[self.appConfig.stateKey]\n else:\n newJNull()\n self.log(%*{\"event\": \"setAsState\", \"key\": self.appConfig.stateKey, \"value\": value})\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n stateKey: params{\"stateKey\"}.getStr(\"\"),\n valueString: params{\"valueString\"}.getStr(\"\"),\n valueJson: params{\"valueJson\"},\n debugLog: block:\n var v = false\n if params.hasKey(\"debugLog\"):\n let n = params{\"debugLog\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"stateKey\":\n app.appConfig.stateKey = value.asString()\n of \"valueString\":\n app.appConfig.valueString = value.asString()\n of \"valueJson\":\n app.appConfig.valueJson = value.asJson()\n of \"debugLog\":\n app.appConfig.debugLog = value.asBool()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Set as state\",\n \"description\": \"Save the value (json node) as a state variable\",\n \"category\": \"logic\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"stateKey\",\n \"type\": \"string\",\n \"required\": true,\n \"label\": \"State key\",\n \"value\": \"\",\n \"placeholder\": \"\"\n },\n {\n \"name\": \"valueString\",\n \"type\": \"string\",\n \"required\": false,\n \"label\": \"Value as a string\"\n },\n {\n \"name\": \"valueJson\",\n \"type\": \"json\",\n \"required\": false,\n \"label\": \"Value as JSON\"\n },\n {\n \"name\": \"debugLog\",\n \"type\": \"boolean\",\n \"required\": false,\n \"label\": \"Log to console\",\n \"value\": false\n }\n ]\n}\n" + }, + "render/calendar": { + "README.md": "# Calendar\n\nThis app renders a monthly calendar view.", + "app.nim": "import pixie\nimport json, options, strformat, strutils, tables\nimport times, chroma\nimport algorithm\n\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/font\n\nproc daysInMonth(year, month: int): int =\n case month\n of 1, 3, 5, 7, 8, 10, 12: 31\n of 4, 6, 9, 11: 30\n of 2:\n if (year mod 400 == 0) or ((year mod 4 == 0) and (year mod 100 != 0)):\n 29\n else:\n 28\n else:\n 30\n\nproc weekday(year, month, day: int): int =\n var m = month\n var y = year\n if m < 3:\n m += 12\n y -= 1\n let k = y mod 100\n let j = y div 100\n let h = (day + (13 * (m + 1)) div 5 + k + k div 4 + j div 4 + 5 * j) mod 7\n result = (h + 6) mod 7 # 0=Sunday\n\ntype\n AppConfig* = object\n inputImage*: Option[Image]\n events*: JsonNode\n year*: int\n month*: int\n startWeekOnMonday*: bool\n\n # colors / fonts\n lastTheme*: string\n theme*: string\n transparentBackground*: bool\n backgroundColor*: Color\n weekendBackgroundColor*: Color # NEW: background color for Saturday/Sunday day cells\n gridColor*: Color\n dateTextColor*: Color\n eventTimeColor*: Color\n eventTitleColor*: Color\n weekdayFont*: string\n weekdayFontSize*: float\n weekdayBackgroundColor*: Color\n weekdayTextColor*: Color\n titleFont*: string\n titleFontSize*: float\n titleBackgroundColor*: Color\n titleTextColor*: Color\n dateFont*: string\n dateFontSize*: float\n eventTimeFont*: string\n eventTitleFont*: string\n eventFontSize*: float\n eventColorCount*: int\n eventColorForeground*: seq[Color]\n eventColorBackground*: seq[Color]\n\n # layout / decoration\n padding*: int # outer padding (px)\n showMonthYear*: bool # show month + year\n monthYearPosition*: string # \"top\", \"bottom\", or \"none\"\n showGrid*: bool # toggle grid on/off\n gridWidth*: float # grid stroke width (px)\n todayStrokeColor*: Color # outline color for today's cell\n todayBackgroundColor*: Color # NEW: background fill for today's cell\n todayStrokeWidth*: float # NEW: outline thickness (px, before scaling)\n showEventTimes*: bool # show times next to non all-day events\n scale*: int # scale percentage (100 = default)\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\n# Small record describing one visual line inside a day cell\ntype\n EventLine* = object\n display*: string # what we will draw on the line\n isAllDay*: bool # if true we draw a colored chip behind it\n colors*: Option[(Color, Color)] # (bg, fg) - colors used if isAllDay\n sortKey*: int # minutes since midnight; all-day uses -1\n\nproc setCommonThemeFonts*(self: App) =\n self.appConfig.weekdayFont = \"Ubuntu-Medium.ttf\"\n self.appConfig.titleFont = \"Ubuntu-Bold.ttf\"\n self.appConfig.dateFont = \"Ubuntu-Medium.ttf\"\n self.appConfig.eventTimeFont = \"Ubuntu-Light.ttf\"\n self.appConfig.eventTitleFont = \"Ubuntu-Medium.ttf\"\n self.appConfig.weekdayFontSize = 16\n self.appConfig.titleFontSize = 28\n self.appConfig.dateFontSize = 18\n self.appConfig.eventFontSize = 14\n\nproc setTheme*(self: App) =\n if self.appConfig.theme == \"light\" and self.appConfig.lastTheme != \"light\":\n self.appConfig.lastTheme = \"light\"\n self.setCommonThemeFonts()\n self.appConfig.backgroundColor = rgb(255, 255, 255).to(Color)\n self.appConfig.weekendBackgroundColor = rgba(174, 190, 232, 1).to(Color)\n self.appConfig.todayStrokeColor = rgb(255, 0, 0).to(Color)\n self.appConfig.todayBackgroundColor = rgb(239, 189, 189).to(Color)\n self.appConfig.dateTextColor = rgb(0, 0, 0).to(Color)\n self.appConfig.eventTimeColor = rgb(0, 0, 0).to(Color)\n self.appConfig.eventTitleColor = rgb(0, 0, 0).to(Color)\n self.appConfig.titleTextColor = rgb(0, 0, 0).to(Color)\n self.appConfig.titleBackgroundColor = rgb(255, 255, 255).to(Color)\n self.appConfig.weekdayTextColor = rgb(0, 0, 0).to(Color)\n self.appConfig.weekdayBackgroundColor = rgb(240, 240, 240).to(Color)\n self.appConfig.gridColor = rgb(220, 220, 220).to(Color)\n self.appConfig.eventColorCount = 7\n self.appConfig.eventColorForeground = @[\n rgb(0, 0, 0).to(Color),\n rgb(0, 0, 0).to(Color),\n rgb(0, 0, 0).to(Color),\n rgb(0, 0, 0).to(Color),\n rgb(0, 0, 0).to(Color),\n rgb(0, 0, 0).to(Color),\n rgb(0, 0, 0).to(Color)\n ]\n self.appConfig.eventColorBackground = @[\n rgb(166, 181, 249).to(Color), # bright blue\n rgb(135, 241, 162).to(Color), # bright green\n rgb(237, 196, 138).to(Color), # orange\n rgb(248, 168, 164).to(Color), # bright red\n rgb(230, 181, 255).to(Color), # violet\n rgb(160, 211, 241).to(Color), # sky\n rgb(255, 245, 160).to(Color), # yellow\n ]\n elif self.appConfig.theme == \"dark\" and self.appConfig.lastTheme != \"dark\":\n self.appConfig.lastTheme = \"dark\"\n self.setCommonThemeFonts()\n self.appConfig.backgroundColor = rgb(34, 34, 34).to(Color)\n self.appConfig.weekendBackgroundColor = rgb(45, 45, 45).to(Color)\n self.appConfig.todayStrokeColor = rgb(255, 0, 0).to(Color)\n self.appConfig.todayBackgroundColor = rgb(47, 1, 1).to(Color)\n self.appConfig.dateTextColor = rgb(255, 255, 255).to(Color)\n self.appConfig.eventTimeColor = rgb(255, 255, 255).to(Color)\n self.appConfig.eventTitleColor = rgb(255, 255, 255).to(Color)\n self.appConfig.titleTextColor = rgb(255, 255, 255).to(Color)\n self.appConfig.titleBackgroundColor = rgb(34, 34, 34).to(Color)\n self.appConfig.weekdayTextColor = rgb(255, 255, 255).to(Color)\n self.appConfig.weekdayBackgroundColor = rgb(30, 30, 30).to(Color)\n self.appConfig.gridColor = rgb(40, 40, 40).to(Color)\n self.appConfig.eventColorCount = 7\n self.appConfig.eventColorForeground = @[\n rgb(255, 255, 255).to(Color),\n rgb(255, 255, 255).to(Color),\n rgb(255, 255, 255).to(Color),\n rgb(255, 255, 255).to(Color),\n rgb(255, 255, 255).to(Color),\n rgb(255, 255, 255).to(Color),\n rgb(255, 255, 255).to(Color),\n ]\n self.appConfig.eventColorBackground = @[\n rgb(36, 89, 175).to(Color),\n rgb(15, 105, 39).to(Color),\n rgb(148, 101, 15).to(Color),\n rgb(136, 28, 18).to(Color),\n rgb(127, 18, 158).to(Color),\n rgb(15, 106, 149).to(Color),\n rgb(116, 128, 24).to(Color),\n ]\n\n# quick-and-simple width-based truncation so each line stays on one row\nproc truncateToWidth(text: string, fontSize, maxWidth: float32): string =\n # Average glyph width heuristic ~55% of font size\n let avgChar = max(1.0'f32, fontSize * 0.55)\n var maxChars = int(maxWidth / avgChar)\n if maxChars < 1: maxChars = 1\n if text.len <= maxChars: return text\n if maxChars <= 1: return \"…\"\n result = text[0 .. max(0, maxChars - 2)] & \"…\"\n\nproc hashTitle(s: string): uint32 =\n var h: uint32 = 5381\n for ch in s:\n h = ((h shl 5) + h) + uint32(ord(ch)) # djb2\n h\n\nproc pickColors*(self: App, title: string): (Color, Color) =\n let colorIndex = int(hashTitle(title) mod uint32(self.appConfig.eventColorCount))\n return (\n self.appConfig.eventColorBackground[colorIndex],\n self.appConfig.eventColorForeground[colorIndex],\n )\n\n# --- Helpers for robust all-day detection ------------------------------------\n\nproc getBoolLoose(n: JsonNode; defaultVal = false): bool =\n ## Returns a boolean even if the JSON value is a string/int like \"true\"/1.\n if n.isNil: return defaultVal\n case n.kind\n of JBool: n.getBool()\n of JString:\n let s = n.getStr().toLowerAscii()\n (s in [\"true\", \"1\", \"yes\", \"y\"])\n of JInt: n.getInt() != 0\n of JFloat: n.getFloat() != 0.0\n else: defaultVal\n\nproc looksLikeAllDay(startStr: string): bool =\n ## Consider YYYY-MM-DD (length 10) as all-day.\n ## Many feeds encode all-day starts without a time.\n if startStr.len == 10: return true\n # If we have a time part, treat \"00:00\" as possibly all-day\n if startStr.len >= 16:\n let hhmm = startStr[11..15]\n if hhmm == \"00:00\": return true\n false\n\n# Date utils for expanding multi-day events (no timezone assumptions needed)\nproc parseYMD(s: string; y, m, d: var int): bool =\n if s.len < 10: return false\n try:\n y = parseInt(s[0..3])\n m = parseInt(s[5..6])\n d = parseInt(s[8..9])\n return true\n except CatchableError:\n return false\n\nproc cmpYMD(aY, aM, aD, bY, bM, bD: int): int =\n ## -1 if ab\n if aY != bY: return (if aY < bY: -1 else: 1)\n if aM != bM: return (if aM < bM: -1 else: 1)\n if aD != bD: return (if aD < bD: -1 else: 1)\n 0\n\nproc incOneDay(y, m, d: var int) =\n var dd = d + 1\n var mm = m\n var yy = y\n let dim = daysInMonth(yy, mm)\n if dd > dim:\n dd = 1\n mm.inc\n if mm > 12:\n mm = 1\n yy.inc\n y = yy; m = mm; d = dd\n\n# -----------------------------------------------------------------------------\n\nproc timeToMinutes(start: string): int =\n ## Parse \"YYYY-MM-DD[ T]HH:MM...\" -> minutes since midnight.\n ## Returns a large sentinel if parsing fails.\n if start.len >= 16:\n try:\n let hh = parseInt(start[11..12])\n let mm = parseInt(start[14..15])\n return max(0, min(23, hh)) * 60 + max(0, min(59, mm))\n except CatchableError:\n discard\n # Put unknown/invalid times after normal timed events\n result = high(int) div 2\n\nproc dateOrdinalFromStart(start: string): int =\n var y, m, d: int\n if parseYMD(start, y, m, d):\n return y * 10000 + m * 100 + d\n 0\n\nproc makeLine(self: App; summary, start: string; isAllDay: bool): EventLine =\n var display = summary\n if self.appConfig.showEventTimes and not isAllDay and start.len >= 16:\n let timeStr = start[11..15]\n if timeStr.len > 0: display = timeStr & \" \" & summary\n let key =\n if isAllDay:\n dateOrdinalFromStart(start) # earlier start date => smaller key\n else:\n 100_000_000 + timeToMinutes(start) # ensure timed events come after all-day\n EventLine(display: display, isAllDay: isAllDay, colors: some(pickColors(self, summary)), sortKey: key)\n\nproc addEventLine(t: var Table[string, seq[EventLine]], key: string, line: EventLine) =\n if not t.hasKey(key): t[key] = @[]\n t[key].add(line)\n\nproc groupEvents*(self: App): Table[string, seq[EventLine]] =\n result = initTable[string, seq[EventLine]]()\n let events = self.appConfig.events\n if events == nil or events.kind != JArray:\n return\n for ev in events.items():\n let summary = ev{\"summary\"}.getStr()\n let start = ev{\"startTime\"}.getStr()\n let endStr = ev{\"endTime\"}.getStr(\"\")\n\n # Robustly detect all-day across different payload styles.\n var isAllDay =\n getBoolLoose(ev{\"allDay\"}) or\n getBoolLoose(ev{\"all_day\"}) or\n getBoolLoose(ev{\"isAllDay\"}) or\n getBoolLoose(ev{\"allday\"}) or\n looksLikeAllDay(start)\n\n # If we have an endTime and the event is (or looks) all-day/date-only, expand across each day (inclusive)\n var sy, sm, sd, ey, em, ed: int\n if endStr.len >= 10 and parseYMD(start, sy, sm, sd) and parseYMD(endStr, ey, em, ed) and (isAllDay or start.len == 10):\n # Ensure start <= end; if not, clamp to start\n var y = sy; var m = sm; var d = sd\n if cmpYMD(sy, sm, sd, ey, em, ed) > 0:\n addEventLine(result, &\"{y:04}-{m:02}-{d:02}\", self.makeLine(summary, start, isAllDay))\n else:\n var guard = 0\n while cmpYMD(y, m, d, ey, em, ed) <= 0 and guard < 1000:\n addEventLine(result, &\"{y:04}-{m:02}-{d:02}\", self.makeLine(summary, start, isAllDay))\n incOneDay(y, m, d)\n inc guard\n else:\n if start.len >= 10:\n addEventLine(result, start[0..9], self.makeLine(summary, start, isAllDay))\n\nproc sortEventLines*(eventsByDay: var Table[string, seq[EventLine]]) =\n for _, daySeq in eventsByDay.mpairs:\n sort(daySeq, proc (a, b: EventLine): int =\n let c = system.cmp(a.sortKey, b.sortKey)\n if c != 0: c else: system.cmp(a.display, b.display)\n )\n\nproc render*(self: App, context: ExecutionContext, image: Image) =\n self.setTheme()\n # Current date/time (use LOCAL time zone; FrameOS sets TZ to the user's zone, e.g., Europe/Brussels)\n let nowLocal = times.now()\n let defaultYear = nowLocal.year\n let defaultMonth = nowLocal.month.ord\n let todayDay = nowLocal.monthday\n let year = if self.appConfig.year == 0: defaultYear else: self.appConfig.year\n let month = if self.appConfig.month == 0: defaultMonth else: self.appConfig.month\n let isCurrentMonth = (year == defaultYear) and (month == defaultMonth)\n\n # Scale (percentage -> factor)\n let s = max(0.01'f32, self.appConfig.scale.float32 / 100.0'f32)\n\n # Calendar structure\n let startMonday = self.appConfig.startWeekOnMonday\n let days = daysInMonth(year, month)\n let firstWeekday = weekday(year, month, 1)\n let firstCol = if startMonday: (firstWeekday + 6) mod 7 else: firstWeekday\n let totalCells = firstCol + days\n let rows = (totalCells + 6) div 7\n\n # Layout areas\n let p = max(self.appConfig.padding, 0).float32 * s\n let gridStroke = round(max(self.appConfig.gridWidth, 1.0).float32 * s)\n\n if not self.appConfig.transparentBackground:\n image.fill(self.appConfig.backgroundColor)\n\n # Content region (inside padding)\n let contentX = p\n let contentY = p\n let contentW = image.width.float32 - 2f*p\n let contentH = image.height.float32 - 2f*p\n\n # Heights (use split sizes)\n let weekdayHeaderHeight = self.appConfig.weekdayFontSize.float32 * s * 1.5\n let titleShown = self.appConfig.showMonthYear and (self.appConfig.monthYearPosition.toLowerAscii() in [\"top\", \"bottom\"])\n let titleHeight = if titleShown: self.appConfig.titleFontSize.float32 * s * 1.8 else: 0f\n let topTitle = if titleShown and self.appConfig.monthYearPosition.toLowerAscii() == \"top\": titleHeight else: 0f\n let bottomTitle = if titleShown and self.appConfig.monthYearPosition.toLowerAscii() == \"bottom\": titleHeight else: 0f\n\n let gridHeight = contentH - weekdayHeaderHeight - topTitle - bottomTitle\n let cellWidth = contentW / 7.0\n let cellHeight = gridHeight / rows.float32\n\n # Fonts (apply scaling)\n let titleTypeface = getTypeface(self.appConfig.titleFont, self.frameConfig.assetsPath)\n let titleFont = newFont(titleTypeface, self.appConfig.titleFontSize * s, self.appConfig.titleTextColor)\n let weekdayTypeface = getTypeface(self.appConfig.weekdayFont, self.frameConfig.assetsPath)\n let weekdayFont = newFont(weekdayTypeface, self.appConfig.weekdayFontSize * s, self.appConfig.weekdayTextColor)\n let dateTypeface = getTypeface(self.appConfig.dateFont, self.frameConfig.assetsPath)\n let dateFont = newFont(dateTypeface, self.appConfig.dateFontSize * s, self.appConfig.dateTextColor)\n let eventTimeTypeface = getTypeface(self.appConfig.eventTimeFont, self.frameConfig.assetsPath)\n let eventTimeFont = newFont(eventTimeTypeface, self.appConfig.eventFontSize * s, self.appConfig.eventTimeColor)\n let eventTitleTypeface = getTypeface(self.appConfig.eventTitleFont, self.frameConfig.assetsPath)\n let eventTitleFont = newFont(eventTitleTypeface, self.appConfig.eventFontSize * s, self.appConfig.eventTitleColor)\n\n # Title (Month Year) at top or bottom\n if titleShown:\n let monthNames = @[\"January\", \"February\", \"March\", \"April\", \"May\", \"June\", \"July\", \"August\", \"September\", \"October\",\n \"November\", \"December\"]\n let titleText = &\"{monthNames[month-1]} {year}\"\n\n # Draw a background for the title\n let ty = if topTitle > 0: contentY else: contentY + contentH - bottomTitle\n var titleBg = newImage(contentW.int, titleHeight.int)\n titleBg.fill(self.appConfig.titleBackgroundColor)\n image.draw(titleBg, translate(vec2(contentX, ty)))\n\n let types = typeset(\n spans = [newSpan(titleText, titleFont)],\n bounds = vec2(contentW, titleHeight),\n hAlign = CenterAlign,\n vAlign = MiddleAlign,\n )\n image.fillText(types, translate(vec2(contentX, ty)))\n\n # Weekday header background\n var headerImg = newImage(contentW.int, weekdayHeaderHeight.int)\n headerImg.fill(self.appConfig.weekdayBackgroundColor)\n image.draw(headerImg, translate(vec2(contentX, contentY + topTitle)))\n\n # Weekday labels\n let weekdays = if startMonday:\n @[\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"]\n else:\n @[\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"]\n\n for i, dayName in weekdays:\n let types = typeset(\n spans = [newSpan(dayName, weekdayFont)],\n bounds = vec2(cellWidth, weekdayHeaderHeight),\n hAlign = CenterAlign,\n vAlign = MiddleAlign,\n )\n image.fillText(types, translate(vec2(contentX + i.float32 * cellWidth, contentY + topTitle)))\n\n # Weekend backgrounds (draw BEFORE grid so grid stays visible)\n block shadeWeekends:\n var d = 1\n for row in 0.. days:\n break\n let wd = weekday(year, month, d) # 0=Sun .. 6=Sat\n if wd == 0 or wd == 6:\n let x = contentX + col.float32 * cellWidth\n let y = contentY + topTitle + weekdayHeaderHeight + row.float32 * cellHeight\n var bg = newImage(cellWidth.int, cellHeight.int)\n bg.fill(self.appConfig.weekendBackgroundColor)\n image.draw(bg, translate(vec2(x, y)))\n d += 1\n if d > days:\n break\n\n # Today's background (also BEFORE grid so lines remain visible)\n block shadeToday:\n if isCurrentMonth:\n let d = todayDay\n let idx = firstCol + (d - 1)\n let row = idx div 7\n let col = idx mod 7\n let x = contentX + col.float32 * cellWidth\n let y = contentY + topTitle + weekdayHeaderHeight + row.float32 * cellHeight\n var bg = newImage(cellWidth.int, cellHeight.int)\n bg.fill(self.appConfig.todayBackgroundColor)\n image.draw(bg, translate(vec2(x, y)))\n\n # Grid\n if self.appConfig.showGrid:\n for i in 0..7:\n let x = contentX + i.float32 * cellWidth\n var vLine = newImage(max(1, gridStroke.int), gridHeight.int)\n vLine.fill(self.appConfig.gridColor)\n image.draw(vLine, translate(vec2(x, contentY + topTitle + weekdayHeaderHeight)))\n for i in 0..rows:\n let y = contentY + topTitle + weekdayHeaderHeight + i.float32 * cellHeight\n var hLine = newImage(contentW.int, max(1, gridStroke.int))\n hLine.fill(self.appConfig.gridColor)\n image.draw(hLine, translate(vec2(contentX, y)))\n\n # Events and dates\n var eventsByDay = self.groupEvents()\n\n # Ensure events within each day are sorted (all-day first, then by start time)\n sortEventLines(eventsByDay)\n\n var day = 1\n for row in 0.. days:\n break\n let x = contentX + col.float32 * cellWidth\n let y = contentY + topTitle + weekdayHeaderHeight + row.float32 * cellHeight\n\n # Highlight \"today\" with a configurable stroke rectangle\n if isCurrentMonth and day == todayDay:\n let strokeF = max(0.0'f32, self.appConfig.todayStrokeWidth.float32 * s)\n let stroke = max(0, strokeF.int)\n if stroke > 0:\n # top\n var t = newImage(cellWidth.int, stroke)\n t.fill(self.appConfig.todayStrokeColor)\n image.draw(t, translate(vec2(x, y)))\n # bottom\n image.draw(t, translate(vec2(x, y + cellHeight - stroke.float32)))\n # left\n var l = newImage(stroke, cellHeight.int)\n l.fill(self.appConfig.todayStrokeColor)\n image.draw(l, translate(vec2(x, y)))\n # right\n image.draw(l, translate(vec2(x + cellWidth - stroke.float32, y)))\n\n # Date number\n let dateText = $day\n let datePadX = 4f * s\n let datePadY = 3f * s\n let dateBoundsH = self.appConfig.dateFontSize.float32 * s + 6f * s\n let dateTypes = typeset(\n spans = [newSpan(dateText, dateFont)],\n bounds = vec2(cellWidth - (datePadX + 2f*s), dateBoundsH),\n hAlign = LeftAlign,\n vAlign = TopAlign,\n )\n image.fillText(dateTypes, translate(vec2(x + datePadX, y + datePadY)))\n\n # Events\n let key = &\"{year:04}-{month:02}-{day:02}\"\n if eventsByDay.hasKey(key):\n # Determine how many lines fit and truncate each line to stay on one row.\n let availableH = cellHeight - (self.appConfig.dateFontSize.float32 * s) - 9f*s\n let lineH = (self.appConfig.eventFontSize.float32 * s) + 2f*s\n let lineGap = 1f * s\n var maxLines = int((availableH + lineGap) / (lineH + lineGap))\n if maxLines < 0: maxLines = 0\n\n var visibleCount = 0\n var needMoreLine = false\n let total = eventsByDay[key].len\n\n if total > maxLines and maxLines > 0:\n visibleCount = max(0, maxLines - 1) # reserve last line for \"+N more\"\n needMoreLine = true\n else:\n visibleCount = min(total, maxLines)\n\n # Build the actual lines (possibly trimmed) we will draw\n var linesToDraw: seq[EventLine] = @[]\n for i in 0 ..< visibleCount:\n var ev = eventsByDay[key][i]\n let trimW = if ev.isAllDay: (cellWidth - 12f*s) else: (cellWidth - 6f*s)\n ev.display = truncateToWidth(ev.display, (self.appConfig.eventFontSize.float32 * s), trimW)\n linesToDraw.add(ev)\n\n if needMoreLine and maxLines > 0:\n let remaining = total - visibleCount\n if remaining > 0:\n linesToDraw.add(EventLine(display: &\"+{remaining} more\", isAllDay: false, colors: none((Color, Color)),\n sortKey: high(int)))\n\n # Draw each line individually so we can paint chips for all-day events\n let baseY = y + (self.appConfig.dateFontSize.float32 * s) + 6f*s\n for li, line in linesToDraw:\n let yLine = baseY + li.float32 * (lineH + lineGap)\n\n if line.isAllDay and not line.display.startsWith(\"+\"):\n # Chip background\n let bg = if line.colors.isSome(): line.colors.get()[0] else: self.appConfig.backgroundColor\n let padX = 4f * s\n let chipW = cellWidth - (padX * 2)\n let chipH = lineH\n let chipX = x + padX\n let chipY = yLine\n var chip = newImage(chipW.int, chipH.int)\n chip.fill(bg)\n image.draw(chip, translate(vec2(chipX, chipY)))\n\n # Build text for the line (time part + bold title for timed events)\n var spans: seq[Span] = @[]\n if line.display.len >= 6 and line.display[2] == ':' and line.display[5] == ' ':\n let timePart = line.display[0..4] & \" \"\n let namePart = line.display[6..^1]\n spans.add(newSpan(timePart, eventTimeFont))\n spans.add(newSpan(namePart, eventTitleFont))\n else:\n # Either an all-day event line or the \"+N more\" line\n if line.display.startsWith(\"+\"):\n spans.add(newSpan(line.display, eventTimeFont))\n else:\n let fg = if line.colors.isSome(): line.colors.get()[1] else: self.appConfig.eventTitleColor\n if fg != eventTitleFont.paint.color:\n let font = cloneFontWithColor(eventTitleFont, fg)\n spans.add(newSpan(line.display, font))\n else:\n spans.add(newSpan(line.display, eventTitleFont))\n\n let bx = x + (if line.isAllDay and not line.display.startsWith(\"+\"): 8f*s else: 4f*s)\n let bw = cellWidth - (if line.isAllDay and not line.display.startsWith(\"+\"): 12f*s else: 6f*s)\n let bH = if line.isAllDay and not line.display.startsWith(\"+\"): (lineH - 2f*s) else: lineH\n\n let types = typeset(\n spans = spans,\n bounds = vec2(bw, bH),\n hAlign = LeftAlign,\n vAlign = if line.isAllDay and not line.display.startsWith(\"+\"): MiddleAlign else: TopAlign,\n )\n image.fillText(types, translate(vec2(bx, yLine + (if line.isAllDay and not line.display.startsWith(\n \"+\"): 1f*s else: 0f))))\n day += 1\n if day > days:\n break\n\nproc run*(self: App, context: ExecutionContext) =\n render(self, context, context.image)\n\nproc get*(self: App, context: ExecutionContext): Image =\n result = if self.appConfig.inputImage.isSome:\n self.appConfig.inputImage.get()\n elif context.hasImage:\n newImage(context.image.width, context.image.height)\n else:\n newImage(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())\n render(self, context, result)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n inputImage: none(Image),\n events: params{\"events\"},\n year: block:\n var v = 0\n if params.hasKey(\"year\"):\n let n = params{\"year\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n month: block:\n var v = 0\n if params.hasKey(\"month\"):\n let n = params{\"month\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n startWeekOnMonday: block:\n var v = true\n if params.hasKey(\"startWeekOnMonday\"):\n let n = params{\"startWeekOnMonday\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n scale: block:\n var v = 100\n if params.hasKey(\"scale\"):\n let n = params{\"scale\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n theme: params{\"theme\"}.getStr(\"light\"),\n transparentBackground: block:\n var v = false\n if params.hasKey(\"transparentBackground\"):\n let n = params{\"transparentBackground\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n backgroundColor: block:\n var v: Color = parseHtmlColor(\"#ffffff\")\n if params.hasKey(\"backgroundColor\"):\n let n = params{\"backgroundColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n weekendBackgroundColor: block:\n var v: Color = parseHtmlColor(\"#9eafd6\")\n if params.hasKey(\"weekendBackgroundColor\"):\n let n = params{\"weekendBackgroundColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n todayStrokeColor: block:\n var v: Color = parseHtmlColor(\"#ff0000\")\n if params.hasKey(\"todayStrokeColor\"):\n let n = params{\"todayStrokeColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n todayBackgroundColor: block:\n var v: Color = parseHtmlColor(\"#e5afaf\")\n if params.hasKey(\"todayBackgroundColor\"):\n let n = params{\"todayBackgroundColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n todayStrokeWidth: block:\n var v = 2.0\n if params.hasKey(\"todayStrokeWidth\"):\n let n = params{\"todayStrokeWidth\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n dateTextColor: block:\n var v: Color = parseHtmlColor(\"#000000\")\n if params.hasKey(\"dateTextColor\"):\n let n = params{\"dateTextColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n eventTimeColor: block:\n var v: Color = parseHtmlColor(\"#333333\")\n if params.hasKey(\"eventTimeColor\"):\n let n = params{\"eventTimeColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n eventTitleColor: block:\n var v: Color = parseHtmlColor(\"#333333\")\n if params.hasKey(\"eventTitleColor\"):\n let n = params{\"eventTitleColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n titleFont: params{\"titleFont\"}.getStr(\"\"),\n titleFontSize: block:\n var v = 28.0\n if params.hasKey(\"titleFontSize\"):\n let n = params{\"titleFontSize\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n titleTextColor: block:\n var v: Color = parseHtmlColor(\"#000000\")\n if params.hasKey(\"titleTextColor\"):\n let n = params{\"titleTextColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n titleBackgroundColor: block:\n var v: Color = parseHtmlColor(\"#ffffff\")\n if params.hasKey(\"titleBackgroundColor\"):\n let n = params{\"titleBackgroundColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n weekdayFont: params{\"weekdayFont\"}.getStr(\"\"),\n weekdayFontSize: block:\n var v = 16.0\n if params.hasKey(\"weekdayFontSize\"):\n let n = params{\"weekdayFontSize\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n weekdayTextColor: block:\n var v: Color = parseHtmlColor(\"#000000\")\n if params.hasKey(\"weekdayTextColor\"):\n let n = params{\"weekdayTextColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n weekdayBackgroundColor: block:\n var v: Color = parseHtmlColor(\"#ffffff\")\n if params.hasKey(\"weekdayBackgroundColor\"):\n let n = params{\"weekdayBackgroundColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n dateFont: params{\"dateFont\"}.getStr(\"\"),\n dateFontSize: block:\n var v = 18.0\n if params.hasKey(\"dateFontSize\"):\n let n = params{\"dateFontSize\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n eventTitleFont: params{\"eventTitleFont\"}.getStr(\"Ubuntu-Medium.ttf\"),\n eventTimeFont: params{\"eventTimeFont\"}.getStr(\"Ubuntu-Light.ttf\"),\n eventFontSize: block:\n var v = 14.0\n if params.hasKey(\"eventFontSize\"):\n let n = params{\"eventFontSize\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n padding: block:\n var v = 18\n if params.hasKey(\"padding\"):\n let n = params{\"padding\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n showMonthYear: block:\n var v = true\n if params.hasKey(\"showMonthYear\"):\n let n = params{\"showMonthYear\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n monthYearPosition: params{\"monthYearPosition\"}.getStr(\"top\"),\n showGrid: block:\n var v = true\n if params.hasKey(\"showGrid\"):\n let n = params{\"showGrid\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n gridWidth: block:\n var v = 1.0\n if params.hasKey(\"gridWidth\"):\n let n = params{\"gridWidth\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n gridColor: block:\n var v: Color = parseHtmlColor(\"#999999\")\n if params.hasKey(\"gridColor\"):\n let n = params{\"gridColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n showEventTimes: block:\n var v = true\n if params.hasKey(\"showEventTimes\"):\n let n = params{\"showEventTimes\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n eventColorCount: block:\n var v = 1\n if params.hasKey(\"eventColorCount\"):\n let n = params{\"eventColorCount\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n eventColorBackground: block:\n let start1 = 1\n let stop1 = block:\n var v = 1\n if params.hasKey(\"eventColorCount\"):\n let n = params{\"eventColorCount\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v\n let size1 = (if stop1 >= start1: stop1 - start1 + 1 else: 0)\n var output: seq[Color]\n output = newSeq[Color](size1)\n for i1 in start1..stop1:\n var v: Color = parseHtmlColor(\"#ffffff\")\n let k = \"eventColorBackground\" & \"[\" & $i1 & \"]\"\n if params.hasKey(k) and params[k].kind == JString:\n v = parseHtmlColor(params[k].getStr())\n output[i1 - start1] = v\n output,\n eventColorForeground: block:\n let start1 = 1\n let stop1 = block:\n var v = 1\n if params.hasKey(\"eventColorCount\"):\n let n = params{\"eventColorCount\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v\n let size1 = (if stop1 >= start1: stop1 - start1 + 1 else: 0)\n var output: seq[Color]\n output = newSeq[Color](size1)\n for i1 in start1..stop1:\n var v: Color = parseHtmlColor(\"#000000\")\n let k = \"eventColorForeground\" & \"[\" & $i1 & \"]\"\n if params.hasKey(k) and params[k].kind == JString:\n v = parseHtmlColor(params[k].getStr())\n output[i1 - start1] = v\n output,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"inputImage\":\n app.appConfig.inputImage = (if value.kind == fkImage: some(value.asImage()) else: none(Image))\n of \"events\":\n app.appConfig.events = value.asJson()\n of \"year\":\n app.appConfig.year = value.asInt().int\n of \"month\":\n app.appConfig.month = value.asInt().int\n of \"startWeekOnMonday\":\n app.appConfig.startWeekOnMonday = value.asBool()\n of \"scale\":\n app.appConfig.scale = value.asInt().int\n of \"theme\":\n app.appConfig.theme = value.asString()\n of \"transparentBackground\":\n app.appConfig.transparentBackground = value.asBool()\n of \"backgroundColor\":\n app.appConfig.backgroundColor = value.asColor()\n of \"weekendBackgroundColor\":\n app.appConfig.weekendBackgroundColor = value.asColor()\n of \"todayStrokeColor\":\n app.appConfig.todayStrokeColor = value.asColor()\n of \"todayBackgroundColor\":\n app.appConfig.todayBackgroundColor = value.asColor()\n of \"todayStrokeWidth\":\n app.appConfig.todayStrokeWidth = value.asFloat()\n of \"dateTextColor\":\n app.appConfig.dateTextColor = value.asColor()\n of \"eventTimeColor\":\n app.appConfig.eventTimeColor = value.asColor()\n of \"eventTitleColor\":\n app.appConfig.eventTitleColor = value.asColor()\n of \"titleFont\":\n app.appConfig.titleFont = value.asString()\n of \"titleFontSize\":\n app.appConfig.titleFontSize = value.asFloat()\n of \"titleTextColor\":\n app.appConfig.titleTextColor = value.asColor()\n of \"titleBackgroundColor\":\n app.appConfig.titleBackgroundColor = value.asColor()\n of \"weekdayFont\":\n app.appConfig.weekdayFont = value.asString()\n of \"weekdayFontSize\":\n app.appConfig.weekdayFontSize = value.asFloat()\n of \"weekdayTextColor\":\n app.appConfig.weekdayTextColor = value.asColor()\n of \"weekdayBackgroundColor\":\n app.appConfig.weekdayBackgroundColor = value.asColor()\n of \"dateFont\":\n app.appConfig.dateFont = value.asString()\n of \"dateFontSize\":\n app.appConfig.dateFontSize = value.asFloat()\n of \"eventTitleFont\":\n app.appConfig.eventTitleFont = value.asString()\n of \"eventTimeFont\":\n app.appConfig.eventTimeFont = value.asString()\n of \"eventFontSize\":\n app.appConfig.eventFontSize = value.asFloat()\n of \"padding\":\n app.appConfig.padding = value.asInt().int\n of \"showMonthYear\":\n app.appConfig.showMonthYear = value.asBool()\n of \"monthYearPosition\":\n app.appConfig.monthYearPosition = value.asString()\n of \"showGrid\":\n app.appConfig.showGrid = value.asBool()\n of \"gridWidth\":\n app.appConfig.gridWidth = value.asFloat()\n of \"gridColor\":\n app.appConfig.gridColor = value.asColor()\n of \"showEventTimes\":\n app.appConfig.showEventTimes = value.asBool()\n of \"eventColorCount\":\n app.appConfig.eventColorCount = value.asInt().int\n of \"eventColorBackground\":\n if value.kind == fkJson:\n let n = value.asJson()\n if n.kind == JArray:\n var arr: seq[Color] = @[]\n for it in n.items():\n if it.kind == JString: arr.add(parseHtmlColor(it.getStr()))\n app.appConfig.eventColorBackground = arr\n else:\n raise newException(ValueError, \"Expected JSON array for seq field: eventColorBackground\")\n else:\n raise newException(ValueError, \"Expected JSON for seq field: eventColorBackground\")\n of \"eventColorForeground\":\n if value.kind == fkJson:\n let n = value.asJson()\n if n.kind == JArray:\n var arr: seq[Color] = @[]\n for it in n.items():\n if it.kind == JString: arr.add(parseHtmlColor(it.getStr()))\n app.appConfig.eventColorForeground = arr\n else:\n raise newException(ValueError, \"Expected JSON array for seq field: eventColorForeground\")\n else:\n raise newException(ValueError, \"Expected JSON for seq field: eventColorForeground\")\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Calendar\",\n \"description\": \"Render a monthly calendar with events\",\n \"category\": \"render\",\n \"version\": \"1.3.0\",\n \"fields\": [\n {\n \"name\": \"inputImage\",\n \"type\": \"image\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Background image\"\n },\n {\n \"name\": \"events\",\n \"type\": \"json\",\n \"required\": false,\n \"value\": \"[]\",\n \"label\": \"Events JSON\"\n },\n {\n \"name\": \"year\",\n \"type\": \"integer\",\n \"value\": \"0\",\n \"required\": false,\n \"label\": \"Year (0 = current)\"\n },\n {\n \"name\": \"month\",\n \"type\": \"integer\",\n \"value\": \"0\",\n \"required\": false,\n \"label\": \"Month (1–12; 0 = current)\"\n },\n {\n \"name\": \"startWeekOnMonday\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": true,\n \"label\": \"Week starts on Monday\"\n },\n {\n \"name\": \"scale\",\n \"type\": \"integer\",\n \"value\": \"100\",\n \"required\": true,\n \"label\": \"Scale (%) — scales fonts, paddings, strokes\"\n },\n {\n \"name\": \"theme\",\n \"type\": \"select\",\n \"options\": [\n \"light\",\n \"dark\",\n \"custom\"\n ],\n \"value\": \"light\",\n \"required\": true,\n \"label\": \"Calendar theme\"\n },\n {\n \"name\": \"transparentBackground\",\n \"type\": \"boolean\",\n \"value\": false,\n \"required\": true,\n \"label\": \"Transparent background\"\n },\n {\n \"name\": \"backgroundColor\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"required\": true,\n \"label\": \"Overall background color\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"weekendBackgroundColor\",\n \"type\": \"color\",\n \"value\": \"#9eafd6\",\n \"required\": true,\n \"label\": \"Weekend day background color (Sat/Sun cells)\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"todayStrokeColor\",\n \"type\": \"color\",\n \"value\": \"#ff0000\",\n \"required\": true,\n \"label\": \"Today's cell outline color\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"todayBackgroundColor\",\n \"type\": \"color\",\n \"value\": \"#e5afaf\",\n \"required\": true,\n \"label\": \"Today's cell background color\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"todayStrokeWidth\",\n \"type\": \"float\",\n \"value\": \"2\",\n \"required\": true,\n \"label\": \"Today's cell outline thickness\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"dateTextColor\",\n \"type\": \"color\",\n \"value\": \"#000000\",\n \"required\": true,\n \"label\": \"Date number color\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"eventTimeColor\",\n \"type\": \"color\",\n \"value\": \"#333333\",\n \"required\": true,\n \"label\": \"Event time color\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"eventTitleColor\",\n \"type\": \"color\",\n \"value\": \"#333333\",\n \"required\": true,\n \"label\": \"Event title color\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"titleFont\",\n \"type\": \"font\",\n \"required\": false,\n \"label\": \"Title font (used for month & year)\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"titleFontSize\",\n \"type\": \"float\",\n \"value\": \"28\",\n \"required\": true,\n \"label\": \"Month & year title font size\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"titleTextColor\",\n \"type\": \"color\",\n \"value\": \"#000000\",\n \"required\": true,\n \"label\": \"Title text color\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"titleBackgroundColor\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"required\": true,\n \"label\": \"Title background\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"weekdayFont\",\n \"type\": \"font\",\n \"required\": false,\n \"label\": \"Weekday font (used for weekday row)\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"weekdayFontSize\",\n \"type\": \"float\",\n \"value\": \"16\",\n \"required\": true,\n \"label\": \"Weekday row font size\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"weekdayTextColor\",\n \"type\": \"color\",\n \"value\": \"#000000\",\n \"required\": true,\n \"label\": \"Weekday text color\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"weekdayBackgroundColor\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"required\": true,\n \"label\": \"Weekday background\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"dateFont\",\n \"type\": \"font\",\n \"required\": false,\n \"label\": \"Date number font\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"dateFontSize\",\n \"type\": \"float\",\n \"value\": \"18\",\n \"required\": true,\n \"label\": \"Date number font size\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"eventTitleFont\",\n \"type\": \"font\",\n \"required\": false,\n \"label\": \"Event title font\",\n \"value\": \"Ubuntu-Medium.ttf\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"eventTimeFont\",\n \"type\": \"font\",\n \"required\": false,\n \"label\": \"Event time font\",\n \"value\": \"Ubuntu-Light.ttf\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"eventFontSize\",\n \"type\": \"float\",\n \"value\": \"14\",\n \"required\": true,\n \"label\": \"Event font size\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"padding\",\n \"type\": \"integer\",\n \"value\": \"18\",\n \"required\": true,\n \"label\": \"Outer padding (px)\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"showMonthYear\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": true,\n \"label\": \"Show month & year title\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"monthYearPosition\",\n \"type\": \"select\",\n \"options\": [\"top\", \"bottom\", \"none\"],\n \"value\": \"top\",\n \"required\": true,\n \"label\": \"Title position (ignored if hidden)\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"showGrid\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": true,\n \"label\": \"Show grid lines\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"gridWidth\",\n \"type\": \"float\",\n \"value\": \"1\",\n \"required\": true,\n \"label\": \"Grid stroke width (px)\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"gridColor\",\n \"type\": \"color\",\n \"value\": \"#999999\",\n \"required\": true,\n \"label\": \"Grid line color\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"name\": \"showEventTimes\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": true,\n \"label\": \"Show times for non all-day events\",\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"label\": \"All day event color count\",\n \"name\": \"eventColorCount\",\n \"value\": \"1\",\n \"type\": \"integer\",\n \"required\": true,\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"label\": \"All day event background #{color}\",\n \"name\": \"eventColorBackground\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"seq\": [\n [\"color\", 1, \"eventColorCount\"]\n ],\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n },\n {\n \"label\": \"All day event foreground #{color}\",\n \"name\": \"eventColorForeground\",\n \"type\": \"color\",\n \"value\": \"#000000\",\n \"seq\": [\n [\"color\", 1, \"eventColorCount\"]\n ],\n \"showIf\": [\n {\n \"field\": \"theme\",\n \"operator\": \"eq\",\n \"value\": \"custom\"\n }\n ]\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "render/chart": { + "app.nim": "import json\nimport math\nimport options\nimport strformat\nimport strutils\nimport pixie\nimport chroma\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/font\nimport frameos/utils/image\n\nconst seriesPalette* = [\n \"#2a78d6\", \"#1baf7a\", \"#eda100\", \"#008300\",\n \"#4a3aa7\", \"#e34948\", \"#e87ba4\", \"#eb6834\",\n]\n\ntype\n ChartSeries* = object\n name*: string\n color*: string\n values*: seq[float]\n\n ChartData* = object\n series*: seq[ChartSeries]\n labels*: seq[string]\n\n AppConfig* = object\n inputImage*: Option[Image]\n data*: JsonNode\n chartType*: string\n title*: string\n color*: Color\n transparentBackground*: bool\n backgroundColor*: Color\n axisColor*: Color\n showGrid*: bool\n showLabels*: bool\n minY*: string\n maxY*: string\n lineWidth*: float\n fontSize*: float\n padding*: float\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc isFiniteValue*(v: float): bool {.inline.} =\n v == v and v != Inf and v != NegInf\n\nproc formatValue*(v: float): string =\n if not isFiniteValue(v):\n return \"\"\n if abs(v - round(v)) < 1e-9 and abs(v) < 1e12:\n return $round(v).int\n result = formatFloat(v, ffDecimal, 2)\n result.trimZeros()\n\nproc toChartValue*(node: JsonNode): float =\n if node.isNil:\n return NaN\n case node.kind\n of JInt: node.getInt().float\n of JFloat: node.getFloat()\n of JBool: (if node.getBool(): 1.0 else: 0.0)\n of JString:\n try:\n parseFloat(node.getStr().strip())\n except ValueError:\n NaN\n else: NaN\n\nproc parseValues(node: JsonNode): seq[float] =\n result = @[]\n if node.isNil or node.kind != JArray:\n return\n for item in node.items:\n result.add(toChartValue(item))\n\nproc parseLabels(node: JsonNode): seq[string] =\n result = @[]\n if node.isNil or node.kind != JArray:\n return\n for item in node.items:\n case item.kind\n of JString: result.add(item.getStr())\n of JInt: result.add($item.getInt())\n of JFloat: result.add(formatValue(item.getFloat()))\n else: result.add(\"\")\n\nproc parseSeriesEntry(node: JsonNode): ChartSeries =\n result = ChartSeries()\n if node.isNil:\n return\n case node.kind\n of JArray:\n result.values = parseValues(node)\n of JObject:\n result.name = node{\"name\"}.getStr(node{\"label\"}.getStr(\"\"))\n result.color = node{\"color\"}.getStr(\"\")\n if node.hasKey(\"values\"):\n result.values = parseValues(node{\"values\"})\n elif node.hasKey(\"data\"):\n result.values = parseValues(node{\"data\"})\n else:\n result.values = @[toChartValue(node)]\n\nproc parseChartData*(node: JsonNode): ChartData =\n result = ChartData()\n if node.isNil:\n return\n # State fields and control forms often deliver json values as strings\n if node.kind == JString:\n let text = node.getStr().strip()\n if text.len == 0:\n return\n try:\n return parseChartData(parseJson(text))\n except CatchableError:\n return\n case node.kind\n of JArray:\n if node.len == 0:\n return\n var values: seq[float] = @[]\n var labels: seq[string] = @[]\n var hasLabels = false\n for item in node.items:\n if item.kind == JObject:\n let label = item{\"label\"}.getStr(item{\"name\"}.getStr(\"\"))\n if label.len > 0:\n hasLabels = true\n labels.add(label)\n if item.hasKey(\"value\"):\n values.add(toChartValue(item{\"value\"}))\n elif item.hasKey(\"y\"):\n values.add(toChartValue(item{\"y\"}))\n else:\n values.add(NaN)\n else:\n labels.add(\"\")\n values.add(toChartValue(item))\n result.series = @[ChartSeries(values: values)]\n if hasLabels:\n result.labels = labels\n of JObject:\n result.labels = parseLabels(node{\"labels\"})\n let seriesNode = node{\"series\"}\n if not seriesNode.isNil and seriesNode.kind == JArray:\n for item in seriesNode.items:\n let entry = parseSeriesEntry(item)\n if entry.values.len > 0:\n result.series.add(entry)\n elif node.hasKey(\"values\"):\n let entry = parseSeriesEntry(node)\n if entry.values.len > 0:\n result.series.add(entry)\n else:\n discard\n\nproc maxPoints*(data: ChartData): int =\n for series in data.series:\n if series.values.len > result:\n result = series.values.len\n\nproc hasFiniteValues*(data: ChartData): bool =\n for series in data.series:\n for v in series.values:\n if isFiniteValue(v):\n return true\n\nproc computeYRange*(data: ChartData, minYStr, maxYStr: string, includeZero: bool): tuple[lo, hi: float] =\n var lo = Inf\n var hi = NegInf\n for series in data.series:\n for v in series.values:\n if isFiniteValue(v):\n if v < lo: lo = v\n if v > hi: hi = v\n if lo > hi:\n lo = 0.0\n hi = 1.0\n if includeZero:\n lo = min(lo, 0.0)\n hi = max(hi, 0.0)\n if minYStr.strip().len > 0:\n try:\n let parsed = parseFloat(minYStr.strip())\n if isFiniteValue(parsed):\n lo = parsed\n except ValueError:\n discard\n if maxYStr.strip().len > 0:\n try:\n let parsed = parseFloat(maxYStr.strip())\n if isFiniteValue(parsed):\n hi = parsed\n except ValueError:\n discard\n if lo > hi:\n swap(lo, hi)\n if hi - lo < 1e-9:\n let pad = max(abs(lo) * 0.1, 1.0)\n lo -= pad\n hi += pad\n (lo, hi)\n\nproc valueToY*(v, lo, hi, plotY, plotH: float): float =\n if hi <= lo:\n return plotY + plotH\n plotY + plotH - (v - lo) / (hi - lo) * plotH\n\nproc pointX*(index, count: int, plotX, plotW: float, chartType: string): float =\n if count <= 0:\n return plotX + plotW / 2.0\n if chartType == \"bar\":\n plotX + (index.float + 0.5) * plotW / count.float\n elif count == 1:\n plotX + plotW / 2.0\n else:\n plotX + plotW * index.float / (count - 1).float\n\nproc barSlot*(groupIndex, groupCount, seriesIndex, seriesCount: int, plotW: float): tuple[x, w: float] =\n if groupCount <= 0 or seriesCount <= 0:\n return (0.0, 0.0)\n let groupW = plotW / groupCount.float\n let innerW = groupW * 0.8\n let gap = min(2.0, innerW / (seriesCount.float * 4.0))\n let barW = max(1.0, (innerW - gap * (seriesCount - 1).float) / seriesCount.float)\n let x0 = groupIndex.float * groupW + (groupW - innerW) / 2.0\n (x0 + seriesIndex.float * (barW + gap), barW)\n\nproc labelStep*(count: int, plotW, labelWidth: float): int =\n if count <= 1 or labelWidth <= 0 or plotW <= 0:\n return 1\n max(1, ceil(count.float * labelWidth / plotW).int)\n\nproc seriesColor*(baseColor: Color, series: ChartSeries, index: int): Color =\n if series.color.len > 0:\n try:\n return parseHtmlColor(series.color)\n except CatchableError:\n discard\n if index == 0:\n return baseColor\n parseHtmlColor(seriesPalette[index mod seriesPalette.len])\n\nproc init*(self: App) =\n if self.appConfig.chartType notin [\"line\", \"bar\", \"area\"]:\n self.appConfig.chartType = \"line\"\n if self.appConfig.fontSize <= 0:\n self.appConfig.fontSize = 16.0\n if self.appConfig.lineWidth <= 0:\n self.appConfig.lineWidth = 2.0\n if self.appConfig.padding < 0:\n self.appConfig.padding = 0.0\n self.appConfig.minY = self.appConfig.minY.strip()\n self.appConfig.maxY = self.appConfig.maxY.strip()\n\nproc canvasIsDark(image: Image): bool =\n ## Sample a few canvas pixels to tell dark backgrounds from light ones\n if image.isNil or image.width <= 0 or image.height <= 0:\n return false\n var luminance = 0.0\n var samples = 0\n for (fx, fy) in [(0.5, 0.5), (0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8)]:\n let px = image.unsafe[int(fx * float(image.width - 1)), int(fy * float(image.height - 1))]\n luminance += 0.21 * px.r.float + 0.72 * px.g.float + 0.07 * px.b.float\n inc samples\n luminance / samples.float < 100.0\n\nproc inkColor(self: App, image: Image): Color =\n if self.appConfig.axisColor.a > 0:\n self.appConfig.axisColor\n elif canvasIsDark(image):\n color(0.9, 0.9, 0.9, 1)\n else:\n color(0, 0, 0, 1)\n\nproc drawTextBox(self: App, image: Image, text: string, font: Font,\n x, y, w, h: float, hAlign: HorizontalAlignment, vAlign: VerticalAlignment) =\n let types = typeset(\n spans = [newSpan(text, font)],\n bounds = vec2(w.float32, h.float32),\n hAlign = hAlign,\n vAlign = vAlign,\n )\n image.fillText(types, translate(vec2(x.float32, y.float32)))\n\nproc drawMessage(self: App, image: Image, message: string) =\n let size = clamp(min(image.width, image.height).float / 8.0, 8.0, 32.0)\n let font = newFont(getDefaultTypeface(), size, self.inkColor(image))\n self.drawTextBox(image, message, font, 0.0, 0.0, image.width.float, image.height.float,\n CenterAlign, MiddleAlign)\n\nproc drawLineSeries(self: App, image: Image, values: seq[float], count: int,\n plotX, plotY, plotW, plotH, lo, hi: float, col: Color, lineWidth: float,\n fillArea: bool, baselineY: float) =\n var i = 0\n while i < values.len:\n if not isFiniteValue(values[i]):\n inc i\n continue\n var runEnd = i\n while runEnd + 1 < values.len and isFiniteValue(values[runEnd + 1]):\n inc runEnd\n if runEnd == i:\n let x = pointX(i, count, plotX, plotW, \"line\")\n let y = valueToY(values[i], lo, hi, plotY, plotH)\n let dot = newPath()\n dot.circle(x.float32, y.float32, max(lineWidth, 2.0).float32)\n image.fillPath(dot, col)\n else:\n if fillArea:\n let fill = newPath()\n fill.moveTo(pointX(i, count, plotX, plotW, \"line\").float32, baselineY.float32)\n for j in i .. runEnd:\n fill.lineTo(pointX(j, count, plotX, plotW, \"line\").float32,\n valueToY(values[j], lo, hi, plotY, plotH).float32)\n fill.lineTo(pointX(runEnd, count, plotX, plotW, \"line\").float32, baselineY.float32)\n fill.closePath()\n image.fillPath(fill, color(col.r, col.g, col.b, col.a * 0.35))\n let stroke = newPath()\n stroke.moveTo(pointX(i, count, plotX, plotW, \"line\").float32,\n valueToY(values[i], lo, hi, plotY, plotH).float32)\n for j in (i + 1) .. runEnd:\n stroke.lineTo(pointX(j, count, plotX, plotW, \"line\").float32,\n valueToY(values[j], lo, hi, plotY, plotH).float32)\n image.strokePath(stroke, col, strokeWidth = lineWidth.float32,\n lineCap = RoundCap, lineJoin = RoundJoin)\n i = runEnd + 1\n\nproc render*(self: App, context: ExecutionContext, image: Image) =\n try:\n if not self.appConfig.transparentBackground and self.appConfig.backgroundColor.a > 0:\n image.fill(self.appConfig.backgroundColor)\n\n let data = parseChartData(self.appConfig.data)\n if data.series.len == 0 or not hasFiniteValues(data):\n self.drawMessage(image, \"No chart data\")\n return\n\n let chartType = if self.appConfig.chartType in [\"line\", \"bar\", \"area\"]: self.appConfig.chartType else: \"line\"\n let fontSize = max(self.appConfig.fontSize, 1.0)\n let padding = max(self.appConfig.padding, 0.0)\n let lineWidth = max(self.appConfig.lineWidth, 0.5)\n let ink = self.inkColor(image)\n let typeface = getDefaultTypeface()\n\n let includeZero = chartType in [\"bar\", \"area\"]\n let (lo, hi) = computeYRange(data, self.appConfig.minY, self.appConfig.maxY, includeZero)\n let points = maxPoints(data)\n\n var plotX = padding\n var plotY = padding\n let plotRight = image.width.float - padding\n var plotBottom = image.height.float - padding\n\n if self.appConfig.title.len > 0:\n let titleFont = newFont(typeface, fontSize * 1.25, ink)\n let titleHeight = fontSize * 1.75\n self.drawTextBox(image, self.appConfig.title, titleFont, plotX, plotY,\n max(plotRight - plotX, 1.0), titleHeight, CenterAlign, MiddleAlign)\n plotY += titleHeight\n\n let showLabels = self.appConfig.showLabels\n let labelFont = newFont(typeface, fontSize, ink)\n let loText = formatValue(lo)\n let hiText = formatValue(hi)\n var gutter = 0.0\n if showLabels:\n gutter = max(loText.len, hiText.len).float * fontSize * 0.6 + 6.0\n plotX += gutter\n if data.labels.len > 0:\n plotBottom -= fontSize * 1.5\n\n let plotW = plotRight - plotX\n let plotH = plotBottom - plotY\n if plotW < 8.0 or plotH < 8.0:\n self.drawMessage(image, \"Not enough space\")\n return\n\n if self.appConfig.showGrid:\n let gridColor = color(ink.r, ink.g, ink.b, ink.a * 0.25)\n for i in 0 .. 4:\n let y = plotY + plotH * i.float / 4.0\n let line = newPath()\n line.rect(plotX.float32, y.float32, plotW.float32, 1)\n image.fillPath(line, gridColor)\n\n let baselineValue = clamp(0.0, lo, hi)\n let baselineY = valueToY(baselineValue, lo, hi, plotY, plotH)\n\n for si, series in data.series:\n let col = seriesColor(self.appConfig.color, series, si)\n if chartType == \"bar\":\n let bars = newPath()\n if points > plotW.int:\n # More bars than pixels: bucket to one 1px column per pixel so the\n # path stays O(plot width) instead of O(points)\n let columns = max(plotW.int, 1)\n var topByCol = newSeq[float](columns)\n var bottomByCol = newSeq[float](columns)\n for c in 0 ..< columns:\n topByCol[c] = Inf\n bottomByCol[c] = NegInf\n for i in 0 ..< min(points, series.values.len):\n let v = series.values[i]\n if not isFiniteValue(v):\n continue\n let c = clamp(int(i.float * columns.float / points.float), 0, columns - 1)\n let y = valueToY(v, lo, hi, plotY, plotH)\n topByCol[c] = min(topByCol[c], min(y, baselineY))\n bottomByCol[c] = max(bottomByCol[c], max(y, baselineY))\n for c in 0 ..< columns:\n if topByCol[c] <= bottomByCol[c]:\n bars.rect((plotX + c.float).float32, topByCol[c].float32, 1,\n max(bottomByCol[c] - topByCol[c], 1.0).float32)\n else:\n for i in 0 ..< min(points, series.values.len):\n let v = series.values[i]\n if not isFiniteValue(v):\n continue\n let (slotX, slotW) = barSlot(i, points, si, data.series.len, plotW)\n let y = valueToY(v, lo, hi, plotY, plotH)\n let top = min(y, baselineY)\n let barH = max(abs(baselineY - y), 1.0)\n bars.rect((plotX + slotX).float32, top.float32, slotW.float32, barH.float32)\n image.fillPath(bars, col)\n else:\n self.drawLineSeries(image, series.values, points, plotX, plotY, plotW, plotH,\n lo, hi, col, lineWidth, chartType == \"area\", baselineY)\n\n block drawAxes:\n let yAxis = newPath()\n yAxis.rect(plotX.float32, plotY.float32, 1, plotH.float32)\n image.fillPath(yAxis, ink)\n let xAxis = newPath()\n xAxis.rect(plotX.float32, (plotBottom - 1.0).float32, plotW.float32, 1)\n image.fillPath(xAxis, ink)\n\n if showLabels:\n let labelH = fontSize * 1.3\n self.drawTextBox(image, hiText, labelFont, plotX - gutter,\n max(plotY - labelH / 2.0, 0.0), max(gutter - 4.0, 1.0), labelH, RightAlign, MiddleAlign)\n self.drawTextBox(image, loText, labelFont, plotX - gutter,\n min(plotBottom - labelH / 2.0, image.height.float - labelH), max(gutter - 4.0, 1.0),\n labelH, RightAlign, MiddleAlign)\n\n if data.labels.len > 0:\n var maxLen = 0\n for label in data.labels:\n if label.len > maxLen:\n maxLen = label.len\n let labelWidth = maxLen.float * fontSize * 0.6 + 8.0\n let step = labelStep(points, plotW, labelWidth)\n let boxW = max(labelWidth, plotW / max(points, 1).float)\n var i = 0\n while i < points:\n if i < data.labels.len and data.labels[i].len > 0:\n let x = pointX(i, points, plotX, plotW, chartType)\n let boxX = max(0.0, min(x - boxW / 2.0, image.width.float - boxW))\n self.drawTextBox(image, data.labels[i], labelFont, boxX,\n plotBottom + 2.0, boxW, fontSize * 1.4, CenterAlign, TopAlign)\n i += step\n except Exception as e:\n let message = &\"Error rendering chart: {e.msg}\"\n self.logError(message)\n renderErrorInto(image, image.width, image.height, message)\n\nproc run*(self: App, context: ExecutionContext) =\n render(self, context, context.image)\n\nproc get*(self: App, context: ExecutionContext): Image =\n result = if self.appConfig.inputImage.isSome:\n self.appConfig.inputImage.get()\n elif context.hasImage:\n newImage(context.image.width, context.image.height)\n else:\n newImage(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())\n render(self, context, result)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n inputImage: none(Image),\n data: params{\"data\"},\n chartType: params{\"chartType\"}.getStr(\"line\"),\n title: params{\"title\"}.getStr(\"\"),\n color: block:\n var v: Color = parseHtmlColor(\"#2a78d6\")\n if params.hasKey(\"color\"):\n let n = params{\"color\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n transparentBackground: block:\n var v = true\n if params.hasKey(\"transparentBackground\"):\n let n = params{\"transparentBackground\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n backgroundColor: block:\n var v: Color = parseHtmlColor(\"#ffffff\")\n if params.hasKey(\"backgroundColor\"):\n let n = params{\"backgroundColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n axisColor: block:\n var v: Color = parseHtmlColor(\"#333333\")\n if params.hasKey(\"axisColor\"):\n let n = params{\"axisColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n showGrid: block:\n var v = true\n if params.hasKey(\"showGrid\"):\n let n = params{\"showGrid\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n showLabels: block:\n var v = true\n if params.hasKey(\"showLabels\"):\n let n = params{\"showLabels\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n minY: params{\"minY\"}.getStr(\"\"),\n maxY: params{\"maxY\"}.getStr(\"\"),\n lineWidth: block:\n var v = 2.0\n if params.hasKey(\"lineWidth\"):\n let n = params{\"lineWidth\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n fontSize: block:\n var v = 16.0\n if params.hasKey(\"fontSize\"):\n let n = params{\"fontSize\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n padding: block:\n var v = 16.0\n if params.hasKey(\"padding\"):\n let n = params{\"padding\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n app_module.init(app_module.App(result))\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"inputImage\":\n app.appConfig.inputImage = (if value.kind == fkImage: some(value.asImage()) else: none(Image))\n of \"data\":\n app.appConfig.data = value.asJson()\n of \"chartType\":\n app.appConfig.chartType = value.asString()\n of \"title\":\n app.appConfig.title = value.asString()\n of \"color\":\n app.appConfig.color = value.asColor()\n of \"transparentBackground\":\n app.appConfig.transparentBackground = value.asBool()\n of \"backgroundColor\":\n app.appConfig.backgroundColor = value.asColor()\n of \"axisColor\":\n app.appConfig.axisColor = value.asColor()\n of \"showGrid\":\n app.appConfig.showGrid = value.asBool()\n of \"showLabels\":\n app.appConfig.showLabels = value.asBool()\n of \"minY\":\n app.appConfig.minY = value.asString()\n of \"maxY\":\n app.appConfig.maxY = value.asString()\n of \"lineWidth\":\n app.appConfig.lineWidth = value.asFloat()\n of \"fontSize\":\n app.appConfig.fontSize = value.asFloat()\n of \"padding\":\n app.appConfig.padding = value.asFloat()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Chart\",\n \"description\": \"Graphs and charts\",\n \"category\": \"render\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"inputImage\",\n \"type\": \"image\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Image to render on (optional)\",\n \"showIf\": [\n {\n \"field\": \".meta.showOutput\"\n }\n ]\n },\n {\n \"name\": \"data\",\n \"type\": \"json\",\n \"value\": \"[]\",\n \"required\": true,\n \"label\": \"Chart data\",\n \"hint\": \"Accepts [1,2,3], [{\\\"label\\\": \\\"Mon\\\", \\\"value\\\": 3}, ...] or {\\\"series\\\": [{\\\"name\\\": \\\"a\\\", \\\"color\\\": \\\"#ff0000\\\", \\\"values\\\": [1, 2]}], \\\"labels\\\": [\\\"Mon\\\", \\\"Tue\\\"]}\"\n },\n {\n \"name\": \"chartType\",\n \"type\": \"select\",\n \"options\": [\"line\", \"bar\", \"area\"],\n \"value\": \"line\",\n \"required\": true,\n \"label\": \"Chart type\"\n },\n {\n \"name\": \"title\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Title\"\n },\n {\n \"name\": \"color\",\n \"type\": \"color\",\n \"value\": \"#2a78d6\",\n \"required\": true,\n \"label\": \"Series color\",\n \"hint\": \"First series uses this color, extra series get colors from a built-in palette\"\n },\n {\n \"name\": \"transparentBackground\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": true,\n \"label\": \"Transparent background\"\n },\n {\n \"name\": \"backgroundColor\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"required\": false,\n \"label\": \"Background color\",\n \"showIf\": [{ \"field\": \"transparentBackground\", \"operator\": \"eq\", \"value\": \"false\" }]\n },\n {\n \"name\": \"axisColor\",\n \"type\": \"color\",\n \"value\": \"#333333\",\n \"required\": true,\n \"label\": \"Axis & text color\"\n },\n {\n \"name\": \"showGrid\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": true,\n \"label\": \"Show grid lines\"\n },\n {\n \"name\": \"showLabels\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"required\": true,\n \"label\": \"Show axis labels\"\n },\n {\n \"name\": \"minY\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Min Y (empty = auto)\",\n \"placeholder\": \"auto\"\n },\n {\n \"name\": \"maxY\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Max Y (empty = auto)\",\n \"placeholder\": \"auto\"\n },\n {\n \"name\": \"lineWidth\",\n \"type\": \"float\",\n \"value\": \"2\",\n \"required\": false,\n \"label\": \"Line width\",\n \"showIf\": [{ \"field\": \"chartType\", \"operator\": \"in\", \"value\": [\"line\", \"area\"] }]\n },\n {\n \"name\": \"fontSize\",\n \"type\": \"float\",\n \"value\": \"16\",\n \"required\": true,\n \"label\": \"Font size\"\n },\n {\n \"name\": \"padding\",\n \"type\": \"float\",\n \"value\": \"16\",\n \"required\": true,\n \"label\": \"Padding\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "render/color": { + "app.nim": "import pixie\nimport options\nimport frameos/apps\nimport frameos/types\n\ntype\n AppConfig* = object\n inputImage*: Option[Image]\n color*: Color\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc render*(self: App, context: ExecutionContext, image: Image) =\n image.fill(self.appConfig.color)\n\nproc run*(self: App, context: ExecutionContext) =\n render(self, context, context.image)\n\nproc get*(self: App, context: ExecutionContext): Image =\n result = if self.appConfig.inputImage.isSome:\n self.appConfig.inputImage.get()\n elif context.hasImage:\n newImage(context.image.width, context.image.height)\n else:\n newImage(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())\n render(self, context, result)\n\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n inputImage: none(Image),\n color: block:\n var v: Color = parseHtmlColor(\"#ffffff\")\n if params.hasKey(\"color\"):\n let n = params{\"color\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"inputImage\":\n app.appConfig.inputImage = (if value.kind == fkImage: some(value.asImage()) else: none(Image))\n of \"color\":\n app.appConfig.color = value.asColor()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Color\",\n \"description\": \"Set a single color background\",\n \"category\": \"render\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"inputImage\",\n \"type\": \"image\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Image to render on (optional)\",\n \"showIf\": [\n {\n \"field\": \".meta.showOutput\"\n }\n ]\n },\n {\n \"name\": \"color\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"required\": true,\n \"label\": \"Color\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "render/gradient": { + "app.nim": "import pixie\nimport options\nimport frameos/apps\nimport frameos/types\n\ntype\n AppConfig* = object\n inputImage*: Option[Image]\n startColor*: Color\n endColor*: Color\n angle*: float\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc angleToPoints(angle: float, width: float, height: float): seq[Vec2] =\n let rad = angle * PI / 180.0\n let dx = cos(rad)\n let dy = sin(rad)\n let halfDiagonal = sqrt(width * width + height * height) / 2\n let centerX = width / 2\n let centerY = height / 2\n\n return @[\n vec2(centerX - dx * halfDiagonal, centerY - dy * halfDiagonal),\n vec2(centerX + dx * halfDiagonal, centerY + dy * halfDiagonal)\n ]\n\nproc render*(self: App, context: ExecutionContext, image: Image) =\n let paint = newPaint(LinearGradientPaint)\n paint.gradientStops = @[\n ColorStop(color: self.appConfig.startColor, position: 0),\n ColorStop(color: self.appConfig.endColor, position: 1.0),\n ]\n paint.gradientHandlePositions = angleToPoints(self.appConfig.angle,\n image.width.toFloat, image.height.toFloat)\n image.fillGradient(paint)\n\nproc run*(self: App, context: ExecutionContext) =\n render(self, context, context.image)\n\nproc get*(self: App, context: ExecutionContext): Image =\n result = if self.appConfig.inputImage.isSome:\n self.appConfig.inputImage.get()\n elif context.hasImage:\n newImage(context.image.width, context.image.height)\n else:\n newImage(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())\n render(self, context, result)\n\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n inputImage: none(Image),\n startColor: block:\n var v: Color = parseHtmlColor(\"#800080\")\n if params.hasKey(\"startColor\"):\n let n = params{\"startColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n endColor: block:\n var v: Color = parseHtmlColor(\"#ffc0cb\")\n if params.hasKey(\"endColor\"):\n let n = params{\"endColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n angle: block:\n var v = 45.0\n if params.hasKey(\"angle\"):\n let n = params{\"angle\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"inputImage\":\n app.appConfig.inputImage = (if value.kind == fkImage: some(value.asImage()) else: none(Image))\n of \"startColor\":\n app.appConfig.startColor = value.asColor()\n of \"endColor\":\n app.appConfig.endColor = value.asColor()\n of \"angle\":\n app.appConfig.angle = value.asFloat()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Gradient\",\n \"description\": \"Set a gradient background\",\n \"category\": \"render\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"inputImage\",\n \"type\": \"image\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Image to render on (optional)\",\n \"showIf\": [\n {\n \"field\": \".meta.showOutput\"\n }\n ]\n },\n {\n \"name\": \"startColor\",\n \"type\": \"color\",\n \"value\": \"#800080\",\n \"required\": true,\n \"label\": \"Start color (hex)\"\n },\n {\n \"name\": \"endColor\",\n \"type\": \"color\",\n \"value\": \"#ffc0cb\",\n \"required\": true,\n \"label\": \"End color (hex)\"\n },\n {\n \"name\": \"angle\",\n \"type\": \"float\",\n \"value\": \"45\",\n \"required\": true,\n \"label\": \"Angle (degrees)\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "render/image": { + "app.nim": "import strformat\nimport pixie\nimport options\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/image\n\ntype\n AppConfig* = object\n inputImage*: Option[Image]\n image*: Image\n placement*: string\n offsetX*: int\n offsetY*: int\n blendMode*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc clearTransientInputs(self: App) =\n self.appConfig.inputImage = none(Image)\n self.appConfig.image = nil\n\nproc render*(self: App, context: ExecutionContext, image: Image) =\n try:\n let sourceImage = self.appConfig.image\n if sourceImage.isNil:\n raise newException(Exception, \"No image provided.\")\n if sourceImage == image:\n # The producer already decoded straight into this canvas.\n return\n let blendMode = case self.appConfig.blendMode:\n of \"normal\": NormalBlend\n of \"overwrite\": OverwriteBlend\n of \"darken\": DarkenBlend\n of \"multiply\": MultiplyBlend\n of \"color-burn\": ColorBurnBlend\n of \"lighten\": LightenBlend\n of \"screen\": ScreenBlend\n of \"color-dodge\": ColorDodgeBlend\n of \"overlay\": OverlayBlend\n of \"soft-light\": SoftLightBlend\n of \"hard-light\": HardLightBlend\n of \"difference\": DifferenceBlend\n of \"exclusion\": ExclusionBlend\n of \"hue\": HueBlend\n of \"saturation\": SaturationBlend\n of \"color\": ColorBlend\n of \"luminosity\": LuminosityBlend\n of \"mask\": MaskBlend\n of \"inverse-mask\": SubtractMaskBlend\n of \"exclude-mask\": ExcludeMaskBlend\n else: NormalBlend\n scaleAndDrawImage(image, sourceImage, self.appConfig.placement, self.appConfig.offsetX,\n self.appConfig.offsetY, blendMode)\n except Exception as e:\n let message = &\"Error rendering image: {e.msg}\"\n self.logError(message)\n when defined(frameosEmbedded):\n renderErrorInto(image, image.width, image.height, message)\n else:\n let errorImage = renderError(image.width, image.height, message)\n scaleAndDrawImage(image, errorImage, self.appConfig.placement)\n\nproc run*(self: App, context: ExecutionContext) =\n try:\n render(self, context, context.image)\n finally:\n self.clearTransientInputs()\n\nproc get*(self: App, context: ExecutionContext): Image =\n try:\n result = if self.appConfig.inputImage.isSome:\n self.appConfig.inputImage.get()\n elif context.hasImage:\n newImage(context.image.width, context.image.height)\n else:\n newImage(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())\n render(self, context, result)\n finally:\n self.clearTransientInputs()\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n inputImage: none(Image),\n image: nil,\n placement: params{\"placement\"}.getStr(\"cover\"),\n offsetX: block:\n var v = 0\n if params.hasKey(\"offsetX\"):\n let n = params{\"offsetX\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n offsetY: block:\n var v = 0\n if params.hasKey(\"offsetY\"):\n let n = params{\"offsetY\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n blendMode: params{\"blendMode\"}.getStr(\"normal\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"inputImage\":\n app.appConfig.inputImage = (if value.kind == fkImage: some(value.asImage()) else: none(Image))\n of \"image\":\n app.appConfig.image = value.asImage()\n of \"placement\":\n app.appConfig.placement = value.asString()\n of \"offsetX\":\n app.appConfig.offsetX = value.asInt().int\n of \"offsetY\":\n app.appConfig.offsetY = value.asInt().int\n of \"blendMode\":\n app.appConfig.blendMode = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Render Image\",\n \"description\": \"Render an image onto a canvas\",\n \"category\": \"render\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"inputImage\",\n \"type\": \"image\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Image to render on (optional)\",\n \"showIf\": [\n {\n \"field\": \".meta.showOutput\"\n }\n ]\n },\n {\n \"name\": \"image\",\n \"type\": \"image\",\n \"required\": true,\n \"label\": \"Image\"\n },\n {\n \"name\": \"placement\",\n \"type\": \"select\",\n \"options\": [\n \"cover\",\n \"contain\",\n \"stretch\",\n \"center\",\n \"tiled\",\n \"top-left\",\n \"top-center\",\n \"top-right\",\n \"center-left\",\n \"center-right\",\n \"bottom-left\",\n \"bottom-center\",\n \"bottom-right\"\n ],\n \"value\": \"cover\",\n \"required\": true,\n \"label\": \"Placement\"\n },\n {\n \"name\": \"offsetX\",\n \"type\": \"integer\",\n \"value\": \"0\",\n \"required\": false,\n \"label\": \"Offset X\",\n \"showIf\": [{ \"field\": \"placement\", \"operator\": \"notIn\", \"value\": [\"cover\", \"contain\", \"stretch\"] }]\n },\n {\n \"name\": \"offsetY\",\n \"type\": \"integer\",\n \"value\": \"0\",\n \"required\": false,\n \"label\": \"Offset Y\",\n \"showIf\": [{ \"field\": \"placement\", \"operator\": \"notIn\", \"value\": [\"cover\", \"contain\", \"stretch\"] }]\n },\n {\n \"name\": \"blendMode\",\n \"type\": \"select\",\n \"options\": [\n \"normal\",\n \"overwrite\",\n \"darken\",\n \"multiply\",\n \"color-burn\",\n \"lighten\",\n \"screen\",\n \"color-dodge\",\n \"overlay\",\n \"soft-light\",\n \"hard-light\",\n \"difference\",\n \"exclusion\",\n \"hue\",\n \"saturation\",\n \"color\",\n \"luminosity\",\n \"mask\",\n \"inverse-mask\",\n \"exclude-mask\"\n ],\n \"value\": \"normal\",\n \"required\": false,\n \"label\": \"Blend Mode\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "render/opacity": { + "app.nim": "import pixie\nimport options\nimport frameos/apps\nimport frameos/types\n\ntype\n AppConfig* = object\n image*: Option[Image]\n opacity*: float\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc get*(self: App, context: ExecutionContext): Image =\n let image = if self.appConfig.image.isSome(): self.appConfig.image.get().copy()\n elif context.hasImage: newImage(context.image.width, context.image.height)\n else: newImage(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())\n applyOpacity(image, self.appConfig.opacity)\n return image\n\nproc run*(self: App, context: ExecutionContext) =\n applyOpacity(context.image, self.appConfig.opacity)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n image: none(Image),\n opacity: block:\n var v = 1.0\n if params.hasKey(\"opacity\"):\n let n = params{\"opacity\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"image\":\n app.appConfig.image = (if value.kind == fkImage: some(value.asImage()) else: none(Image))\n of \"opacity\":\n app.appConfig.opacity = value.asFloat()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Opacity\",\n \"description\": \"Change how transparent an image is\",\n \"category\": \"render\",\n \"version\": \"1.0.0\",\n \"fields\": [\n { \n \"name\": \"image\", \n \"type\": \"image\", \n \"required\": false, \n \"label\": \"Image (optional)\",\n \"showIf\": [\n {\n \"field\": \".meta.showOutput\"\n }\n ]\n },\n {\n \"name\": \"opacity\",\n \"type\": \"float\",\n \"value\": \"1\",\n \"required\": true,\n \"label\": \"Opacity (0-1)\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "render/split": { + "app.nim": "import strutils\nimport pixie\nimport options\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/image\n\ntype\n AppConfig* = object\n inputImage*: Option[Image]\n hideEmpty*: bool\n rows*: int\n columns*: int\n renderFunctions*: seq[seq[NodeId]]\n renderFunction*: NodeId\n gap*: string\n margin*: string\n widthRatios*: string\n heightRatios*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc extractMargins*(marginString: string): (float, float, float, float) =\n let\n margins = if marginString == \"\": @[] else: marginString.split(' ')\n marginTop = if margins.len > 0: parseFloat(margins[0]) else: 0.0\n marginRight = if margins.len > 1: parseFloat(margins[1]) else: marginTop\n marginBottom = if margins.len > 2: parseFloat(margins[2]) else: marginTop\n marginLeft = if margins.len > 3: parseFloat(margins[3]) else: marginRight\n result = (marginTop, marginRight, marginBottom, marginLeft)\n\nproc extractGaps*(gapString: string): (float, float) =\n let\n gaps = if gapString == \"\": @[] else: gapString.split(' ')\n gapHorizontal = if gaps.len > 0: parseFloat(gaps[0]) else: 0.0\n gapVertical = if gaps.len > 1: parseFloat(gaps[1]) else: gapHorizontal\n result = (gapHorizontal, gapVertical)\n\nproc extractRatios*(widthRatios: string, heightRatios: string, columns: int,\n rows: int): (seq[float], seq[float], float, float) =\n let\n widthRatios = if widthRatios == \"\": @[] else: widthRatios.split(' ')\n heightRatios = if heightRatios == \"\": @[] else: heightRatios.split(' ')\n\n var\n normalizedWidthRatios = newSeq[float](columns)\n normalizedHeightRatios = newSeq[float](rows)\n totalWidthRatio = 0.0\n totalHeightRatio = 0.0\n\n for i in 0..(columns-1):\n normalizedWidthRatios[i] = if widthRatios.len > 0: parseFloat(widthRatios[\n i mod widthRatios.len]) else: 1.0\n totalWidthRatio += normalizedWidthRatios[i]\n for i in 0..(rows-1):\n normalizedHeightRatios[i] = if heightRatios.len > 0: parseFloat(\n heightRatios[i mod heightRatios.len]) else: 1.0\n totalHeightRatio += normalizedHeightRatios[i]\n if totalWidthRatio == 0.0:\n for i in 0..(columns-1):\n normalizedWidthRatios[i] = 1.0\n totalWidthRatio = columns.toFloat\n if totalHeightRatio == 0.0:\n for i in 0..(rows-1):\n normalizedHeightRatios[i] = 1.0\n totalHeightRatio = rows.toFloat\n result = (normalizedWidthRatios, normalizedHeightRatios, totalWidthRatio, totalHeightRatio)\n\nproc splitDimensions*(width: int, height: int, appConfig: AppConfig): seq[(int, int)] =\n let\n rows = appConfig.rows\n columns = appConfig.columns\n\n (marginTop, marginRight, marginBottom, marginLeft) = extractMargins(appConfig.margin)\n (gapHorizontal, gapVertical) = extractGaps(appConfig.gap)\n\n imageWidth = width.toFloat - marginLeft - marginRight - (columns - 1).toFloat * gapHorizontal\n imageHeight = height.toFloat - marginTop - marginBottom - (rows - 1).toFloat * gapVertical\n\n (widthRatios, heightRatios, totalWidthRatio, totalHeightRatio) = extractRatios(\n appConfig.widthRatios, appConfig.heightRatios, columns, rows)\n\n var\n cellWidths, cellHeights: seq[int]\n\n var\n totalWidth: int = 0\n totalHeight: int = 0\n\n # Calculate cell dimensions\n for i in 0..(columns-1):\n let width: int = (imageWidth * widthRatios[i] / totalWidthRatio).toInt\n totalWidth += width\n cellWidths.add(width)\n for i in 0..(rows-1):\n let height: int = (imageHeight * heightRatios[i] / totalHeightRatio).toInt\n totalHeight += height\n cellHeights.add(height)\n\n # Adjust last cell dimensions to fill the image\n cellWidths[cellWidths.len - 1] = imageWidth.toInt - (totalWidth - cellWidths[cellWidths.len - 1])\n cellHeights[cellHeights.len - 1] = imageHeight.toInt - (totalHeight - cellHeights[cellHeights.len - 1])\n\n # Return cell dimensions as a sequence of tuples\n result = newSeq[(int, int)](rows * columns)\n for row in 0..= 0 and row < renderFunctions.len and column >= 0 and column < renderFunctions[\n row].len and renderFunctions[row][column] == 0: renderFunction else: renderFunctions[row][column]\n if renderer != 0:\n let img = image.subImage(cellX.toInt, cellY.toInt, cellWidth, cellHeight)\n var cellContext = ExecutionContext(\n scene: context.scene,\n image: img,\n hasImage: true,\n event: context.event,\n payload: context.payload,\n parent: context,\n loopIndex: row * columns + column,\n loopKey: context.loopKey & \"/\" & $(row * columns + column),\n nextSleep: context.nextSleep\n )\n self.scene.execNode(renderer, cellContext)\n if cellContext.nextSleep != context.nextSleep:\n context.nextSleep = cellContext.nextSleep\n image.draw(cellContext.image, translate(vec2(cellX, cellY)))\n\n cellX += cellWidth.toFloat + gapHorizontal\n if column == columns - 1:\n cellY += cellHeight.toFloat + gapVertical\n\nproc run*(self: App, context: ExecutionContext) =\n render(self, context, context.image)\n\nproc get*(self: App, context: ExecutionContext): Image =\n result = if self.appConfig.inputImage.isSome:\n self.appConfig.inputImage.get()\n elif context.hasImage:\n newImage(context.image.width, context.image.height)\n else:\n newImage(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())\n render(self, context, result)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n inputImage: none(Image),\n rows: block:\n var v = 1\n if params.hasKey(\"rows\"):\n let n = params{\"rows\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n columns: block:\n var v = 1\n if params.hasKey(\"columns\"):\n let n = params{\"columns\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n hideEmpty: block:\n var v = false\n if params.hasKey(\"hideEmpty\"):\n let n = params{\"hideEmpty\"}\n if n.kind == JBool:\n v = n.getBool()\n elif n.kind == JString:\n let s = n.getStr().toLowerAscii()\n v = (s in [\"true\",\"1\",\"yes\",\"y\"])\n v,\n render_functions: block:\n let start1 = 1\n let stop1 = block:\n var v = 1\n if params.hasKey(\"rows\"):\n let n = params{\"rows\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v\n let start2 = 1\n let stop2 = block:\n var v = 1\n if params.hasKey(\"columns\"):\n let n = params{\"columns\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v\n let size1 = (if stop1 >= start1: stop1 - start1 + 1 else: 0)\n let size2 = (if stop2 >= start2: stop2 - start2 + 1 else: 0)\n var output: seq[seq[NodeId]]\n output = newSeq[seq[NodeId]](size1)\n for i1 in start1..stop1:\n output[i1 - start1] = newSeq[NodeId](size2)\n for i2 in start2..stop2:\n var v: NodeId = 0.NodeId\n let k = \"render_functions\" & \"[\" & $i1 & \"]\" & \"[\" & $i2 & \"]\"\n if params.hasKey(k):\n let n2 = params[k]\n if n2.kind == JInt: v = n2.getInt().int.NodeId\n elif n2.kind == JFloat: v = int(n2.getFloat()).NodeId\n elif n2.kind == JString:\n try: v = int(parseFloat(n2.getStr())).NodeId\n except CatchableError: discard\n output[i1 - start1][i2 - start2] = v\n output,\n render_function: block:\n var v: NodeId = 0.NodeId\n if params.hasKey(\"render_function\"):\n let n = params{\"render_function\"}\n if n.kind == JInt:\n v = n.getInt().int.NodeId\n elif n.kind == JFloat:\n v = int(n.getFloat()).NodeId\n elif n.kind == JString:\n try: v = int(parseFloat(n.getStr())).NodeId\n except CatchableError: discard\n v,\n gap: params{\"gap\"}.getStr(\"\"),\n margin: params{\"margin\"}.getStr(\"\"),\n width_ratios: params{\"width_ratios\"}.getStr(\"\"),\n height_ratios: params{\"height_ratios\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"inputImage\":\n app.appConfig.inputImage = (if value.kind == fkImage: some(value.asImage()) else: none(Image))\n of \"rows\":\n app.appConfig.rows = value.asInt().int\n of \"columns\":\n app.appConfig.columns = value.asInt().int\n of \"hideEmpty\":\n app.appConfig.hideEmpty = value.asBool()\n of \"render_functions\":\n if value.kind == fkJson:\n let n = value.asJson()\n if n.kind == JArray:\n var arr: seq[seq[NodeId]] = @[]\n for row in n.items():\n if row.kind == JArray:\n var r: seq[NodeId] = @[]\n for it in row.items():\n if it.kind == JInt: r.add(it.getInt().int.NodeId)\n elif it.kind == JFloat: r.add(int(it.getFloat()).NodeId)\n elif it.kind == JString:\n try: r.add(int(parseFloat(it.getStr())).NodeId)\n except CatchableError: discard\n arr.add(r)\n app.appConfig.render_functions = arr\n else:\n raise newException(ValueError, \"Expected JSON 2D array for seq field: render_functions\")\n else:\n raise newException(ValueError, \"Expected JSON for seq field: render_functions\")\n of \"render_function\":\n app.appConfig.render_function = value.asNode()\n of \"gap\":\n app.appConfig.gap = value.asString()\n of \"margin\":\n app.appConfig.margin = value.asString()\n of \"width_ratios\":\n app.appConfig.width_ratios = value.asString()\n of \"height_ratios\":\n app.appConfig.height_ratios = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Split\",\n \"description\": \"Render a grid\",\n \"category\": \"render\",\n \"version\": \"1.0.0\",\n \"settings\": [],\n \"fields\": [\n {\n \"markdown\": \"Loop index in: `context.loopIndex`\"\n },\n {\n \"name\": \"inputImage\",\n \"type\": \"image\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Image to render on (optional)\",\n \"showIf\": [\n {\n \"field\": \".meta.showOutput\"\n }\n ]\n },\n {\n \"label\": \"Rows\",\n \"name\": \"rows\",\n \"value\": \"1\",\n \"type\": \"integer\",\n \"required\": true\n },\n {\n \"label\": \"Columns\",\n \"name\": \"columns\",\n \"value\": \"1\",\n \"type\": \"integer\",\n \"required\": true\n },\n {\n \"label\": \"Hide empty cells\",\n \"name\": \"hideEmpty\",\n \"type\": \"boolean\",\n \"value\": \"false\"\n },\n {\n \"label\": \"Render cell: {row} x {column}\",\n \"name\": \"render_functions\",\n \"type\": \"node\",\n \"seq\": [\n [\"row\", 1, \"rows\"],\n [\"column\", 1, \"columns\"]\n ],\n \"showIf\": [\n {\n \"operator\": \"notEmpty\"\n },\n {\n \"field\": \"hideEmpty\",\n \"operator\": \"eq\",\n \"value\": \"false\"\n }\n ]\n },\n {\n \"label\": \"Render all other cells\",\n \"name\": \"render_function\",\n \"type\": \"node\"\n },\n {\n \"label\": \"Gap\",\n \"name\": \"gap\",\n \"placeholder\": \"0\",\n \"type\": \"string\"\n },\n {\n \"label\": \"Margin\",\n \"name\": \"margin\",\n \"placeholder\": \"0\",\n \"type\": \"string\"\n },\n {\n \"label\": \"Widths\",\n \"name\": \"width_ratios\",\n \"placeholder\": \"1 2 1\",\n \"hint\": \"Relative widths of columns, separated by spaces\",\n \"type\": \"string\"\n },\n {\n \"label\": \"Heights\",\n \"name\": \"height_ratios\",\n \"placeholder\": \"1 2 1\",\n \"hint\": \"Relative heights of rows, separated by spaces\",\n \"type\": \"string\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "render/svg": { + "app.nim": "import base64\nimport options\nimport pixie\nimport sequtils\nimport strformat\nimport strutils\nimport uri\n\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/image\n\ntype\n AppConfig* = object\n inputImage*: Option[Image]\n svg*: string\n placement*: string\n offsetX*: int\n offsetY*: int\n blendMode*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n\nproc decodeSvgInput(svgInput: string): string =\n if svgInput.startsWith(\"data:\"):\n let commaIndex = svgInput.find(',')\n if commaIndex == -1:\n raise newException(ValueError, \"Invalid data URL.\")\n let header = svgInput[5 ..< commaIndex]\n let dataBody = svgInput[commaIndex + 1 .. ^1]\n let headerParts = if header.len > 0: header.split(';') else: @[\"\"]\n let isBase64 = headerParts.anyIt(it == \"base64\")\n if isBase64:\n return dataBody.decode\n return decodeUrl(dataBody)\n return svgInput\n\nproc render*(self: App, context: ExecutionContext, image: Image) =\n try:\n if self.appConfig.svg.len == 0:\n raise newException(Exception, \"No SVG provided.\")\n let svgMarkup = decodeSvgInput(self.appConfig.svg)\n let svgImageOption = decodeSvgWithFallback(svgMarkup, image.width, image.height)\n if svgImageOption.isNone:\n raise newException(Exception, \"Failed to render SVG.\")\n let svgImage = svgImageOption.get()\n let blendMode = case self.appConfig.blendMode:\n of \"normal\": NormalBlend\n of \"overwrite\": OverwriteBlend\n of \"darken\": DarkenBlend\n of \"multiply\": MultiplyBlend\n of \"color-burn\": ColorBurnBlend\n of \"lighten\": LightenBlend\n of \"screen\": ScreenBlend\n of \"color-dodge\": ColorDodgeBlend\n of \"overlay\": OverlayBlend\n of \"soft-light\": SoftLightBlend\n of \"hard-light\": HardLightBlend\n of \"difference\": DifferenceBlend\n of \"exclusion\": ExclusionBlend\n of \"hue\": HueBlend\n of \"saturation\": SaturationBlend\n of \"color\": ColorBlend\n of \"luminosity\": LuminosityBlend\n of \"mask\": MaskBlend\n of \"inverse-mask\": SubtractMaskBlend\n of \"exclude-mask\": ExcludeMaskBlend\n else: NormalBlend\n scaleAndDrawImage(image, svgImage, self.appConfig.placement, self.appConfig.offsetX,\n self.appConfig.offsetY, blendMode)\n except Exception as e:\n let message = &\"Error rendering SVG: {e.msg}\"\n self.logError(message)\n when defined(frameosEmbedded):\n renderErrorInto(image, image.width, image.height, message)\n else:\n let errorImage = renderError(image.width, image.height, message)\n scaleAndDrawImage(image, errorImage, self.appConfig.placement)\n\nproc run*(self: App, context: ExecutionContext) =\n render(self, context, context.image)\n\nproc get*(self: App, context: ExecutionContext): Image =\n result = if self.appConfig.inputImage.isSome:\n self.appConfig.inputImage.get()\n elif context.hasImage:\n newImage(context.image.width, context.image.height)\n else:\n newImage(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())\n render(self, context, result)\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n inputImage: none(Image),\n svg: params{\"svg\"}.getStr(\"\"),\n placement: params{\"placement\"}.getStr(\"cover\"),\n offsetX: block:\n var v = 0\n if params.hasKey(\"offsetX\"):\n let n = params{\"offsetX\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n offsetY: block:\n var v = 0\n if params.hasKey(\"offsetY\"):\n let n = params{\"offsetY\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n blendMode: params{\"blendMode\"}.getStr(\"normal\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"inputImage\":\n app.appConfig.inputImage = (if value.kind == fkImage: some(value.asImage()) else: none(Image))\n of \"svg\":\n app.appConfig.svg = value.asString()\n of \"placement\":\n app.appConfig.placement = value.asString()\n of \"offsetX\":\n app.appConfig.offsetX = value.asInt().int\n of \"offsetY\":\n app.appConfig.offsetY = value.asInt().int\n of \"blendMode\":\n app.appConfig.blendMode = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Render SVG\",\n \"description\": \"Render an SVG onto a canvas\",\n \"category\": \"render\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"inputImage\",\n \"type\": \"image\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Image to render on (optional)\",\n \"showIf\": [\n {\n \"field\": \".meta.showOutput\"\n }\n ]\n },\n {\n \"name\": \"svg\",\n \"type\": \"text\",\n \"value\": \"\",\n \"required\": true,\n \"label\": \"SVG\",\n \"hint\": \"Provide raw SVG markup or a data URL like data:image/svg+xml;charset=utf-8,.\"\n },\n {\n \"name\": \"placement\",\n \"type\": \"select\",\n \"options\": [\n \"cover\",\n \"contain\",\n \"stretch\",\n \"center\",\n \"tiled\",\n \"top-left\",\n \"top-center\",\n \"top-right\",\n \"center-left\",\n \"center-right\",\n \"bottom-left\",\n \"bottom-center\",\n \"bottom-right\"\n ],\n \"value\": \"cover\",\n \"required\": true,\n \"label\": \"Placement\"\n },\n {\n \"name\": \"offsetX\",\n \"type\": \"integer\",\n \"value\": \"0\",\n \"required\": false,\n \"label\": \"Offset X\",\n \"showIf\": [{ \"field\": \"placement\", \"operator\": \"notIn\", \"value\": [\"cover\", \"contain\", \"stretch\"] }]\n },\n {\n \"name\": \"offsetY\",\n \"type\": \"integer\",\n \"value\": \"0\",\n \"required\": false,\n \"label\": \"Offset Y\",\n \"showIf\": [{ \"field\": \"placement\", \"operator\": \"notIn\", \"value\": [\"cover\", \"contain\", \"stretch\"] }]\n },\n {\n \"name\": \"blendMode\",\n \"type\": \"select\",\n \"options\": [\n \"normal\",\n \"overwrite\",\n \"darken\",\n \"multiply\",\n \"color-burn\",\n \"lighten\",\n \"screen\",\n \"color-dodge\",\n \"overlay\",\n \"soft-light\",\n \"hard-light\",\n \"difference\",\n \"exclusion\",\n \"hue\",\n \"saturation\",\n \"color\",\n \"luminosity\",\n \"mask\",\n \"inverse-mask\",\n \"exclude-mask\"\n ],\n \"value\": \"normal\",\n \"required\": false,\n \"label\": \"Blend Mode\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "render/text": { + "app.nim": "import pixie, options\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/text\n\ntype\n AppConfig* = object\n inputImage*: Option[Image]\n text*: string\n richText*: string\n position*: string\n vAlign*: string\n offsetX*: float\n offsetY*: float\n padding*: float\n font*: string\n fontColor*: Color\n fontSize*: float\n borderColor*: Color\n borderWidth*: int\n overflow*: string\n\n # Internal cache key to avoid re-typesetting when nothing relevant changed.\n CacheKey = object\n text: string\n richText: string\n position: string\n vAlign: string\n width: int\n height: int\n padding: float\n font: string\n fontColor: Color\n fontSize: float\n borderColor: Color\n borderWidth: int\n overflow: string\n assetsPath: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n layout*: Option[TextLayoutResult]\n cacheKey*: Option[CacheKey]\n\nproc `==`(a, b: CacheKey): bool =\n a.text == b.text and\n a.richText == b.richText and\n a.position == b.position and\n a.vAlign == b.vAlign and\n a.width == b.width and\n a.height == b.height and\n a.padding == b.padding and\n a.font == b.font and\n a.fontColor == b.fontColor and\n a.fontSize == b.fontSize and\n a.borderColor == b.borderColor and\n a.borderWidth == b.borderWidth and\n a.overflow == b.overflow and\n a.assetsPath == b.assetsPath\n\nproc toOptions(self: App, width, height: int): TextRenderOptions =\n TextRenderOptions(\n text: self.appConfig.text,\n richTextMode: self.appConfig.richText,\n position: self.appConfig.position,\n vAlign: self.appConfig.vAlign,\n padding: self.appConfig.padding,\n font: self.appConfig.font,\n fontColor: self.appConfig.fontColor,\n fontSize: self.appConfig.fontSize,\n borderColor: self.appConfig.borderColor,\n borderWidth: self.appConfig.borderWidth,\n overflow: self.appConfig.overflow,\n assetsPath: self.frameConfig.assetsPath\n )\n\nproc buildKey(self: App, width, height: int): CacheKey =\n CacheKey(\n text: self.appConfig.text,\n richText: self.appConfig.richText,\n position: self.appConfig.position,\n vAlign: self.appConfig.vAlign,\n width: width,\n height: height,\n padding: self.appConfig.padding,\n font: self.appConfig.font,\n fontColor: self.appConfig.fontColor,\n fontSize: self.appConfig.fontSize,\n borderColor: self.appConfig.borderColor,\n borderWidth: self.appConfig.borderWidth,\n overflow: self.appConfig.overflow,\n assetsPath: self.frameConfig.assetsPath\n )\n\nproc ensureLayout(self: App, maxWidth, maxHeight: int) =\n let key = self.buildKey(maxWidth, maxHeight)\n let need = if self.cacheKey.isSome: not (self.cacheKey.get == key) else: true\n if need or not self.layout.isSome:\n let opts = self.toOptions(maxWidth, maxHeight)\n let layout = typesetIntoBounds(opts, maxWidth, maxHeight)\n self.layout = some(layout)\n self.cacheKey = some(key)\n\nproc renderText*(self: App, context: ExecutionContext, image: Image, offsetX, offsetY: float): Image =\n let lay = self.layout.get()\n drawText(lay, image, offsetX = offsetX, offsetY = offsetY)\n image\n\nproc run*(self: App, context: ExecutionContext) =\n if self.appConfig.text.len == 0:\n return\n self.ensureLayout(context.image.width, context.image.height)\n discard self.renderText(context, context.image, self.appConfig.offsetX, self.appConfig.offsetY)\n\nproc get*(self: App, context: ExecutionContext): Image =\n if self.appConfig.inputImage.isSome:\n let img = self.appConfig.inputImage.get()\n self.ensureLayout(img.width, img.height)\n return self.renderText(context, img, self.appConfig.offsetX, self.appConfig.offsetY)\n else:\n if context.hasImage:\n self.ensureLayout(context.image.width, context.image.height)\n else:\n self.ensureLayout(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())\n\n let lay = self.layout.get()\n # Compute a tightly-cropped image size around the laid out text (ignoring padding),\n # same behavior as before but via utils helpers.\n let br = layoutBounds(lay.textTypeset)\n let tl = layoutBoundsTopLeft(lay.textTypeset)\n let border = self.appConfig.borderWidth\n let outW = max(1, (br.x - tl.x).int + border)\n let outH = max(1, (br.y - tl.y).int + border)\n let output = newImage(outW, outH)\n\n # Offset so that the top-left of the layout sits at ~border/2 inset.\n let ox = ceil(border.float / 2) - tl.x + self.appConfig.offsetX\n let oy = ceil(border.float / 2) - tl.y + self.appConfig.offsetY\n discard self.renderText(context, output, ox, oy)\n return output\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n inputImage: none(Image),\n text: params{\"text\"}.getStr(\"\"),\n richText: params{\"richText\"}.getStr(\"disabled\"),\n position: params{\"position\"}.getStr(\"center\"),\n vAlign: params{\"vAlign\"}.getStr(\"middle\"),\n offsetX: block:\n var v = 0.0\n if params.hasKey(\"offsetX\"):\n let n = params{\"offsetX\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n offsetY: block:\n var v = 0.0\n if params.hasKey(\"offsetY\"):\n let n = params{\"offsetY\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n padding: block:\n var v = 10.0\n if params.hasKey(\"padding\"):\n let n = params{\"padding\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n font: params{\"font\"}.getStr(\"\"),\n fontColor: block:\n var v: Color = parseHtmlColor(\"#ffffff\")\n if params.hasKey(\"fontColor\"):\n let n = params{\"fontColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n fontSize: block:\n var v = 32.0\n if params.hasKey(\"fontSize\"):\n let n = params{\"fontSize\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n borderColor: block:\n var v: Color = parseHtmlColor(\"#000000\")\n if params.hasKey(\"borderColor\"):\n let n = params{\"borderColor\"}\n if n.kind == JString:\n let s = n.getStr()\n try:\n v = parseHtmlColor(s)\n except CatchableError:\n discard\n v,\n borderWidth: block:\n var v = 2\n if params.hasKey(\"borderWidth\"):\n let n = params{\"borderWidth\"}\n if n.kind == JInt:\n v = n.getInt().int\n elif n.kind == JFloat:\n v = int(n.getFloat())\n elif n.kind == JString:\n try: v = parseInt(n.getStr())\n except CatchableError: discard\n v,\n overflow: params{\"overflow\"}.getStr(\"fit-bounds\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"inputImage\":\n app.appConfig.inputImage = (if value.kind == fkImage: some(value.asImage()) else: none(Image))\n of \"text\":\n app.appConfig.text = value.asString()\n of \"richText\":\n app.appConfig.richText = value.asString()\n of \"position\":\n app.appConfig.position = value.asString()\n of \"vAlign\":\n app.appConfig.vAlign = value.asString()\n of \"offsetX\":\n app.appConfig.offsetX = value.asFloat()\n of \"offsetY\":\n app.appConfig.offsetY = value.asFloat()\n of \"padding\":\n app.appConfig.padding = value.asFloat()\n of \"font\":\n app.appConfig.font = value.asString()\n of \"fontColor\":\n app.appConfig.fontColor = value.asColor()\n of \"fontSize\":\n app.appConfig.fontSize = value.asFloat()\n of \"borderColor\":\n app.appConfig.borderColor = value.asColor()\n of \"borderWidth\":\n app.appConfig.borderWidth = value.asInt().int\n of \"overflow\":\n app.appConfig.overflow = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Text\",\n \"description\": \"Overlay a block of text\",\n \"category\": \"render\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"inputImage\",\n \"type\": \"image\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Image to print text on (optional)\",\n \"showIf\": [\n {\n \"field\": \".meta.showOutput\"\n }\n ]\n },\n {\n \"name\": \"text\",\n \"type\": \"text\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Text\",\n \"placeholder\": \"Once upon a time...\"\n },\n {\n \"name\": \"richText\",\n \"label\": \"Rich text mode\",\n \"type\": \"select\",\n \"options\": [\"disabled\", \"basic-caret\"],\n \"value\": \"disabled\",\n \"required\": false,\n \"hint\": \"Enable rich text editing\\n\\nThe \\\"basic-caret\\\" mode lets you change the size and color of each part of the text. Example syntax:\\n- ^(16)font ^(32)size\\n- ^(#FF00FF)color\\n- ^(PTSans-Bold.ttf)font\\n- ^(underline)lines ^(no-underline) not\\n- ^(strikethrough)lines ^(no-strikethrough) not\\n- ^(16,#FF0000) combine styles\\n- Use ^(reset) to clean house\"\n },\n {\n \"name\": \"position\",\n \"type\": \"select\",\n \"options\": [\"left\", \"center\", \"right\"],\n \"value\": \"center\",\n \"required\": true,\n \"label\": \"Align\",\n \"placeholder\": \"center\"\n },\n {\n \"name\": \"vAlign\",\n \"type\": \"select\",\n \"options\": [\"top\", \"middle\", \"bottom\"],\n \"value\": \"middle\",\n \"required\": true,\n \"label\": \"Align V\",\n \"placeholder\": \"middle\",\n \"showIf\": [\n {\n \"field\": \".meta.showNextPrev\"\n },\n {\n \"and\": [\n {\n \"field\": \".meta.showOutput\"\n },\n {\n \"field\": \"inputImage\"\n }\n ]\n }\n ]\n },\n {\n \"name\": \"offsetX\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"required\": true,\n \"label\": \"Offset X\",\n \"placeholder\": \"0\",\n \"showIf\": [\n {\n \"field\": \".meta.showNextPrev\"\n },\n {\n \"field\": \"inputImage\"\n }\n ]\n },\n {\n \"name\": \"offsetY\",\n \"type\": \"float\",\n \"value\": \"0\",\n \"required\": true,\n \"label\": \"Offset Y\",\n \"placeholder\": \"0\",\n \"showIf\": [\n {\n \"field\": \".meta.showNextPrev\"\n },\n {\n \"field\": \"inputImage\"\n }\n ]\n },\n {\n \"name\": \"padding\",\n \"type\": \"float\",\n \"value\": \"10\",\n \"required\": true,\n \"label\": \"Padding\",\n \"placeholder\": \"10\"\n },\n {\n \"name\": \"font\",\n \"type\": \"font\",\n \"required\": false,\n \"label\": \"Font\"\n },\n {\n \"name\": \"fontColor\",\n \"type\": \"color\",\n \"value\": \"#ffffff\",\n \"required\": true,\n \"label\": \"Font Color\",\n \"placeholder\": \"#ffffff\"\n },\n {\n \"name\": \"fontSize\",\n \"type\": \"float\",\n \"value\": \"32\",\n \"required\": true,\n \"label\": \"Font Size\",\n \"placeholder\": \"32\"\n },\n {\n \"name\": \"borderColor\",\n \"type\": \"color\",\n \"value\": \"#000000\",\n \"required\": true,\n \"label\": \"Border Color\",\n \"placeholder\": \"#000000\"\n },\n {\n \"name\": \"borderWidth\",\n \"type\": \"integer\",\n \"value\": \"2\",\n \"required\": true,\n \"label\": \"Border width\",\n \"placeholder\": \"2\"\n },\n {\n \"name\": \"overflow\",\n \"type\": \"select\",\n \"options\": [\"fit-bounds\", \"visible\"],\n \"value\": \"fit-bounds\",\n \"label\": \"Overflow\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "render/zoomPan": { + "app.nim": "import json\nimport math\nimport strformat\nimport times\nimport pixie\nimport options\nimport frameos/apps\nimport frameos/types\nimport frameos/utils/image\n\ntype\n AppConfig* = object\n inputImage*: Option[Image]\n image*: Image\n motion*: string\n zoomStart*: float\n zoomEnd*: float\n durationSeconds*: float\n easing*: string\n anchor*: string\n phaseStateKey*: string\n\n App* = ref object of AppRoot\n appConfig*: AppConfig\n phaseRestored*: bool\n timeOffset*: float\n\nproc phase*(now: float, durationSeconds: float): float =\n ## Progress through the current cycle, 0..1, wrapping every durationSeconds.\n if durationSeconds <= 0:\n return 0.0\n let cycles = now / durationSeconds\n result = cycles - floor(cycles)\n\nproc cycleIndex*(now: float, durationSeconds: float): int =\n if durationSeconds <= 0:\n return 0\n result = floor(now / durationSeconds).int\n\nproc pingPong*(t: float): float =\n ## Maps 0..1 to 0..1..0 so a wrapping phase produces continuous motion.\n let tc = clamp(t, 0.0, 1.0)\n result = if tc < 0.5: tc * 2.0 else: 2.0 - tc * 2.0\n\nproc eased*(t: float, easing: string): float =\n let tc = clamp(t, 0.0, 1.0)\n case easing\n of \"easeInOut\":\n result = tc * tc * (3.0 - 2.0 * tc)\n of \"sine\":\n result = (1.0 - cos(tc * PI)) / 2.0\n else:\n result = tc\n\nproc hash01*(values: varargs[int]): float =\n ## Deterministic FNV-1a hash of the inputs mapped into [0, 1).\n var h = 2166136261'u32\n for value in values:\n var v = cast[uint64](value.int64)\n for _ in 0 ..< 8:\n h = (h xor uint32(v and 0xff'u64)) * 16777619'u32\n v = v shr 8\n result = float(h and 0xffffff'u32) / float(0x1000000)\n\nproc anchorPoint*(anchor: string, seed: int): tuple[x: float, y: float] =\n case anchor\n of \"top\": (0.5, 0.0)\n of \"bottom\": (0.5, 1.0)\n of \"left\": (0.0, 0.5)\n of \"right\": (1.0, 0.5)\n of \"random\": (hash01(seed, 11), hash01(seed, 23))\n else: (0.5, 0.5)\n\nproc kenBurnsFocal*(imageW, imageH, seed: int, t: float): tuple[x: float, y: float] =\n ## Focal point drifting from the center area towards a corner picked\n ## deterministically from the image dimensions and the cycle index.\n let corner = int(hash01(imageW, imageH, seed, 101) * 4.0) mod 4\n let cornerX = if corner mod 2 == 0: 0.0 else: 1.0\n let cornerY = if corner < 2: 0.0 else: 1.0\n let jitterX = (hash01(imageW, imageH, seed, 7) - 0.5) * 0.3\n let jitterY = (hash01(imageW, imageH, seed, 13) - 0.5) * 0.3\n let startX = clamp(1.0 - cornerX + jitterX, 0.0, 1.0)\n let startY = clamp(1.0 - cornerY + jitterY, 0.0, 1.0)\n let endX = clamp(cornerX + jitterX, 0.0, 1.0)\n let endY = clamp(cornerY + jitterY, 0.0, 1.0)\n let tc = clamp(t, 0.0, 1.0)\n result = (startX + (endX - startX) * tc, startY + (endY - startY) * tc)\n\nproc zoomAt*(motion: string, zoomStart, zoomEnd, t: float, easing: string): float =\n let startZoom = if zoomStart > 0: zoomStart else: 1.0\n let endZoom = if zoomEnd > 0: zoomEnd else: startZoom\n case motion\n of \"zoomIn\", \"kenBurns\":\n result = startZoom + (endZoom - startZoom) * eased(t, easing)\n of \"panLeftRight\", \"panTopBottom\":\n result = endZoom\n else: # zoomInOut\n result = startZoom + (endZoom - startZoom) * eased(pingPong(t), easing)\n\nproc cropRect*(imageW, imageH, canvasW, canvasH: int, zoom: float, anchor: string,\n t: float, motion: string, easing: string = \"linear\", seed: int = 0):\n tuple[x: float, y: float, w: float, h: float] =\n ## Source rect to show at phase t: canvas aspect ratio, never out of bounds,\n ## and at zoom <= 1.0 exactly the largest cover-crop of the image.\n if imageW <= 0 or imageH <= 0 or canvasW <= 0 or canvasH <= 0:\n return (0.0, 0.0, max(imageW, 0).float, max(imageH, 0).float)\n let\n iw = imageW.float\n ih = imageH.float\n canvasAspect = canvasW.float / canvasH.float\n var baseW = iw\n var baseH = iw / canvasAspect\n if baseH > ih:\n baseH = ih\n baseW = ih * canvasAspect\n let effZoom = if zoom > 1.0: zoom else: 1.0\n let w = baseW / effZoom\n let h = baseH / effZoom\n let freeX = iw - w\n let freeY = ih - h\n let tc = clamp(t, 0.0, 1.0)\n\n var fx = 0.5\n var fy = 0.5\n case motion\n of \"panLeftRight\":\n fx = eased(pingPong(tc), easing)\n fy = anchorPoint(anchor, seed).y\n of \"panTopBottom\":\n fx = anchorPoint(anchor, seed).x\n fy = eased(pingPong(tc), easing)\n of \"kenBurns\":\n (fx, fy) = kenBurnsFocal(imageW, imageH, seed, eased(tc, easing))\n else: # zoomInOut, zoomIn\n (fx, fy) = anchorPoint(anchor, seed)\n result = (freeX * clamp(fx, 0.0, 1.0), freeY * clamp(fy, 0.0, 1.0), w, h)\n\nproc init*(self: App) =\n if self.appConfig.durationSeconds <= 0:\n self.appConfig.durationSeconds = 60.0\n if self.appConfig.zoomStart <= 0:\n self.appConfig.zoomStart = 1.0\n if self.appConfig.zoomEnd <= 0:\n self.appConfig.zoomEnd = self.appConfig.zoomStart\n\nproc clearTransientInputs(self: App) =\n self.appConfig.inputImage = none(Image)\n self.appConfig.image = nil\n\nproc animationCycles(self: App, now: float, durationSeconds: float): float =\n if not self.phaseRestored:\n self.phaseRestored = true\n if self.appConfig.phaseStateKey != \"\" and not self.scene.isNil and\n not self.scene.state.isNil and self.scene.state.hasKey(self.appConfig.phaseStateKey):\n let persisted = self.scene.state[self.appConfig.phaseStateKey].getFloat()\n self.timeOffset = persisted * durationSeconds - now\n result = (now + self.timeOffset) / durationSeconds\n\nproc renderAt*(self: App, context: ExecutionContext, image: Image, now: float) =\n try:\n let sourceImage = self.appConfig.image\n if sourceImage.isNil:\n raise newException(Exception, \"No image provided.\")\n if sourceImage == image:\n # The producer already decoded straight into this canvas.\n return\n let durationSeconds = if self.appConfig.durationSeconds > 0:\n self.appConfig.durationSeconds else: 60.0\n let cycles = self.animationCycles(now, durationSeconds)\n let t = cycles - floor(cycles)\n let cycle = floor(cycles).int\n let zoom = zoomAt(self.appConfig.motion, self.appConfig.zoomStart,\n self.appConfig.zoomEnd, t, self.appConfig.easing)\n let (sx, sy, sw, sh) = cropRect(sourceImage.width, sourceImage.height,\n image.width, image.height, zoom, self.appConfig.anchor, t,\n self.appConfig.motion, self.appConfig.easing, cycle)\n if sw <= 0 or sh <= 0:\n raise newException(Exception, \"Invalid crop rect.\")\n # Sample the source rect straight onto the canvas: pixie's draw()\n # pre-scales whole-source copies for non-integer zooms, which allocates\n # multi-MB intermediates every render and fragments ESP32 PSRAM.\n let stepX = sw / image.width.float\n let stepY = sh / image.height.float\n for y in 0 ..< image.height:\n let srcY = (sy + (y.float + 0.5) * stepY - 0.5).float32\n for x in 0 ..< image.width:\n let srcX = (sx + (x.float + 0.5) * stepX - 0.5).float32\n image.unsafe[x, y] = sourceImage.getRgbaSmooth(srcX, srcY)\n if self.appConfig.phaseStateKey != \"\":\n self.scene.state[self.appConfig.phaseStateKey] = %*(cycles)\n except Exception as e:\n let message = &\"Error rendering zoom & pan: {e.msg}\"\n self.logError(message)\n when defined(frameosEmbedded):\n renderErrorInto(image, image.width, image.height, message)\n else:\n let errorImage = renderError(image.width, image.height, message)\n scaleAndDrawImage(image, errorImage, \"cover\")\n\nproc render*(self: App, context: ExecutionContext, image: Image) =\n renderAt(self, context, image, epochTime())\n\nproc run*(self: App, context: ExecutionContext) =\n try:\n render(self, context, context.image)\n finally:\n self.clearTransientInputs()\n\nproc get*(self: App, context: ExecutionContext): Image =\n try:\n result = if self.appConfig.inputImage.isSome:\n self.appConfig.inputImage.get()\n elif context.hasImage:\n newImage(context.image.width, context.image.height)\n else:\n newImage(self.frameConfig.renderWidth(), self.frameConfig.renderHeight())\n render(self, context, result)\n finally:\n self.clearTransientInputs()\n", + "app_loader.nim": "{.warning[UnusedImport]: off.}\nimport json\nimport options\nimport strutils\nimport pixie\nimport frameos/values\nimport frameos/types\nimport ./app as app_module\n\nproc init*(\n node: DiagramNode,\n scene: FrameScene,\n): AppRoot =\n let params = node.data[\"config\"]\n if params.kind != JObject:\n raise newException(Exception, \"Invalid config format\")\n let config = app_module.AppConfig(\n inputImage: none(Image),\n image: nil,\n motion: params{\"motion\"}.getStr(\"kenBurns\"),\n zoomStart: block:\n var v = 1.0\n if params.hasKey(\"zoomStart\"):\n let n = params{\"zoomStart\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n zoomEnd: block:\n var v = 1.4\n if params.hasKey(\"zoomEnd\"):\n let n = params{\"zoomEnd\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n durationSeconds: block:\n var v = 60.0\n if params.hasKey(\"durationSeconds\"):\n let n = params{\"durationSeconds\"}\n if n.kind == JFloat:\n v = n.getFloat()\n elif n.kind == JInt:\n v = n.getInt().float\n elif n.kind == JString:\n try: v = parseFloat(n.getStr())\n except CatchableError: discard\n v,\n easing: params{\"easing\"}.getStr(\"easeInOut\"),\n anchor: params{\"anchor\"}.getStr(\"center\"),\n phaseStateKey: params{\"phaseStateKey\"}.getStr(\"\"),\n )\n\n result = app_module.App(\n appConfig: config,\n nodeName: node.data{\"name\"}.getStr(),\n nodeId: node.id,\n scene: scene,\n frameConfig: scene.frameConfig,\n )\n app_module.init(app_module.App(result))\n\nproc setField*(self: AppRoot, field: string, value: Value) =\n let app = app_module.App(self)\n case field:\n of \"inputImage\":\n app.appConfig.inputImage = (if value.kind == fkImage: some(value.asImage()) else: none(Image))\n of \"image\":\n app.appConfig.image = value.asImage()\n of \"motion\":\n app.appConfig.motion = value.asString()\n of \"zoomStart\":\n app.appConfig.zoomStart = value.asFloat()\n of \"zoomEnd\":\n app.appConfig.zoomEnd = value.asFloat()\n of \"durationSeconds\":\n app.appConfig.durationSeconds = value.asFloat()\n of \"easing\":\n app.appConfig.easing = value.asString()\n of \"anchor\":\n app.appConfig.anchor = value.asString()\n of \"phaseStateKey\":\n app.appConfig.phaseStateKey = value.asString()\n else:\n raise newException(ValueError, \"Unknown field: \" & field)\n\nproc get*(self: AppRoot, context: ExecutionContext): Value =\n return app_module.get(app_module.App(self), context)\n\nproc run*(self: AppRoot, context: ExecutionContext) =\n app_module.run(app_module.App(self), context)\n", + "config.json": "{\n \"name\": \"Zoom & Pan\",\n \"description\": \"Ken Burns style image motion: every render draws a slightly different crop, so consecutive renders form a smooth zoom and pan. Only useful on displays that can render many times per second (HDMI, LCD, web) - e-ink panels refresh far too slowly for this.\",\n \"category\": \"render\",\n \"version\": \"1.0.0\",\n \"fields\": [\n {\n \"name\": \"inputImage\",\n \"type\": \"image\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Image to render on (optional)\",\n \"showIf\": [\n {\n \"field\": \".meta.showOutput\"\n }\n ]\n },\n {\n \"name\": \"image\",\n \"type\": \"image\",\n \"required\": true,\n \"label\": \"Image\"\n },\n {\n \"name\": \"motion\",\n \"type\": \"select\",\n \"options\": [\n \"zoomInOut\",\n \"zoomIn\",\n \"panLeftRight\",\n \"panTopBottom\",\n \"kenBurns\"\n ],\n \"value\": \"kenBurns\",\n \"required\": true,\n \"label\": \"Motion\",\n \"hint\": \"kenBurns combines zooming with a focal point that drifts towards a new corner every cycle.\"\n },\n {\n \"name\": \"zoomStart\",\n \"type\": \"float\",\n \"value\": \"1.0\",\n \"required\": false,\n \"label\": \"Start zoom\",\n \"hint\": \"Zoom level at the start of a cycle. 1.0 shows the largest crop of the image that covers the canvas.\",\n \"showIf\": [\n {\n \"field\": \"motion\",\n \"operator\": \"notIn\",\n \"value\": [\n \"panLeftRight\",\n \"panTopBottom\"\n ]\n }\n ]\n },\n {\n \"name\": \"zoomEnd\",\n \"type\": \"float\",\n \"value\": \"1.4\",\n \"required\": false,\n \"label\": \"End zoom\",\n \"hint\": \"Zoom level at the end of a cycle. Pan motions keep this zoom for the whole cycle.\",\n \"placeholder\": \"1.3\"\n },\n {\n \"name\": \"durationSeconds\",\n \"type\": \"float\",\n \"value\": \"60\",\n \"required\": false,\n \"label\": \"Cycle duration (seconds)\",\n \"hint\": \"Time for one full zoom cycle.\",\n \"placeholder\": \"60\"\n },\n {\n \"name\": \"easing\",\n \"type\": \"select\",\n \"options\": [\n \"linear\",\n \"easeInOut\",\n \"sine\"\n ],\n \"value\": \"easeInOut\",\n \"required\": false,\n \"label\": \"Easing\"\n },\n {\n \"name\": \"anchor\",\n \"type\": \"select\",\n \"options\": [\n \"center\",\n \"top\",\n \"bottom\",\n \"left\",\n \"right\",\n \"random\"\n ],\n \"value\": \"center\",\n \"required\": false,\n \"label\": \"Anchor\",\n \"hint\": \"Part of the image the zoom stays locked on. Random picks a new deterministic spot every cycle.\",\n \"showIf\": [\n {\n \"field\": \"motion\",\n \"operator\": \"in\",\n \"value\": [\n \"zoomInOut\",\n \"zoomIn\"\n ]\n }\n ]\n },\n {\n \"name\": \"phaseStateKey\",\n \"type\": \"string\",\n \"value\": \"\",\n \"required\": false,\n \"label\": \"Phase state key\",\n \"hint\": \"Enter a state key to persist the animation phase so scene re-activations continue smoothly.\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + } +} diff --git a/frontend/src/models/appsModel.tsx b/frontend/src/models/appsModel.tsx index 1762a9c47..8dd5e4dcf 100644 --- a/frontend/src/models/appsModel.tsx +++ b/frontend/src/models/appsModel.tsx @@ -4,6 +4,7 @@ import type { appsModelType } from './appsModelType' import { loaders } from 'kea-loaders' import { AppConfig } from '../types' import { apiFetch } from '../utils/apiFetch' +import { embeddedBuiltinAppConfigs } from '../generated/builtinApps' import { embeddedRepoAppConfigs } from '../generated/repoApps' export const categoryLabels: Record = { @@ -14,7 +15,8 @@ export const categoryLabels: Record = { } function withEmbeddedRepoApps(apps: Record): Record { - return { ...apps, ...embeddedRepoAppConfigs } + // Build-time catalogs fill the gaps; anything the backend serves wins. + return { ...embeddedBuiltinAppConfigs, ...apps, ...embeddedRepoAppConfigs } } export const appsModel = kea([ diff --git a/frontend/src/scenes/frame/frameFormSceneErrors.ts b/frontend/src/scenes/frame/frameFormSceneErrors.ts new file mode 100644 index 000000000..af9e1b1a9 --- /dev/null +++ b/frontend/src/scenes/frame/frameFormSceneErrors.ts @@ -0,0 +1,35 @@ +import { builtinFrameEventNames } from '../../utils/frameEvents' +import { FrameScene } from '../../types' + +// The per-scene slice of frameForm validation (state fields + custom events), +// shared by the real frameLogic form and the embedded editor's in-memory shim +// so both editors flag the same problems. +export function frameFormSceneErrors(scenes: Partial[] | undefined): Record[] { + return (scenes ?? []).map((scene: Record) => { + const customEventNames = (scene.customEvents ?? []).map((event: Record) => + String(event.name ?? '').trim() + ) + return { + fields: (scene.fields ?? []).map((field: Record) => ({ + name: String(field.name ?? '').trim() ? '' : 'Codename is required', + type: field.type ? '' : 'Type is required', + })), + customEvents: (scene.customEvents ?? []).map((event: Record, eventIndex: number) => { + const name = String(event.name ?? '').trim() + return { + name: !name + ? 'Event name is required' + : builtinFrameEventNames.has(name) + ? 'Event name must not match a built-in event' + : customEventNames.findIndex((candidate: string) => candidate === name) !== eventIndex + ? 'Event name must be unique' + : '', + fields: (event.fields ?? []).map((field: Record) => ({ + name: String(field.name ?? '').trim() ? '' : 'Codename is required', + type: field.type ? '' : 'Type is required', + })), + } + }), + } + }) +} diff --git a/frontend/src/scenes/frame/frameLogic.ts b/frontend/src/scenes/frame/frameLogic.ts index 6811e4255..91bb7eea0 100644 --- a/frontend/src/scenes/frame/frameLogic.ts +++ b/frontend/src/scenes/frame/frameLogic.ts @@ -51,7 +51,8 @@ import { urls } from '../../urls' import { normalizeFrameCompilationMode } from '../../utils/frameBuildOptions' import { frameHasActivityLog } from '../../decorators/frame' import { frameRunsScenesInterpreted, sceneExecutionForFrame } from '../../utils/sceneExecution' -import { builtinFrameEventNames, normalizeCustomEvent } from '../../utils/frameEvents' +import { normalizeCustomEvent } from '../../utils/frameEvents' +import { frameFormSceneErrors } from './frameFormSceneErrors' import { cloneSplitScreenSceneLayout, defaultSplitScreenBackground, @@ -1657,33 +1658,7 @@ export const frameLogic = kea([ pass: state.frame_admin_auth?.pass ? undefined : 'Password is required', } : undefined, - scenes: (state.scenes ?? []).map((scene: Record) => { - const customEventNames = (scene.customEvents ?? []).map((event: Record) => - String(event.name ?? '').trim() - ) - return { - fields: (scene.fields ?? []).map((field: Record) => ({ - name: String(field.name ?? '').trim() ? '' : 'Codename is required', - type: field.type ? '' : 'Type is required', - })), - customEvents: (scene.customEvents ?? []).map((event: Record, eventIndex: number) => { - const name = String(event.name ?? '').trim() - return { - name: !name - ? 'Event name is required' - : builtinFrameEventNames.has(name) - ? 'Event name must not match a built-in event' - : customEventNames.findIndex((candidate: string) => candidate === name) !== eventIndex - ? 'Event name must be unique' - : '', - fields: (event.fields ?? []).map((field: Record) => ({ - name: String(field.name ?? '').trim() ? '' : 'Codename is required', - type: field.type ? '' : 'Type is required', - })), - } - }), - } - }), + scenes: frameFormSceneErrors(state.scenes), mountpoints: state.mountpoints?.enabled ? { items: (state.mountpoints.items ?? []).map((item) => diff --git a/frontend/src/scenes/frame/panels/Scenes/LivePreviewModal.tsx b/frontend/src/scenes/frame/panels/Scenes/LivePreviewModal.tsx index 8b514584f..bfb2b3197 100644 --- a/frontend/src/scenes/frame/panels/Scenes/LivePreviewModal.tsx +++ b/frontend/src/scenes/frame/panels/Scenes/LivePreviewModal.tsx @@ -18,7 +18,7 @@ import { StateFieldEdit } from './StateFieldEdit' // Match the real logs' terminal text coloring (see Logs.tsx logTypeClassName). // The preview's runtime lines are raw strings, so classify them by content. -function logLineColor(line: string): string { +export function logLineColor(line: string): string { if (/error|failed|exception/i.test(line)) { return 'text-red-300' } @@ -29,7 +29,7 @@ function logLineColor(line: string): string { } // Same timestamp format as the real logs panel (Logs.tsx formatTimestamp). -function formatTimestamp(isoTimestamp: string): string { +export function formatTimestamp(isoTimestamp: string): string { const date = new Date(isoTimestamp) return `${date.getFullYear()}-${date.getMonth() + 1 < 10 ? '0' : ''}${date.getMonth() + 1}-${ date.getDate() < 10 ? '0' : '' @@ -41,7 +41,7 @@ function formatTimestamp(isoTimestamp: string): string { // Open the current canvas image in a new tab. The window is opened // synchronously so popup blockers count it as user-initiated; the blob URL is // filled in once the canvas has been encoded. -function openCanvasImageInNewTab(canvas: HTMLCanvasElement): void { +export function openCanvasImageInNewTab(canvas: HTMLCanvasElement): void { const win = window.open('', '_blank') canvas.toBlob((blob) => { if (!blob) { @@ -59,7 +59,7 @@ function openCanvasImageInNewTab(canvas: HTMLCanvasElement): void { // Runtime log lines are mostly JSON like {"event":"debug","message":"..."}. // Render them the way the real logs panel renders webhook lines: the event // name highlighted, the remaining keys as key=value pairs. -function renderLogLine(line: string): JSX.Element | string { +export function renderLogLine(line: string): JSX.Element | string { if (line.startsWith('{')) { try { const { event, timestamp: _timestamp, ...rest } = JSON.parse(line) diff --git a/frontend/src/scenes/frame/panels/Scenes/controlLogic.tsx b/frontend/src/scenes/frame/panels/Scenes/controlLogic.tsx index be85f9ca4..6491d4e08 100644 --- a/frontend/src/scenes/frame/panels/Scenes/controlLogic.tsx +++ b/frontend/src/scenes/frame/panels/Scenes/controlLogic.tsx @@ -51,6 +51,11 @@ export const controlLogic = kea([ sync: async (_, breakpoint) => { await breakpoint(100) + // The standalone embedded editor has no frame to sync state from. + if (typeof window !== 'undefined' && (window as any).FRAMEOS_EMBEDDED_NO_BACKEND) { + return emptyFrameStateRecord + } + try { const statesResponse = await apiFetch(`/api/frames/${props.frameId}/states`) if (statesResponse.ok) { diff --git a/frontend/src/scenes/frame/panels/Scenes/livePreviewLogic.tsx b/frontend/src/scenes/frame/panels/Scenes/livePreviewLogic.tsx index 6b9012967..0f6916b9c 100644 --- a/frontend/src/scenes/frame/panels/Scenes/livePreviewLogic.tsx +++ b/frontend/src/scenes/frame/panels/Scenes/livePreviewLogic.tsx @@ -98,6 +98,7 @@ export const livePreviewLogic = kea([ setPreviewState: (state: Record) => ({ state }), dispatchPreviewEvent: (name: string, payload: Record) => ({ name, payload }), forcePreviewRender: true, + setPreviewSettings: (settings: Record>) => ({ settings }), }), reducers({ livePreviewSceneId: [ @@ -168,6 +169,15 @@ export const livePreviewLogic = kea([ previewFrame: (state) => state + 1, }, ], + // User-entered app settings (API keys etc.) merged over the backend's + // assembled settings on every (re)start. Kept in memory only — never + // persisted or sent anywhere except into the wasm runtime. + previewSettings: [ + {} as Record>, + { + setPreviewSettings: (_, { settings }) => settings, + }, + ], }), selectors({ livePreviewScene: [ @@ -272,6 +282,9 @@ export const livePreviewLogic = kea([ // Seed the scene's public fields with the values the user entered in the // form so the in-browser preview reflects their input, not stored defaults. const payloadScenes = collectScenePreviewPayloadScenes(scene, sceneList, state ?? null) + // Snapshot of the scenes as loaded (without state seeding), so + // forcePreviewRender can tell whether they were edited since. + cache.initialScenesJson = JSON.stringify(collectScenePreviewPayloadScenes(scene, sceneList, null)) const { width, height } = values.previewDimensions const frameId = values.frame?.id ?? props.frameId @@ -279,25 +292,38 @@ export const livePreviewLogic = kea([ // Fetch the frame's assembled settings (app API keys etc.) so data apps // that need secrets can run in the preview. Best-effort: a scene with no // secret-using apps still previews fine if this fails. - let settingsJson = '{}' + let settings: Record = {} try { const response = await apiFetch(`/api/frames/${frameId}/scene_preview_settings`) if (response.ok) { const data = await response.json() - settingsJson = JSON.stringify(data?.settings ?? {}) + settings = data?.settings ?? {} } } catch (error) { // fall through with empty settings } + // User-entered keys (setPreviewSettings) win over the backend's, merged + // per settings group. + for (const [group, groupValues] of Object.entries(values.previewSettings ?? {})) { + settings[group] = { ...(settings[group] ?? {}), ...groupValues } + } + const settingsJson = JSON.stringify(settings) // Same-origin backend proxy so the runtime's HTTP requests (image apps, // weather, ...) work despite CORS — the worker's sync XHR carries auth // cookies to it. Resolve the project-prefixed absolute path up front. + // An embedding page (the standalone editor bundle) can supply its own + // proxy endpoint via FRAMEOS_APP_CONFIG.preview_proxy_url instead. let proxyUrl = '' - try { - proxyUrl = getBasePath() + (await projectApiPath(`/api/frames/${frameId}/scene_preview_proxy`)) - } catch (error) { - // preview still runs; external fetches will fail with CORS as before + const configuredProxyUrl = (window as any).FRAMEOS_APP_CONFIG?.preview_proxy_url + if (typeof configuredProxyUrl === 'string' && configuredProxyUrl) { + proxyUrl = configuredProxyUrl + } else { + try { + proxyUrl = getBasePath() + (await projectApiPath(`/api/frames/${frameId}/scene_preview_proxy`)) + } catch (error) { + // preview still runs; external fetches will fail with CORS as before + } } let worker: Worker @@ -378,8 +404,30 @@ export const livePreviewLogic = kea([ cache.worker?.postMessage({ type: 'event', name, payload }) }, forcePreviewRender: () => { + // The worker got a snapshot of the scenes when the preview opened; if + // they were edited since (diagram, panels, ...), restart it on the + // fresh scenes — carrying the current scene state over — instead of + // re-rendering the stale snapshot. + const sceneId = values.livePreviewSceneId + if (sceneId && !values.livePreviewScenes && cache.initialScenesJson) { + const scene = values.scenes.find((item: FrameScene) => item.id === sceneId) + if (scene) { + const currentScenesJson = JSON.stringify(collectScenePreviewPayloadScenes(scene, values.scenes, null)) + if (currentScenesJson !== cache.initialScenesJson) { + actions.openLivePreview(sceneId, values.previewState) + return + } + } + } cache.worker?.postMessage({ type: 'render' }) }, + setPreviewSettings: () => { + // New keys only reach the runtime through its init message: restart the + // running preview so they take effect. + if (values.livePreviewSceneId) { + actions.openLivePreview(values.livePreviewSceneId, values.previewState, values.livePreviewScenes) + } + }, })), beforeUnmount(({ cache }) => { cache.worker?.terminate() diff --git a/frontend/src/scenes/socketLogic.tsx b/frontend/src/scenes/socketLogic.tsx index 3a23797e5..8183ab77f 100644 --- a/frontend/src/scenes/socketLogic.tsx +++ b/frontend/src/scenes/socketLogic.tsx @@ -29,6 +29,9 @@ export const socketLogic = kea([ socketReconnected: true, }), afterMount(({ actions, cache }) => { + if (typeof window !== 'undefined' && (window as any).FRAMEOS_EMBEDDED_NO_BACKEND) { + return + } const frameControlMode = isFrameControlMode() const isFrameOSAdmin = isInFrameAdminMode() diff --git a/frontend/src/utils/apiFetch.ts b/frontend/src/utils/apiFetch.ts index f689d8c47..a9851ebb1 100644 --- a/frontend/src/utils/apiFetch.ts +++ b/frontend/src/utils/apiFetch.ts @@ -41,13 +41,23 @@ export async function userExists(): Promise { } function routeToAuthStatus(status: FirstUserStatus): Promise { - router.actions.push( + // replace, not push: an auth-guard redirect must not add a history entry — + // otherwise Back loops through the redirect forever (worst when the app is + // embedded in an iframe, where it also pollutes the parent's history). + router.actions.replace( status === 'exists' ? urls.login() : status === 'missing' ? urls.signup() : urls.setupUnavailable() ) return new Promise(() => {}) } export async function apiFetch(input: RequestInfo | URL, options: ApiFetchOptions = {}): Promise { + // The standalone embedded editor (editor.html sets the flag) has no + // backend: answer every API call with a synthetic 404 so the callers' + // fallbacks (embedded app catalog, fonts, validation) engage immediately + // instead of resolving project ids or redirecting to auth screens. + if (typeof window !== 'undefined' && (window as any).FRAMEOS_EMBEDDED_NO_BACKEND) { + return new Response('null', { status: 404, statusText: 'No backend in the embedded editor' }) + } const frameControlMode = isFrameControlMode() const inFrameAdminMode = isInFrameAdminMode() const headers: HeadersInit = options.headers || {} diff --git a/frontend/src/utils/projectApi.ts b/frontend/src/utils/projectApi.ts index 853d988bf..cabfa51e8 100644 --- a/frontend/src/utils/projectApi.ts +++ b/frontend/src/utils/projectApi.ts @@ -48,6 +48,12 @@ export async function getCurrentProjectId(): Promise { if (currentProjectId) { return currentProjectId } + // The standalone embedded editor has no backend (and apiFetch is not on + // this path, so its synthetic-404 guard doesn't apply): fail fast instead + // of firing a request that can never succeed. + if (typeof window !== 'undefined' && (window as any).FRAMEOS_EMBEDDED_NO_BACKEND) { + throw new Error('No backend in the embedded editor') + } if (!currentProjectPromise) { currentProjectPromise = fetch(`${getBasePath()}/api/projects`, { headers: { Accept: 'application/json' }, diff --git a/frontend/src/utils/sceneApps.ts b/frontend/src/utils/sceneApps.ts index 6bba2d4c0..7ee90bfdb 100644 --- a/frontend/src/utils/sceneApps.ts +++ b/frontend/src/utils/sceneApps.ts @@ -1,5 +1,6 @@ import { AppConfig, FrameScene, SceneApp } from '../types' import { apiFetch } from './apiFetch' +import { embeddedBuiltinAppSources } from '../generated/builtinApps' import { embeddedRepoAppConfigs, embeddedRepoAppSources } from '../generated/repoApps' export const javascriptAppSourceFiles = ['app.ts', 'app.js', 'app.tsx', 'app.jsx'] @@ -151,7 +152,7 @@ export function appLabel(app: AppConfig, prefix?: string): string { } export async function loadAppSources(keyword: string): Promise> { - const embeddedSources = embeddedRepoAppSources[keyword] + const embeddedSources = embeddedRepoAppSources[keyword] ?? embeddedBuiltinAppSources[keyword] if (embeddedSources) { return { ...embeddedSources } } diff --git a/frontend/utils.mjs b/frontend/utils.mjs index 651ea156c..1c8cbb6b3 100644 --- a/frontend/utils.mjs +++ b/frontend/utils.mjs @@ -205,11 +205,14 @@ function getBuiltEntryPoints(config, result) { let outfiles = [] if (config.outdir) { // convert "src/index.tsx" --> /a/posthog/frontend/dist/index.js + // ({in, out} entries land at outdir/out.js) outfiles = config.entryPoints.map((file) => - path - .resolve(config.absWorkingDir, file) - .replace('/src/', '/dist/') - .replace(/\.[^.]+$/, '.js') + typeof file === 'object' + ? path.resolve(config.absWorkingDir, config.outdir, `${file.out}.js`) + : path + .resolve(config.absWorkingDir, file) + .replace('/src/', '/dist/') + .replace(/\.[^.]+$/, '.js') ) } else if (config.outfile) { outfiles = [path.resolve(config.absWorkingDir, config.outfile)] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e07dccf32..b1cec2623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,8 @@ importers: specifier: ^0.8.3 version: 0.8.3 + frameos/editor: {} + frameos/frontend: dependencies: '@babel/runtime': @@ -206,6 +208,12 @@ importers: specifier: 5.8.3 version: 5.8.3 + frameos/wasm: + devDependencies: + typescript: + specifier: ~5.9.3 + version: 5.9.3 + frontend: dependencies: '@babel/runtime': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f68533970..7f5158e2d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ packages: - frontend - frameos/frontend + - frameos/wasm + - frameos/editor diff --git a/project-folders.json b/project-folders.json index 0616d7e14..d177d74be 100644 --- a/project-folders.json +++ b/project-folders.json @@ -2,7 +2,11 @@ "projects": { "frameos": { "include": ["frameos"], - "exclude": ["frameos/remote"] + "exclude": ["frameos/remote", "frameos/wasm/package.json", "frameos/editor/package.json"] + }, + "editor": { + "include": ["frontend", "frameos/editor", "package.json", "pnpm-lock.yaml", "project-folders.json"], + "exclude": ["frameos/editor/package.json"] }, "remote": { "include": ["frameos/remote"] diff --git a/tools/update_versions.py b/tools/update_versions.py index 08e48e3e8..e306e6c5f 100755 --- a/tools/update_versions.py +++ b/tools/update_versions.py @@ -218,10 +218,36 @@ def main(argv: List[str] | None = None) -> int: output = json.dumps(ordered_versions, indent=2) + "\n" VERSIONS_FILE.write_text(output, encoding="utf-8") + _sync_npm_package_versions(ordered_versions) + changed = "yes" if ordered_versions != existing_versions else "no" print(f"versions_updated={changed}") return 0 +def _sync_npm_package_versions(versions: Dict[str, str]) -> None: + """Sync npm packages to the component version that owns their content. + + frameos-wasm follows the runtime, while frameos-editor has its own hash + because frontend-only changes alter its bundle. Package files are excluded + from those hashes so writing a version cannot force another bump. + """ + package_projects = {"wasm": "frameos", "editor": "editor"} + for name, project_name in package_projects.items(): + component = versions.get(project_name) or "" + component_version = component.split("+", 1)[0] + if not BASE_VERSION_RE.fullmatch(component_version): + continue + package_json = ROOT / "frameos" / name / "package.json" + if not package_json.exists(): + continue + data = json.loads(package_json.read_text(encoding="utf-8")) + if data.get("version") == component_version: + continue + data["version"] = component_version + package_json.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + print(f"{data.get('name')} version set to {component_version}") + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/versions.json b/versions.json index 31ff74c7f..06ee71610 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,6 @@ { "frameos": "2026.7.3+05d3d9138693ab97624c4b404f0a4e6d444d670c", + "editor": "2026.7.3+96833d319c3661e0d9ff995943a363095121fb35", "remote": "2026.6.24+e4b0a9298c6906640abdc08b7aa72d341cf07982", "docker": "2026.7.4+a185b3ce88350a63972144c4aeffdf4840d5c2b9" }