Phase 3 of the OSS / pro split — frontend side. Phase 2 already moved
the auth/DB stack out of the OSS backend; this commit does the same
for the React app. After this, the OSS image is editor + simulator
+ landing + docs only.
What moved to the private overlay (pro/frontend/src/pro/):
pages/{Login,Register,ForgotPassword,ResetPassword}Page.tsx
pages/{Admin,UserProfile,Project,ProjectById}Page.tsx
components/admin/{AdminBoardsTab,AdminDashboardTab,UserActivityModal}.tsx
components/layout/{SaveProjectModal,LoginPromptModal}.tsx
services/{authService,adminService}.ts
store/useAuthStore.ts
hooks/autoSaveImpl.ts
New seams added so OSS components stay decoupled:
* lib/proRoutes.ts — registerProRoutes()/useProRoutes() via
useSyncExternalStore. mountPro() injects the moved pages at runtime;
App.tsx subscribes to the registry, so registration after the
initial render re-renders without a Not-Found flash.
* lib/proSession.ts — registerSessionCheck()/triggerSessionCheck().
App.tsx fires this on mount instead of useAuthStore.checkSession();
pure OSS no-ops.
* lib/proSaveAction.ts — installSaveActionImpl()/triggerSaveAction().
EditorPage's Save button dispatches through this; the overlay
decides whether to show SaveProjectModal or LoginPromptModal based
on auth state. In OSS without an overlay it's a no-op today; in
Phase 4 of the split it becomes the .vlx Export entry point.
OSS-side rewrites:
* App.tsx drops the 8 page imports + 8 route entries; uses
triggerSessionCheck() instead of useAuthStore directly.
* AppHeader.tsx drops the user/login/register block entirely. The
header-auth slot (introduced in Phase 1) now stays empty in OSS
and gets filled by the overlay's portal mount.
* EditorPage.tsx drops useAuthStore + SaveProjectModal +
LoginPromptModal imports. The Save handler is now triggerSaveAction().
* LandingPage.tsx drops the dead UserMenu component (defined but
never rendered) + its useAuthStore imports.
* main.tsx drops the side-effect import of hooks/autoSaveImpl — the
impl lives in pro now and self-registers via mountPro().
Build config:
* vite.config.ts adds @velxio alias → src/. Lets the overlay import
upstream modules (lib/proRoutes etc.) by stable name regardless of
whether it's symlinked (local dev) or COPYed (Docker).
* preserveSymlinks now gated on VITE_PRO_BUILD only (not on serve
mode). Needed so Rollup keeps the overlay logically inside src/pro/
during local junction-based builds.
Build verification:
* OSS-only: 20-ish routes, no /login, /admin, /:username — 285 SEO
pages prerendered. Bundle drops ~80-120 KB.
* OSS + overlay: full 38 routes (30 upstream + 8 from registerProRoutes),
HeaderAuth dropdown injected via slot, save action wired to the
overlay's modal flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First phase of the OSS / pro split. Goal: open the seams so the auth/DB/admin
stack can move into the private overlay (Phase 2-3) without the routes that
stay in OSS (compile, libraries, simulation, iot_gateway) having to know.
Backend
-------
* New app/core/hooks.py — registry for record_compile, get_current_user_id,
and lifespan startup tasks. Each hook is a no-op by default; overlays
call register_* in register_pro(app) to plug in a real implementation.
* compile.py now imports only from app.core.hooks. Drops the direct deps on
app.core.dependencies, app.database.session, app.models.user, and
app.services.metrics. Route signatures use `Depends(get_current_user_id)`
instead of `Depends(get_current_user)`; the metric helper passes user_id
through rather than a User instance.
* compile_chip.py drops the unused _current_user Depends entirely.
* main.py wraps the auth/DB stack import in try/except. When it succeeds
(today's behavior on velxio.dev), an adapter bridges record_compile and
get_current_user_id to the existing app.services.metrics + dependencies,
and the create_all + ALTER TABLE migration block runs via a registered
lifespan_startup hook. When it fails (the post-Phase-2 OSS image), main
logs "running stateless" and skips registering anything — the routes
still load and behave as no-ops for metrics + always-anonymous for auth.
Frontend
--------
* useAutoSaveProject becomes a skeleton: one useState + one useEffect that
delegates to an installed AutoSaveImpl. installAutoSaveImpl() replaces
the impl without changing hook count, so React's rules-of-hooks stay
satisfied even after the impl moves out of OSS.
* New hooks/autoSaveImpl.ts holds the original logic (debouncing, dirty
detection, owner eligibility, fetch keepalive on unload), refactored to
emit() instead of useState. It self-registers at module load; main.tsx
imports it for the side effect.
* AppHeader wraps the entire user-vs-login UI in a data-velxio-slot
="header-auth" boundary. Today the OSS UI still renders inside the slot
— the overlay can portal-inject additional items now, and in Phase 3
the slot becomes the sole owner of header auth UX.
Behavior is identical on velxio.dev (pro overlay imports everything
successfully, every adapter wires up). The change is purely structural:
deleting the auth/DB modules tomorrow no longer crashes OSS at import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without an ownership check, viewing someone else's project (admin
inspection, browsing public projects) caused the auto-save hook to
PUT the project on every store change. The backend correctly rejects
non-owner updates with 403, but the frontend surfaced these as
"save fail" to the user — misleading and noisy in logs.
The hook now stays idle unless the authenticated user matches
currentProject.ownerUsername. Manual saves through SaveProjectModal
are unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User goal: ESP32-CAM live preview that works with any webcam,
regardless of resolution, brand, or scene complexity. The previous
fixed-quality 0.28 was fragile (intermittent decode errors on
moving/textured scenes) and capped visual quality unnecessarily.
Two-layer fix; either alone is insufficient:
LAYER A — Bounded JPEG encoder (frontend, this repo)
frontend/src/hooks/useWebcamFrames.ts:
encodeBoundedJpeg() walks a quality ladder [0.6, 0.5, ..., 0.1]
until the JPEG fits in MAX_FRAME_BYTES (23 000). If even q=0.1
overshoots — extreme HD/4K scenes — falls back to a 240×180
downscaled canvas at q=0.4. Guarantees every emitted frame fits
the deliverable budget regardless of webcam hardware.
The hook now exposes lastQualityUsed + lastDownscaled so UI can
surface when auto-tuning kicks in.
frontend/src/components/simulator/CameraToggle.tsx:
Tooltip shows "(auto-tuned to q=0.X)" or "(auto-downscaled, q=0.X)"
while streaming so users see what the encoder picked.
LAYER B — Multi-lap descriptor ring walker (qemu-lcgamboa, submodule)
Bumps the QEMU per-frame deliverable cap from 8 KiB to ~32 KiB by
letting the walker reset the descriptor ring up to 4 times per
VSYNC. Submodule pointer bumped to eb8b7a5d.
Combined, the demo now supports:
- Cheap 480p webcams: q=0.6, 5-10 KiB JPEGs, sharp
- Logitech mid-range: q=0.5-0.6, 8-15 KiB JPEGs, sharp
- HD 1080p webcams: q=0.4-0.6, 15-23 KiB JPEGs, sharp
- 4K complex scenes: downscaled, still readable
Documented as bug closure in:
test/test-esp32-cam/autosearch/15_universal_webcam_compat.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported "JPG Decompression Failed! Data format error" hitting
intermittently with quality 0.35. Worker log showed actual JPEG
payloads at 7959-8123 bytes per frame — right at the QEMU emulator's
8192-byte deliverable budget (8 EOFs × 1024 bytes from cam_hal's
default 16-descriptor ring).
The webcam JPEG encoder produces variable-size output: simple uniform
scenes compress to ~6 KiB, complex/textured/moving frames bloat to
~9-10 KiB. Anything over 8192 gets truncated mid-Huffman-scan in the
firmware framebuffer, my walker injects FF D9 at byte 8190 to keep
cam_verify_jpeg_eoi happy, but the upstream jpg2rgb565 actually
parses the structure and chokes on the truncated stream.
Quality 0.28 keeps even the worst-case complex frame comfortably
under 8 KiB. Visual quality is still much better than the 0.25
fallback — fine for a 160×120 preview where the user cares about
"is my face there" not "did the JPEG quantization tables converge".
Real long-term fix would be to bump EOFS_PER_FRAME and lift the 8 KiB
ceiling — but that touches the QEMU walker (DLL rebuild cycle) and
risks breaking the descriptor-ring math. Doing this frontend tweak
first to unblock the demo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the ESP32-CAM + ILI9341 live preview at ~1 frame/min.
Profile: 80×60 preview pushes 9600 SPI bytes per drawRGBBitmap, and
each byte was emitting a full {type:'spi_event'} JSON message over
the worker→backend→WS→frontend pipeline. Per-byte overhead ~150-200µs
in Python (json.dumps + sys.stdout.write+flush dominates) plus
asyncio + WS dispatch. Net: 1.5-2 sec/frame minimum, much worse with
GIL contention.
Fix: buffer MOSI bytes in the worker and emit a single base64-encoded
`spi_batch` message when CS goes HIGH (transaction ended) or the
buffer crosses 4 KiB. ~9600 events/frame collapse to ~3 messages.
backend/app/services/esp32_worker.py:_on_spi_event
- Add _spi_byte_buf bytearray + threading.Lock
- On op==0x00 (byte): append; flush early if buf >= 4096
- On op==0x01 (CS change): flush buffer, then emit the CS event
via the legacy spi_event channel (ePaper / custom chips that
observe CS still get it).
frontend/src/simulation/Esp32Bridge.ts
- New 'spi_batch' message handler decodes b64 and replays each
byte through the existing onSpiByte callback. Parts that
subscribed via simulator.spi.onByte don't notice the protocol
change. The 'spi_event' branch still handles CS changes plus
legacy single-byte payloads for backwards compat.
Now that 38 KB/frame is cheap, restore preview to 160×120 + JPEG
quality 0.35 in the gallery example. Real measured speedup: ~50× on
the QVGA preview demo. Real hardware was never affected — it runs
SPI at 80 MHz and pushes the bitmap in ~4 ms either way.
PSRAM emulation is unrelated to this bottleneck and was left untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ESP32-CAM + ILI9341 example was rendering grey-X "decode failed"
rectangles. Serial showed:
E (53868) esp_jpg_decode: JPG Decompression Failed!
Data format error
Root cause: the QEMU emulation delivers up to 8 KiB of JPEG bytes per
frame (8 EOFs × 1024 = 8192) plus a 2-byte FF D9 EOI injection at the
end of that window. Real webcam frames at quality 0.6 are ~11 KiB —
they get truncated mid-Huffman-scan in the firmware framebuffer.
cam_verify_jpeg_eoi accepts the frame (it found FF D9), but the
upstream jpg2rgb565() actually parses the JPEG and rejects the
truncated structure.
Quality 0.25 produces ~3-5 KiB JPEGs that fit the budget entirely.
The decoder finds the natural EOI well before our injection point,
parses cleanly, and renders to the TFT. Visual quality is fine for
an emulator preview — the user is seeing their webcam, not editing
print-quality photos.
Long-term fix is a smarter QEMU walker that ring-wraps to deliver
bigger JPEGs (>16 KiB possible by reusing descriptors mid-frame),
but that's a separate change in qemu-lcgamboa. This frontend tweak
unblocks the demo without another DLL rebuild cycle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First open-source end-to-end emulation of the AI-Thinker ESP32-CAM
in QEMU, paired with a browser webcam → firmware bridge so users can
develop camera sketches without hardware. Status: esp_camera_init()
returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly
match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by
the upstream driver. Final piece (cam_task accepting frames) is in
progress — descriptor walker fix landed in this commit.
Backend (Python/FastAPI):
- simulation.py: camera_attach/frame/detach WS handlers
- esp32_worker.py: ctypes binding to velxio_push_camera_frame +
feature-detection fallback for older DLLs
- esp32_lib_manager.py: forward camera commands to the worker stdin
- esp-idf-template/main/CMakeLists.txt: esp32-camera headers added
via add_prebuilt_library + REQUIRES driver (resolves i2c_master_*
symbols). LED_BUILTIN=2 fallback for sketches that hardcode it.
Frontend (React/TS):
- EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family)
added to isQemuBoard list — Run button now starts the QEMU bridge
for these boards instead of falling through to the AVR path
- useWebcamFrames.ts: getUserMedia → OffscreenCanvas →
toBlob('image/jpeg') → base64 → WS at ~10 fps
- CameraToggle.tsx: header button with status colors + frame counter
- SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards
- Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa
- useSimulatorStore.ts: diagnostic log on compileBoardProgram
- components-metadata.json: regen including esp32-cam component
Submodule pointer:
- wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on
davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32)
Investigation + tests in test/test-esp32-cam/:
- 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S
spec, build blueprint, blockers resolved, descriptor walker fix)
- 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip,
webcam_demo) + 8 live + WS regression tests
- README with the user-facing flow
.gitignore:
- libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated)
- wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path,
not part of this repo)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The project save/load pipeline only persisted a single `board_type`, so
multi-board workspaces silently lost every board except the active one
on save, and wires referencing the dropped boards' IDs orphaned to the
canvas corner on reload. An audit of the production backup found 74/306
projects (24%) with at least one orphaned wire and 174/301 non-trivial
projects whose code was still the default Blink template — strong signal
that users save once and never re-save.
Backend
- Add `boards_json` column on `projects` with idempotent ALTER TABLE in
the lifespan migration list.
- New `FileGroup` schema + `file_groups` array on
ProjectCreate/Update/Response. Legacy `files`/`code` kept for back-compat.
- `project_files.py` now uses `{pid}/{groupId}/{filename}` subdirs via
`read_groups`/`write_groups`. Legacy flat layouts are auto-promoted on
read; legacy single-list `files` only updates the active group, leaving
other boards' files intact.
- `_persist_files_from_body` honors file_groups → files → code priority.
Frontend
- `useSimulatorStore.addBoard` accepts an optional `explicitId` so
saved board IDs can be restored verbatim (wires reference IDs literally).
- New `loadProjectState({boards, fileGroups, components, wires,
activeBoardId})` action: tears down current boards, recreates from the
payload, restores file groups atomically, recalculates wire positions
on the next frame, and refreshes the Interconnect.
- `useEditorStore.replaceFileGroups` for atomic multi-group restore.
- `SaveProjectModal` and `ProjectByIdPage`/`ProjectPage` now go through
`buildSavePayload` / `buildLoadPayload` (handles pre-backfill projects
by synthesising a default board from `board_type`).
Auto-save (#useAutoSaveProject hook)
- 2.5s debounced silent PUT triggered ONLY when an authenticated user
has a `currentProject` with a UUID. State hash detects real changes
vs. UI-only churn; baseline is reset on project load so the just-loaded
state isn't immediately re-saved.
- `beforeunload` flush via `fetch keepalive: true` (supports PUT +
credentials, survives unload).
- Compact status indicator in `AppHeader` (idle/dirty/saving/saved/error).
Backfill script (one-off, idempotent)
- `backend/scripts/backfill_boards_2026_05.py` populates `boards_json`
for legacy projects. Heuristic per project, based on which board IDs
the wires reference:
Case A — wires only ref 'arduino-uno' but board_type ≠ uno:
rename id→board_type and rewrite wire endpoints.
Case B — single-board normal: keep verbatim.
Case C — multi-board: recreate one board per distinct ref, infer
kind by stripping trailing -N suffix.
Also moves any flat files into the active board's group subdir.
Stdlib-only, runs from host or `docker exec`.
Docker
- `Dockerfile.standalone` now copies `backend/scripts/` into the image
so the backfill is callable via `docker exec velxio-app python
/app/scripts/backfill_boards_2026_05.py --apply`.
Verified locally on the restored production backup (363 projects):
33 Case A, 316 Case B, 14 Case C, 135 wire endpoints renamed, 0 orphans.
Re-running the script after apply skips all 363 (idempotent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>