diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py index a629fc04b..2f15c42bf 100644 --- a/backend/app/api/templates.py +++ b/backend/app/api/templates.py @@ -25,6 +25,7 @@ from app.api import api_project, api_open from app.redis import get_redis from app.tenancy import current_project_id, get_user_project +from app.utils.versions import current_frameos_version from app.utils.jwt_tokens import validate_scoped_token from app.api.auth import get_current_user_from_request @@ -45,6 +46,12 @@ def respond_with_template(template: Template): scenes = template_dict.pop('scenes', []) template_dict['scenes'] = './scenes.json' template_dict['image'] = './image.jpg' + # Cloud and other repositories treat this as the oldest compatible + # FrameOS release. Without deeper feature inference, the exporting + # release is a conservative and safe automatic minimum. + frameos_version = current_frameos_version() + if frameos_version: + template_dict['frameosVersion'] = frameos_version zf.writestr(f"{template_name}/scenes.json", json.dumps(scenes, indent=2)) zf.writestr(f"{template_name}/template.json", json.dumps(template_dict, indent=2)) if template.image: diff --git a/backend/app/api/tests/test_repositories.py b/backend/app/api/tests/test_repositories.py index 6930acf8a..47a5af152 100644 --- a/backend/app/api/tests/test_repositories.py +++ b/backend/app/api/tests/test_repositories.py @@ -59,6 +59,7 @@ async def test_get_system_repositories_includes_packaged_templates(async_client) samples = next(repo for repo in repos if repo["id"] == "system-samples") by_name = {template["name"]: template for template in samples["templates"]} assert by_name["Chromium Screenshot"]["embedded"] is False + assert by_name["Bird field journal"]["frameosVersion"] == "2026.7.5" assert by_name["Webcam RSTP"]["embedded"] is False assert "embedded" not in by_name["Weather"] diff --git a/backend/app/api/tests/test_templates.py b/backend/app/api/tests/test_templates.py index cc01a8910..859c50eaf 100644 --- a/backend/app/api/tests/test_templates.py +++ b/backend/app/api/tests/test_templates.py @@ -1,5 +1,10 @@ +import io +import json +import zipfile + import pytest from app.models.template import Template +from app.utils.versions import current_frameos_version @pytest.mark.asyncio @@ -52,6 +57,10 @@ async def test_export_template(async_client, db): response = await async_client.get(f'/api/templates/{t.id}/export') assert response.status_code == 200 assert response.headers['content-type'] == 'application/zip' + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + manifest_path = next(name for name in archive.namelist() if name.endswith('/template.json')) + manifest = json.loads(archive.read(manifest_path)) + assert manifest['frameosVersion'] == current_frameos_version() @pytest.mark.asyncio diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png index ee3d8a45e..bf4e6b0d7 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png index 57b86fdcf..3ecbe9fa1 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png index e6dfac12b..49e18218e 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png index c57d23bd1..d32975348 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png index 3d75aaf2c..11f12019d 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png index 64639a49a..7a8339442 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--dark--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--dark--full.png index 7af165f75..12f8ab699 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--dark--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--dark--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--dark--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--dark--mid.png index 64145260e..ddbc8bf7c 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--dark--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--dark--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--light--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--light--full.png index a1014cb58..c7322dd92 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--light--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--light--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--light--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--light--mid.png index 9e1b1b7b4..30c694199 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--light--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/scene-workspace--apps-drawer--light--mid.png differ diff --git a/frameos/src/frameos/tests/test_bird_journal_scene.nim b/frameos/src/frameos/tests/test_bird_journal_scene.nim new file mode 100644 index 000000000..d8603085c --- /dev/null +++ b/frameos/src/frameos/tests/test_bird_journal_scene.nim @@ -0,0 +1,284 @@ +import std/[base64, json, net, os, strutils, tables] +import pixie +import ../interpreter +import ../types + +# End-to-end test for the "Bird field journal" sample scene: a mock iNaturalist +# serves two bird sightings with licensed photos, a mock OpenAI Responses API +# returns a canned plate for image_generation calls and an approving verdict +# for verification calls. The scene must log both species, draw + verify one +# plate per render, save them as assets, and cycle through the collection. + +const ScenesPath = "../repo/scenes/samples/Bird field journal/scenes.json" + +var mockPort: Port +var mockThread: Thread[void] + +proc jsonResponse(client: Socket, body: string) = + client.send("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " & + $body.len & "\r\n\r\n" & body) + client.close() + +proc bytesResponse(client: Socket, body: string) = + client.send("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: " & + $body.len & "\r\n\r\n" & body) + client.close() + +var generationCalls = 0 +var verificationCalls = 0 + +proc observationsBody(): string = + let photoBase = "http://127.0.0.1:" & $int(mockPort) & "/photos" + $(%*{ + "total_results": 2, + "results": [ + { + "observed_on": "2026-07-10", + "taxon": {"id": 111, "name": "Parus major", "preferred_common_name": "Great Tit"}, + "photos": [ + {"url": photoBase & "/1/square.jpg", "license_code": "cc-by", "attribution": "(c) tester, CC BY"} + ] + }, + { + "observed_on": "2026-07-12", + "taxon": {"id": 222, "name": "Erithacus rubecula", "preferred_common_name": "European Robin"}, + "photos": [ + {"url": photoBase & "/2/square.jpg", "license_code": "cc-by-nc", "attribution": "(c) tester, CC BY-NC"} + ] + }, + { + "observed_on": "2026-07-12", + "taxon": {"id": 222, "name": "Erithacus rubecula", "preferred_common_name": "European Robin"}, + "photos": [ + {"url": photoBase & "/3/square.jpg", "license_code": "cc-by-nc", "attribution": "(c) tester, CC BY-NC"} + ] + } + ] + }) + +proc mockServerLoop() {.thread.} = + var server = newSocket() + server.setSockOpt(OptReuseAddr, true) + server.bindAddr(Port(0), "127.0.0.1") + server.listen() + var boundAddr: string + var boundPort: Port + (boundAddr, boundPort) = server.getLocalAddr() + mockPort = boundPort + + var photoBytes = "" + var plateB64 = "" + + while true: + var client: Socket + server.accept(client) + var requestLine = "" + var contentLength = 0 + try: + requestLine = client.recvLine(timeout = 10000) + while true: + let line = client.recvLine(timeout = 10000) + if line == "\r\n" or line.len == 0: + break + if line.toLowerAscii().startsWith("content-length:"): + contentLength = parseInt(line.split(":", maxsplit = 1)[1].strip()) + except CatchableError: + client.close() + continue + + var body = "" + if contentLength > 0: + try: + body = client.recv(contentLength, timeout = 10000) + except CatchableError: + discard + + let parts = requestLine.splitWhitespace() + let path = if parts.len >= 2: parts[1] else: "/" + + # Lazy fixtures: pixie can encode PNG (not JPEG); the photo bytes are only + # base64-shuttled through the app so the format never matters. + if photoBytes.len == 0: + var photo = newImage(8, 8) + photo.fill(parseHtmlColor("#8899aa")) + photoBytes = encodeImage(photo, PngFormat) + if plateB64.len == 0: + var plate = newImage(100, 160) + plate.fill(parseHtmlColor("#336699")) + plateB64 = encode(encodeImage(plate, PngFormat)) + + if path == "/quit": + client.send("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + client.close() + break + elif path.startsWith("/v2/observations"): + jsonResponse(client, observationsBody()) + elif path.startsWith("/photos/"): + bytesResponse(client, photoBytes) + elif path.startsWith("/v1/responses"): + if body.contains("image_generation"): + inc generationCalls + jsonResponse(client, $(%*{ + "output": [ + {"type": "reasoning", "summary": []}, + {"type": "image_generation_call", "status": "completed", "result": plateB64} + ] + })) + else: + inc verificationCalls + jsonResponse(client, $(%*{ + "output": [ + {"type": "message", "content": [ + {"type": "output_text", "text": "{\"ok\": true, \"reason\": \"matches the reference\"}"} + ]} + ] + })) + else: + client.send("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") + client.close() + + server.close() + +createThread(mockThread, mockServerLoop) +for _ in 0 ..< 200: + if int(mockPort) != 0: + break + sleep(20) +doAssert int(mockPort) != 0, "mock server did not start" +let mockBase = "http://127.0.0.1:" & $int(mockPort) + +let assetsDir = getTempDir() / "frameos-bird-journal-test" +removeDir(assetsDir) +createDir(assetsDir) + +var renderChainErrors: seq[string] = @[] + +proc testLogger(): Logger = + var logger = Logger(enabled: false) + logger.log = proc(payload: JsonNode) = + if getEnv("FRAMEOS_TEST_VERBOSE") == "1": + echo payload + let event = payload{"event"}.getStr() + if event == "runEventInterpreted:error" or + (event.startsWith("interpreter:") and event.contains(":error")) or + event in ["interpreter:graph:hopLimit", "interpreter:graph:cycle", "interpreter:nodeNotFound"]: + renderChainErrors.add($payload) + # JS app errors surface as log events too; catch them so failures are loud. + if event.startsWith("jsApp:error") or payload{"error"}.getStr().len > 0: + renderChainErrors.add($payload) + logger.enable = proc() = logger.enabled = true + logger.disable = proc() = logger.enabled = false + logger + +# Point the scene's app at the mock server before building it. +var scenesJson = parseJson(readFile(ScenesPath)) +doAssert scenesJson.len == 1, "expected one Bird Journal sample scene" +let birdJournalApp = scenesJson[0]["apps"]["birdJournal"] +doAssert not birdJournalApp.hasKey("origin"), + "Bird Journal must remain an inline scene app without a catalog origin" +doAssert birdJournalApp["sources"]{"app.ts"}.getStr().len > 0, + "Bird Journal must keep its app source inline in the scene" +for scene in scenesJson.items: + for node in scene["nodes"].items: + if node["data"]{"keyword"}.getStr() == "birdJournal": + node["data"]["config"]["inatHost"] = %*mockBase + node["data"]["config"]["openaiHost"] = %*mockBase + node["data"]["config"]["pollMinutes"] = %*60 + +let inputs = parseInterpretedSceneInputs($scenesJson) +doAssert inputs.len == 1, "expected one scene, got " & $inputs.len +let exportedScenes = buildInterpretedScenes(inputs) +doAssert exportedScenes.len == 1, "failed to build the scene" + +var uploaded = initTable[SceneId, ExportedInterpretedScene]() +for id, exported in exportedScenes: + uploaded[id] = exported +setUploadedInterpretedScenes(uploaded) +resetInterpretedScenes() + +let config = FrameConfig( + name: "test", + mode: "rpios", + width: 800, + height: 480, + rotate: 0, + scalingMode: "cover", + assetsPath: assetsDir, + debug: false, + settings: %*{"openAI": {"apiKey": "sk-test"}}, + saveAssets: %*false +) + +let persistedState = %*{"latitude": "50.85", "longitude": "4.35"} +let scene = init(inputs[0].id, config, testLogger(), persistedState) + +proc renderOnce(): Image = + var context = ExecutionContext( + scene: scene, event: "render", payload: %*{}, hasImage: false, + loopIndex: 0, loopKey: ".", nextSleep: 0.0 + ) + render(scene, context) + +# Render 1: polls sightings, draws + verifies the first plate (oldest sighting +# first: the Great Tit), and shows it. +let image1 = renderOnce() +doAssert image1.width == 800 and image1.height == 480 +doAssert renderChainErrors.len == 0, "render 1 errors:\n" & renderChainErrors.join("\n") +doAssert fileExists(assetsDir / "birdJournal" / "111-parus-major.png"), + "Great Tit plate was not saved" +doAssert not fileExists(assetsDir / "birdJournal" / "222-erithacus-rubecula.png"), + "only one plate should be drawn per render" +doAssert generationCalls == 1 and verificationCalls == 1, + "render 1 calls: " & $generationCalls & " generations, " & $verificationCalls & " verifications" +var caption = scene.state{"birdCaption"}.getStr() +doAssert "Great Tit" in caption and "Parus major" in caption, "caption 1: " & caption +doAssert "1/1" in caption, "caption 1: " & caption +# The plate must actually be drawn onto the canvas (solid #336699 fixture) +let center1 = image1.data[image1.dataIndex(400, 240)] +doAssert center1.b.int > center1.r.int + 40, "canvas does not show the plate color" + +# Journal state: both species logged, sightings counted per window +let journal1 = scene.state{"birdJournal"} +doAssert journal1{"species"}{"111"}{"plate"}.getStr() == "birdJournal/111-parus-major.png" +doAssert journal1{"species"}{"222"}{"sightings"}.getInt() == 2 +doAssert journal1{"species"}{"222"}{"plate"}.getStr() == "" + +# Render 2: draws the Robin, collection cycles to plate 2/2. +renderChainErrors = @[] +let image2 = renderOnce() +doAssert renderChainErrors.len == 0, "render 2 errors:\n" & renderChainErrors.join("\n") +doAssert fileExists(assetsDir / "birdJournal" / "222-erithacus-rubecula.png"), + "Robin plate was not saved" +doAssert generationCalls == 2 and verificationCalls == 2 +caption = scene.state{"birdCaption"}.getStr() +doAssert "European Robin" in caption and "2/2" in caption, "caption 2: " & caption +doAssert "2 sightings" in caption, "caption 2: " & caption +doAssert image2.width == 800 and image2.height == 480 + +# Render 3: nothing pending, no new API calls, cycles back to plate 1/2. +renderChainErrors = @[] +let image3 = renderOnce() +doAssert renderChainErrors.len == 0, "render 3 errors:\n" & renderChainErrors.join("\n") +doAssert generationCalls == 2 and verificationCalls == 2, "render 3 must not call OpenAI" +caption = scene.state{"birdCaption"}.getStr() +doAssert "Great Tit" in caption and "1/2" in caption, "caption 3: " & caption +doAssert image3.width == 800 and image3.height == 480 + +discard renderOnce() # one more cycle for good measure: 2/2 again +caption = scene.state{"birdCaption"}.getStr() +doAssert "2/2" in caption, "caption 4: " & caption + +setUploadedInterpretedScenes(initTable[SceneId, ExportedInterpretedScene]()) + +# Shut the mock down cleanly +try: + var client = newSocket() + client.connect("127.0.0.1", mockPort, timeout = 2000) + client.send("GET /quit HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + client.close() +except CatchableError: + discard +joinThread(mockThread) +removeDir(assetsDir) + +echo "test_bird_journal_scene: 2 plates drawn, verified, and cycling" diff --git a/frontend/src/embed/EmbedScenePreview.tsx b/frontend/src/embed/EmbedScenePreview.tsx index 96cd04e26..40c68c1e7 100644 --- a/frontend/src/embed/EmbedScenePreview.tsx +++ b/frontend/src/embed/EmbedScenePreview.tsx @@ -172,9 +172,15 @@ export function EmbedScenePreview({ frameId, sceneId }: { frameId: number; scene 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)) + // The runtime omits unset values (notably empty strings), but they are still + // public scene state and appear in the Edit dialog. Include every currently + // visible public field here, falling back to its configured default, so the + // summary and editor show the same state shape. + const publicEntries = visiblePublicStateFields(publicFields, previewState).map( + (field) => [field.name, previewState[field.name] ?? field.value] as [string, any] + ) + const privateEntries = Object.entries(previewState).filter(([key]) => !publicFieldNames.has(key)) + const stateEntries = [...publicEntries, ...privateEntries] const visibleStateEntries = [...(showPublicState ? publicEntries : []), ...(showPrivateState ? privateEntries : [])] const openEditState = (): void => { diff --git a/frontend/src/scenes/frame/panels/Scenes/LivePreviewModal.tsx b/frontend/src/scenes/frame/panels/Scenes/LivePreviewModal.tsx index bfb2b3197..79cd5deaf 100644 --- a/frontend/src/scenes/frame/panels/Scenes/LivePreviewModal.tsx +++ b/frontend/src/scenes/frame/panels/Scenes/LivePreviewModal.tsx @@ -180,9 +180,15 @@ export function LivePreviewModal({ frameId }: { frameId: number }): JSX.Element 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)) + // The runtime omits unset values (notably empty strings), but they are still + // public scene state and appear in the Edit dialog. Include every currently + // visible public field here, falling back to its configured default, so the + // summary and editor show the same state shape. + const publicEntries = visiblePublicStateFields(publicFields, previewState).map( + (field) => [field.name, previewState[field.name] ?? field.value] as [string, any] + ) + const privateEntries = Object.entries(previewState).filter(([key]) => !publicFieldNames.has(key)) + const stateEntries = [...publicEntries, ...privateEntries] const visibleStateEntries = [...(showPublicState ? publicEntries : []), ...(showPrivateState ? privateEntries : [])] const openEditState = (): void => { diff --git a/repo/scenes/samples/Bird field journal/image.jpg b/repo/scenes/samples/Bird field journal/image.jpg new file mode 100644 index 000000000..2a5892654 Binary files /dev/null and b/repo/scenes/samples/Bird field journal/image.jpg differ diff --git a/repo/scenes/samples/Bird field journal/scenes.json b/repo/scenes/samples/Bird field journal/scenes.json new file mode 100644 index 000000000..7a29f2dc2 --- /dev/null +++ b/repo/scenes/samples/Bird field journal/scenes.json @@ -0,0 +1,412 @@ +[ + { + "apps": { + "birdJournal": { + "category": "data", + "description": "Polls iNaturalist for birds recently spotted near you. Every new species gets a field-journal plate drawn by OpenAI from licensed reference photos, verified for accuracy, and saved to the asset collection. Cycles through the collection on each render.", + "fields": [ + { + "hint": "Center of the observation circle. Leave empty to pause the journal.", + "label": "Latitude", + "name": "latitude", + "placeholder": "50.85", + "type": "string", + "value": "" + }, + { + "label": "Longitude", + "name": "longitude", + "placeholder": "4.35", + "type": "string", + "value": "" + }, + { + "label": "Radius (km)", + "name": "radiusKm", + "type": "float", + "value": "25" + }, + { + "hint": "Only sightings observed within this many days count.", + "label": "Time window (days)", + "name": "daysWindow", + "type": "integer", + "value": "7" + }, + { + "hint": "How often to ask iNaturalist for new sightings.", + "label": "Poll every (minutes)", + "name": "pollMinutes", + "type": "integer", + "value": "60" + }, + { + "hint": "3 = birds (Aves). Change for moths, mushrooms, ...", + "label": "iNaturalist taxon ID", + "name": "taxonId", + "type": "integer", + "value": "3" + }, + { + "hint": "research = community-verified IDs only", + "label": "Observation quality", + "name": "qualityGrade", + "options": [ + "research", + "any" + ], + "type": "select", + "value": "research" + }, + { + "label": "Plate style", + "name": "style", + "type": "text", + "value": "A 19th-century naturalist's field-journal plate on lightly aged cream paper: fine ink linework with soft watercolor washes. The bird perches on a small branch or stands in typical habitat, anatomically accurate. Around it, small pencil detail studies (head, wing or feather) and short handwritten-style margin notes. Neatly hand-lettered at the bottom: the common name in larger letters and the scientific name in italics beneath it." + }, + { + "hint": "Responses API model that drives image generation and the accuracy check", + "label": "OpenAI model", + "name": "model", + "type": "string", + "value": "gpt-5.1" + }, + { + "label": "Plate quality", + "name": "imageQuality", + "options": [ + "low", + "medium", + "high" + ], + "type": "select", + "value": "medium" + }, + { + "hint": "Have the model double-check each plate against a reference photo before adding it", + "label": "Verify plates", + "name": "verifyPlates", + "type": "boolean", + "value": "true" + }, + { + "hint": "Least recently seen species (and their plates) are pruned beyond this", + "label": "Max species kept", + "name": "maxSpecies", + "type": "integer", + "value": "24" + }, + { + "label": "Max attempts per species", + "name": "maxAttempts", + "type": "integer", + "value": "3" + }, + { + "hint": "Plates are saved under this folder in the frame's assets directory", + "label": "Asset folder", + "name": "assetFolder", + "type": "string", + "value": "birdJournal" + }, + { + "hint": "Scene state key holding the species log; persist it to disk in scene fields", + "label": "Journal state key", + "name": "journalStateKey", + "type": "string", + "value": "birdJournal" + }, + { + "hint": "Scene state key that receives the caption / status line for a text overlay", + "label": "Caption state key", + "name": "captionStateKey", + "type": "string", + "value": "birdCaption" + }, + { + "label": "iNaturalist API host", + "name": "inatHost", + "type": "string", + "value": "https://api.inaturalist.org" + }, + { + "label": "OpenAI API host", + "name": "openaiHost", + "type": "string", + "value": "https://api.openai.com" + } + ], + "name": "Bird field journal (JS)", + "output": [ + { + "name": "image", + "type": "image" + } + ], + "settings": [ + "openAI" + ], + "sources": { + "app.ts": "// Bird field journal: iNaturalist sightings -> OpenAI field-journal plates -> a\n// cycling collection on the frame.\n//\n// Each render:\n// 1. Poll iNaturalist (rate-limited by pollMinutes) for birds spotted within\n// radiusKm of the configured spot in the last daysWindow days, and log every\n// species with up to 3 licensed reference photos.\n// 2. If a logged species has no plate yet, draw one: the reference photos and\n// the configured style go to the OpenAI Responses API image_generation tool,\n// and the model then double-checks the plate against a reference photo.\n// Verified plates are saved as PNG assets. One attempt per render.\n// 3. Show the next plate in the collection (oldest discovery first) and write a\n// caption line to scene state for a text overlay.\n//\n// The species log lives in scene state under journalStateKey; declare that key\n// as a disk-persisted scene field so the collection survives restarts.\n\nconst PAPER_COLOR = \"#f5f1e6\"\nconst REFERENCE_PHOTOS_PER_SPECIES = 3\nconst REFERENCE_PHOTOS_PER_PLATE = 2\n\nfunction readJournal(app, key) {\n const stored = app.state[key]\n if (stored && typeof stored === \"object\" && stored.species) {\n return stored\n }\n return { species: {}, cursor: 0, lastPollAt: 0 }\n}\n\nfunction slugify(name) {\n return String(name).toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-+|-+$/g, \"\")\n}\n\nfunction isoDate(millis) {\n return new Date(millis).toISOString().slice(0, 10)\n}\n\nfunction plateSize(app, context) {\n let width = context.imageWidth || 0\n let height = context.imageHeight || 0\n if (!width || !height) {\n width = app.frame.width\n height = app.frame.height\n if (app.frame.rotate === 90 || app.frame.rotate === 270) {\n const swap = width\n width = height\n height = swap\n }\n }\n return height >= width ? \"1024x1536\" : \"1536x1024\"\n}\n\nfunction pollSightings(app, journal, lat, lng, now) {\n const cfg = app.config\n const days = Math.max(1, Number(cfg.daysWindow) || 7)\n const d1 = isoDate(now - days * 86400000)\n let url = `${cfg.inatHost}/v2/observations` +\n `?taxon_id=${Number(cfg.taxonId) || 3}` +\n `&lat=${lat}&lng=${lng}&radius=${Number(cfg.radiusKm) || 25}` +\n `&d1=${d1}&photos=true` +\n `&photo_license=cc0,cc-by,cc-by-nc,cc-by-sa,cc-by-nc-sa` +\n `&per_page=200&order_by=observed_on&order=desc` +\n `&fields=(observed_on:!t,taxon:(id:!t,name:!t,preferred_common_name:!t),` +\n `photos:(url:!t,license_code:!t,attribution:!t))`\n if (cfg.qualityGrade === \"research\") {\n url += \"&quality_grade=research\"\n }\n\n const res = frameos.httpRequest(url, { timeoutMs: 30000 })\n const ok = res.status === 200\n if (!ok) {\n return { error: res.error || `HTTP ${res.status}` }\n }\n let parsed = null\n try {\n parsed = JSON.parse(res.body)\n } catch (err) {\n return { error: \"unparseable response\" }\n }\n const results = (parsed && parsed.results) || []\n\n const seenCounts = {}\n let newSpecies = 0\n for (const obs of results) {\n const taxon = obs.taxon\n if (!taxon || !taxon.id || !taxon.name) {\n continue\n }\n const photos = (obs.photos || []).filter((p) => p && p.url && p.license_code)\n if (!photos.length) {\n continue\n }\n const key = String(taxon.id)\n seenCounts[key] = (seenCounts[key] || 0) + 1\n let entry = journal.species[key]\n if (!entry) {\n entry = {\n taxonId: taxon.id,\n name: taxon.name,\n commonName: taxon.preferred_common_name || taxon.name,\n firstSeen: obs.observed_on || isoDate(now),\n sightings: 0,\n attempts: 0,\n photos: [],\n }\n journal.species[key] = entry\n newSpecies += 1\n }\n if (obs.observed_on && (!entry.lastSeen || obs.observed_on > entry.lastSeen)) {\n entry.lastSeen = obs.observed_on\n }\n for (const photo of photos) {\n if (entry.photos.length >= REFERENCE_PHOTOS_PER_SPECIES) {\n break\n }\n const photoUrl = photo.url.replace(\"/square.\", \"/medium.\")\n if (!entry.photos.some((p) => p.url === photoUrl)) {\n entry.photos.push({ url: photoUrl, attribution: photo.attribution || \"\" })\n }\n }\n }\n // Sightings reflect the current window, so counts age out with the window.\n for (const key of Object.keys(seenCounts)) {\n journal.species[key].sightings = seenCounts[key]\n }\n return { newSpecies }\n}\n\nfunction openaiResponses(app, body) {\n const apiKey = frameos.getSetting(\"openAI\", \"apiKey\") || \"\"\n const res = frameos.httpRequest(`${app.config.openaiHost}/v1/responses`, {\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(body),\n timeoutMs: 300000,\n })\n const ok = res.status === 200\n if (!ok) {\n let message = res.error || `HTTP ${res.status}`\n try {\n message = JSON.parse(res.body).error.message || message\n } catch (err) {}\n return { error: message }\n }\n try {\n return { json: JSON.parse(res.body) }\n } catch (err) {\n return { error: \"unparseable response\" }\n }\n}\n\nfunction extractGeneratedImage(json) {\n for (const item of (json && json.output) || []) {\n if (item.type === \"image_generation_call\" && item.result) {\n return item.result\n }\n }\n return null\n}\n\nfunction extractOutputText(json) {\n let text = \"\"\n for (const item of (json && json.output) || []) {\n if (item.type === \"message\") {\n for (const part of item.content || []) {\n if (part.text) {\n text += part.text\n }\n }\n }\n }\n return text\n}\n\nfunction fetchReferenceDataUrls(app, entry) {\n const refs = []\n for (const photo of entry.photos || []) {\n if (refs.length >= REFERENCE_PHOTOS_PER_PLATE) {\n break\n }\n const res = frameos.httpRequest(photo.url, { base64: true, timeoutMs: 30000 })\n if (res.status === 200 && res.bodyBase64) {\n refs.push(`data:image/jpeg;base64,${res.bodyBase64}`)\n }\n }\n return refs\n}\n\nfunction generatePlate(app, entry, size, refs) {\n const cfg = app.config\n const prompt = `${cfg.style}\\n\\n` +\n `Species: ${entry.commonName} (${entry.name}).\\n` +\n `Use the attached reference photo(s) only to get the plumage colours, bill shape, ` +\n `eye colour and proportions right; compose an original plate, do not copy a photo.\\n` +\n `Letter the labels exactly as \"${entry.commonName}\" and \"${entry.name}\".`\n const content = [{ type: \"input_text\", text: prompt }]\n for (const ref of refs) {\n content.push({ type: \"input_image\", image_url: ref })\n }\n const result = openaiResponses(app, {\n model: cfg.model,\n input: [{ role: \"user\", content }],\n tools: [{\n type: \"image_generation\",\n size,\n quality: cfg.imageQuality || \"medium\",\n output_format: \"png\",\n }],\n tool_choice: { type: \"image_generation\" },\n })\n if (result.error) {\n return { error: result.error }\n }\n const image = extractGeneratedImage(result.json)\n if (!image) {\n return { error: \"no image in response\" }\n }\n return { image }\n}\n\nfunction verifyPlate(app, entry, plateBase64, refs) {\n const prompt = `You are checking a generated field-journal illustration before it joins a birding collection.\\n` +\n `Species: ${entry.commonName} (${entry.name}).\\n` +\n `The first image is the generated plate; any further images are reference photos of the species.\\n` +\n `Reply with strict JSON only: {\"ok\": true, \"reason\": \"...\"} or {\"ok\": false, \"reason\": \"...\"}.\\n` +\n `Say ok=true only if the illustrated bird could reasonably be identified as this species ` +\n `(plumage colours, bill shape, overall proportions match the references) and all lettering ` +\n `on the plate is legible and spells the names correctly.`\n const content = [\n { type: \"input_text\", text: prompt },\n { type: \"input_image\", image_url: `data:image/png;base64,${plateBase64}` },\n ]\n for (const ref of refs) {\n content.push({ type: \"input_image\", image_url: ref })\n }\n const result = openaiResponses(app, {\n model: app.config.model,\n input: [{ role: \"user\", content }],\n })\n if (result.error) {\n return { ok: false, reason: result.error }\n }\n const text = extractOutputText(result.json)\n const match = text.match(/\\{[\\s\\S]*\\}/)\n if (!match) {\n return { ok: false, reason: \"unparseable verdict\" }\n }\n try {\n const verdict = JSON.parse(match[0])\n return { ok: verdict.ok === true, reason: verdict.reason || \"\" }\n } catch (err) {\n return { ok: false, reason: \"unparseable verdict\" }\n }\n}\n\nfunction drawPendingPlate(app, context, journal, entry, now) {\n const cfg = app.config\n const refs = fetchReferenceDataUrls(app, entry)\n if (!refs.length) {\n entry.attempts += 1\n entry.lastError = \"no reference photos downloadable\"\n return { error: `${entry.commonName}: ${entry.lastError}` }\n }\n\n frameos.log(`Drawing plate for ${entry.commonName} (${entry.name}), attempt ${entry.attempts + 1}`)\n const generated = generatePlate(app, entry, plateSize(app, context), refs)\n if (generated.error) {\n entry.attempts += 1\n entry.lastError = generated.error\n return { error: `${entry.commonName}: ${generated.error}` }\n }\n\n if (cfg.verifyPlates) {\n const verdict = verifyPlate(app, entry, generated.image, refs.slice(0, 1))\n if (!verdict.ok) {\n entry.attempts += 1\n entry.lastError = `verification failed: ${verdict.reason}`\n frameos.log(`Plate for ${entry.commonName} rejected: ${verdict.reason}`)\n return { error: `${entry.commonName}: ${entry.lastError}` }\n }\n }\n\n const folder = cfg.assetFolder || \"birdJournal\"\n const path = `${folder}/${entry.taxonId}-${slugify(entry.name)}.png`\n if (!frameos.writeAsset(path, generated.image)) {\n entry.attempts += 1\n entry.lastError = \"could not save plate asset\"\n return { error: `${entry.commonName}: ${entry.lastError}` }\n }\n entry.plate = path\n entry.lastError = \"\"\n entry.plateDrawnOn = isoDate(now)\n frameos.log(`Added ${entry.commonName} to the journal: ${path}`)\n return {}\n}\n\nfunction pruneCollection(app, journal) {\n const maxSpecies = Math.max(1, Number(app.config.maxSpecies) || 24)\n const entries = Object.values(journal.species)\n if (entries.length <= maxSpecies) {\n return false\n }\n entries.sort((a, b) => String(a.lastSeen || a.firstSeen || \"\").localeCompare(String(b.lastSeen || b.firstSeen || \"\")))\n let toRemove = entries.length - maxSpecies\n for (const entry of entries) {\n if (toRemove <= 0) {\n break\n }\n if (entry.plate) {\n frameos.deleteAsset(entry.plate)\n }\n delete journal.species[String(entry.taxonId)]\n toRemove -= 1\n }\n return true\n}\n\nexport function get(app, context) {\n const cfg = app.config\n const now = Date.now()\n const journalKey = cfg.journalStateKey || \"birdJournal\"\n const captionKey = cfg.captionStateKey || \"birdCaption\"\n const journal = readJournal(app, journalKey)\n const notes = []\n\n const lat = parseFloat(cfg.latitude)\n const lng = parseFloat(cfg.longitude)\n const hasCoords = isFinite(lat) && isFinite(lng)\n\n if (hasCoords) {\n const pollMillis = Math.max(1, Number(cfg.pollMinutes) || 60) * 60000\n if (now - (journal.lastPollAt || 0) >= pollMillis) {\n const poll = pollSightings(app, journal, lat, lng, now)\n if (poll.error) {\n notes.push(`iNaturalist: ${poll.error}`)\n frameos.error(`iNaturalist poll failed: ${poll.error}`)\n } else {\n journal.lastPollAt = now\n if (poll.newSpecies) {\n frameos.log(`Spotted ${poll.newSpecies} new species`)\n }\n }\n frameos.setState(journalKey, journal)\n }\n } else {\n notes.push(\"set latitude and longitude to begin\")\n }\n\n const maxAttempts = Math.max(1, Number(cfg.maxAttempts) || 3)\n const pending = Object.values(journal.species)\n .filter((e) => !e.plate && e.attempts < maxAttempts)\n .sort((a, b) => String(a.firstSeen || \"\").localeCompare(String(b.firstSeen || \"\")))\n const hasApiKey = (frameos.getSetting(\"openAI\", \"apiKey\") || \"\").length > 0\n\n if (pending.length && hasApiKey) {\n const outcome = drawPendingPlate(app, context, journal, pending[0], now)\n if (outcome.error) {\n notes.push(outcome.error)\n }\n frameos.setState(journalKey, journal)\n if (pending.length > 1) {\n frameos.setNextSleep(20)\n }\n } else if (pending.length) {\n notes.push(`${pending.length} plate${pending.length === 1 ? \"\" : \"s\"} waiting for an OpenAI API key`)\n }\n\n if (pruneCollection(app, journal)) {\n frameos.setState(journalKey, journal)\n }\n\n const plates = Object.values(journal.species)\n .filter((e) => e.plate)\n .sort((a, b) => String(a.firstSeen || \"\").localeCompare(String(b.firstSeen || \"\")))\n\n if (plates.length) {\n const index = (journal.cursor || 0) % plates.length\n journal.cursor = index + 1\n frameos.setState(journalKey, journal)\n const entry = plates[index]\n const image = frameos.loadAssetImage(entry.plate)\n if (image) {\n const days = Math.max(1, Number(cfg.daysWindow) || 7)\n let caption = entry.commonName\n if (entry.name && entry.name !== entry.commonName) {\n caption += ` \u2014 ${entry.name}`\n }\n const sightings = entry.sightings || 1\n caption += ` \u00b7 ${sightings} sighting${sightings === 1 ? \"\" : \"s\"} in ${days} day${days === 1 ? \"\" : \"s\"}`\n caption += ` \u00b7 ${index + 1}/${plates.length}`\n if (notes.length) {\n caption += `\\n${notes.join(\" \u00b7 \")}`\n }\n frameos.setState(captionKey, caption)\n return image\n }\n notes.push(`missing plate file for ${entry.commonName}`)\n }\n\n const speciesCount = Object.keys(journal.species).length\n let status = \"Bird field journal\"\n if (speciesCount) {\n status += ` \u00b7 ${speciesCount} species spotted, drawing plates`\n } else if (hasCoords) {\n status += ` \u00b7 waiting for sightings near ${lat.toFixed(2)}, ${lng.toFixed(2)}`\n }\n if (notes.length) {\n status += `\\n${notes.join(\" \u00b7 \")}`\n }\n frameos.setState(captionKey, status)\n return frameos.image({ color: PAPER_COLOR })\n}\n", + "config.json": "{\n \"name\": \"Bird field journal (JS)\",\n \"category\": \"data\",\n \"description\": \"Polls iNaturalist for birds recently spotted near you. Every new species gets a field-journal plate drawn by OpenAI from licensed reference photos, verified for accuracy, and saved to the asset collection. Cycles through the collection on each render.\",\n \"version\": \"1.0.0\",\n \"settings\": [\"openAI\"],\n \"fields\": [\n {\n \"name\": \"latitude\",\n \"label\": \"Latitude\",\n \"type\": \"string\",\n \"value\": \"\",\n \"placeholder\": \"50.85\",\n \"hint\": \"Center of the observation circle. Leave empty to pause the journal.\"\n },\n {\n \"name\": \"longitude\",\n \"label\": \"Longitude\",\n \"type\": \"string\",\n \"value\": \"\",\n \"placeholder\": \"4.35\"\n },\n {\n \"name\": \"radiusKm\",\n \"label\": \"Radius (km)\",\n \"type\": \"float\",\n \"value\": \"25\"\n },\n {\n \"name\": \"daysWindow\",\n \"label\": \"Time window (days)\",\n \"type\": \"integer\",\n \"value\": \"7\",\n \"hint\": \"Only sightings observed within this many days count.\"\n },\n {\n \"name\": \"pollMinutes\",\n \"label\": \"Poll every (minutes)\",\n \"type\": \"integer\",\n \"value\": \"60\",\n \"hint\": \"How often to ask iNaturalist for new sightings.\"\n },\n {\n \"name\": \"taxonId\",\n \"label\": \"iNaturalist taxon ID\",\n \"type\": \"integer\",\n \"value\": \"3\",\n \"hint\": \"3 = birds (Aves). Change for moths, mushrooms, ...\"\n },\n {\n \"name\": \"qualityGrade\",\n \"label\": \"Observation quality\",\n \"type\": \"select\",\n \"options\": [\"research\", \"any\"],\n \"value\": \"research\",\n \"hint\": \"research = community-verified IDs only\"\n },\n {\n \"name\": \"style\",\n \"label\": \"Plate style\",\n \"type\": \"text\",\n \"value\": \"A 19th-century naturalist's field-journal plate on lightly aged cream paper: fine ink linework with soft watercolor washes. The bird perches on a small branch or stands in typical habitat, anatomically accurate. Around it, small pencil detail studies (head, wing or feather) and short handwritten-style margin notes. Neatly hand-lettered at the bottom: the common name in larger letters and the scientific name in italics beneath it.\"\n },\n {\n \"name\": \"model\",\n \"label\": \"OpenAI model\",\n \"type\": \"string\",\n \"value\": \"gpt-5.1\",\n \"hint\": \"Responses API model that drives image generation and the accuracy check\"\n },\n {\n \"name\": \"imageQuality\",\n \"label\": \"Plate quality\",\n \"type\": \"select\",\n \"options\": [\"low\", \"medium\", \"high\"],\n \"value\": \"medium\"\n },\n {\n \"name\": \"verifyPlates\",\n \"label\": \"Verify plates\",\n \"type\": \"boolean\",\n \"value\": \"true\",\n \"hint\": \"Have the model double-check each plate against a reference photo before adding it\"\n },\n {\n \"name\": \"maxSpecies\",\n \"label\": \"Max species kept\",\n \"type\": \"integer\",\n \"value\": \"24\",\n \"hint\": \"Least recently seen species (and their plates) are pruned beyond this\"\n },\n {\n \"name\": \"maxAttempts\",\n \"label\": \"Max attempts per species\",\n \"type\": \"integer\",\n \"value\": \"3\"\n },\n {\n \"name\": \"assetFolder\",\n \"label\": \"Asset folder\",\n \"type\": \"string\",\n \"value\": \"birdJournal\",\n \"hint\": \"Plates are saved under this folder in the frame's assets directory\"\n },\n {\n \"name\": \"journalStateKey\",\n \"label\": \"Journal state key\",\n \"type\": \"string\",\n \"value\": \"birdJournal\",\n \"hint\": \"Scene state key holding the species log; persist it to disk in scene fields\"\n },\n {\n \"name\": \"captionStateKey\",\n \"label\": \"Caption state key\",\n \"type\": \"string\",\n \"value\": \"birdCaption\",\n \"hint\": \"Scene state key that receives the caption / status line for a text overlay\"\n },\n {\n \"name\": \"inatHost\",\n \"label\": \"iNaturalist API host\",\n \"type\": \"string\",\n \"value\": \"https://api.inaturalist.org\"\n },\n {\n \"name\": \"openaiHost\",\n \"label\": \"OpenAI API host\",\n \"type\": \"string\",\n \"value\": \"https://api.openai.com\"\n }\n ],\n \"output\": [\n {\n \"name\": \"image\",\n \"type\": \"image\"\n }\n ]\n}\n" + }, + "version": "1.0.0" + } + }, + "edges": [ + { + "id": "edge-5f0a6d1c-next-a1b2c3d4-prev", + "source": "5f0a6d1c-8f0e-4d5b-9a3e-1c2b3d4e5f60", + "sourceHandle": "next", + "target": "a1b2c3d4-2222-4aaa-8bbb-000000000002", + "targetHandle": "prev", + "type": "appNodeEdge" + }, + { + "id": "edge-a1b2c3d4-next-a1b2c3d4-prev", + "source": "a1b2c3d4-2222-4aaa-8bbb-000000000002", + "sourceHandle": "next", + "target": "a1b2c3d4-3333-4aaa-8bbb-000000000003", + "targetHandle": "prev", + "type": "appNodeEdge" + }, + { + "id": "edge-a1b2c3d4-fieldOutput-a1b2c3d4-fieldInput_image", + "source": "a1b2c3d4-1111-4aaa-8bbb-000000000001", + "sourceHandle": "fieldOutput", + "target": "a1b2c3d4-2222-4aaa-8bbb-000000000002", + "targetHandle": "fieldInput/image", + "type": "codeNodeEdge" + }, + { + "id": "edge-a1b2c3d4-fieldOutput-a1b2c3d4-fieldInput_latitude", + "source": "a1b2c3d4-4444-4aaa-8bbb-000000000004", + "sourceHandle": "fieldOutput", + "target": "a1b2c3d4-1111-4aaa-8bbb-000000000001", + "targetHandle": "fieldInput/latitude", + "type": "codeNodeEdge" + }, + { + "id": "edge-a1b2c3d4-fieldOutput-a1b2c3d4-fieldInput_longitude", + "source": "a1b2c3d4-5555-4aaa-8bbb-000000000005", + "sourceHandle": "fieldOutput", + "target": "a1b2c3d4-1111-4aaa-8bbb-000000000001", + "targetHandle": "fieldInput/longitude", + "type": "codeNodeEdge" + }, + { + "id": "edge-a1b2c3d4-fieldOutput-a1b2c3d4-fieldInput_radiusKm", + "source": "a1b2c3d4-6666-4aaa-8bbb-000000000006", + "sourceHandle": "fieldOutput", + "target": "a1b2c3d4-1111-4aaa-8bbb-000000000001", + "targetHandle": "fieldInput/radiusKm", + "type": "codeNodeEdge" + }, + { + "id": "edge-a1b2c3d4-fieldOutput-a1b2c3d4-fieldInput_daysWindow", + "source": "a1b2c3d4-7777-4aaa-8bbb-000000000007", + "sourceHandle": "fieldOutput", + "target": "a1b2c3d4-1111-4aaa-8bbb-000000000001", + "targetHandle": "fieldInput/daysWindow", + "type": "codeNodeEdge" + }, + { + "id": "edge-a1b2c3d4-fieldOutput-a1b2c3d4-fieldInput_text", + "source": "a1b2c3d4-8888-4aaa-8bbb-000000000008", + "sourceHandle": "fieldOutput", + "target": "a1b2c3d4-3333-4aaa-8bbb-000000000003", + "targetHandle": "fieldInput/text", + "type": "codeNodeEdge" + } + ], + "fields": [ + { + "access": "public", + "label": "Latitude", + "name": "latitude", + "persist": "disk", + "placeholder": "50.85", + "type": "string", + "value": "" + }, + { + "access": "public", + "label": "Longitude", + "name": "longitude", + "persist": "disk", + "placeholder": "4.35", + "type": "string", + "value": "" + }, + { + "access": "public", + "label": "Radius (km)", + "name": "radiusKm", + "persist": "disk", + "type": "float", + "value": "25" + }, + { + "access": "public", + "label": "Time window (days)", + "name": "daysWindow", + "persist": "disk", + "type": "integer", + "value": "7" + }, + { + "access": "private", + "label": "Species log", + "name": "birdJournal", + "persist": "disk", + "type": "json" + }, + { + "access": "private", + "label": "Caption", + "name": "birdCaption", + "persist": "memory", + "type": "string" + } + ], + "id": "9b7f5b0e-6c3a-4d9e-8a64-2f1c7f0b1d21", + "name": "Bird field journal", + "nodes": [ + { + "data": { + "keyword": "render" + }, + "height": 161, + "id": "5f0a6d1c-8f0e-4d5b-9a3e-1c2b3d4e5f60", + "position": { + "x": -260, + "y": 120 + }, + "type": "event", + "width": 212 + }, + { + "data": { + "config": {}, + "keyword": "birdJournal" + }, + "height": 420, + "id": "a1b2c3d4-1111-4aaa-8bbb-000000000001", + "position": { + "x": 40, + "y": 420 + }, + "type": "app", + "width": 325 + }, + { + "data": { + "config": { + "placement": "cover" + }, + "keyword": "render/image" + }, + "height": 180, + "id": "a1b2c3d4-2222-4aaa-8bbb-000000000002", + "position": { + "x": 60, + "y": 120 + }, + "type": "app", + "width": 325 + }, + { + "data": { + "config": { + "borderColor": "#f5f1e6", + "borderWidth": 2, + "fontColor": "#1c1a15", + "fontSize": "20", + "padding": "14", + "position": "center", + "vAlign": "top" + }, + "keyword": "render/text" + }, + "height": 320, + "id": "a1b2c3d4-3333-4aaa-8bbb-000000000003", + "position": { + "x": 480, + "y": 120 + }, + "type": "app", + "width": 325 + }, + { + "data": { + "keyword": "latitude" + }, + "height": 60, + "id": "a1b2c3d4-4444-4aaa-8bbb-000000000004", + "position": { + "x": -260, + "y": 440 + }, + "type": "state", + "width": 172 + }, + { + "data": { + "keyword": "longitude" + }, + "height": 60, + "id": "a1b2c3d4-5555-4aaa-8bbb-000000000005", + "position": { + "x": -260, + "y": 520 + }, + "type": "state", + "width": 172 + }, + { + "data": { + "keyword": "radiusKm" + }, + "height": 60, + "id": "a1b2c3d4-6666-4aaa-8bbb-000000000006", + "position": { + "x": -260, + "y": 600 + }, + "type": "state", + "width": 172 + }, + { + "data": { + "keyword": "daysWindow" + }, + "height": 60, + "id": "a1b2c3d4-7777-4aaa-8bbb-000000000007", + "position": { + "x": -260, + "y": 680 + }, + "type": "state", + "width": 172 + }, + { + "data": { + "keyword": "birdCaption" + }, + "height": 60, + "id": "a1b2c3d4-8888-4aaa-8bbb-000000000008", + "position": { + "x": 480, + "y": 500 + }, + "type": "state", + "width": 172 + } + ], + "settings": { + "backgroundColor": "#f5f1e6", + "execution": "interpreted", + "refreshInterval": 300 + } + } +] diff --git a/repo/scenes/samples/Bird field journal/template.json b/repo/scenes/samples/Bird field journal/template.json new file mode 100644 index 000000000..6c9a72a6e --- /dev/null +++ b/repo/scenes/samples/Bird field journal/template.json @@ -0,0 +1,10 @@ +{ + "name": "Bird field journal", + "description": "Birds recently spotted near you via live iNaturalist data. Every new species gets a field-journal plate drawn by OpenAI from licensed reference photos, verified for accuracy, then added to a collection that cycles on the frame. Set your latitude/longitude and an OpenAI API key under global settings.", + "frameosVersion": "2026.7.5", + "config": null, + "image": "./image.jpg", + "imageWidth": 480, + "imageHeight": 800, + "scenes": "./scenes.json" +}