Skip to content

Web UI (browser control screen)

Moved out of README.md (see development.md for running from source and an overview, and the README for installing).

Run in a Browser (web_app.py)

Quickest start: double-click WebLauncher.vbs in the project root (mirrors dev/Launcher.vbs's pattern exactly, via dev/_web_launcher_impl.bat) - it starts the server hidden, waits for it to actually come up, and opens your default browser straight to the correct token URL. No terminal, no copying a URL out of a log file. Failures go to data/logs/web_launcher.log.

Since it runs via pythonw.exe with no console and no taskbar entry, a system tray icon (matching the desktop app's own tray icon) is the only way to know it's running or shut it down without Task Manager - right-click it for "פתח בדפדפן" (open the current token URL) or "כבה שרת" (stop the server; it shuts down cleanly via uvicorn.Server.should_exit, not a hard kill, so any request in flight finishes first).

Or manually:

uv run python src/entrypoints/web_app.py
The console prints a URL with an access token baked in (http://127.0.0.1:8878/?token=...) - open that link (a plain http://127.0.0.1:8878 with no token gets a 401; the token is stored in data/web_app_token.txt, gitignored). A fresh token is minted every time the server starts, so an old leaked link stops working after any restart; if you need to invalidate the current link without restarting (e.g. it leaked, or a device you shared it with shouldn't have access anymore), click "🔄 החלף טוקן" in the page header - it mints a new one immediately, keeps the tab you clicked it from logged in (its cookie updates in the same response), and logs out every other tab/device. A local, interactive alternative to the desktop GUI for the core workflow only (pick a script, pick a working folder, run it, watch the log/progress live via WebSocket, see recent history) - everything still runs locally on this machine through the exact same utils/shared/worker.py path the desktop GUI uses (same cross-process render lock, so it won't collide with a render started from the GUI or the bot - and the lock's holder/age is visible right in the header if something else is running). Bot management, the scheduler, and Rocket Shorts settings are covered too (see below) - only the clip gallery/export-wizard view still stays in gui_manager.pyw. Since a browser tab can't receive a real filesystem path from a native file dialog, choosing the working folder uses a small server-side folder browser (/api/browse, with quick-access buttons for every drive on the machine) instead - the server already runs locally, so it can just list real directories on this machine. Only one script can run at a time from here (same as the desktop app's single "Run" button) - the page keeps working from the last browser tab that opened it.

story_cutter.py's --ai-provider and video_editor.py's watermark (add and remove/replace)/vertical/timelapse/codec/loudnorm options are exposed as an inline panel when either script is selected (including a live watermark position/size preview, computed from the real output resolution - not a guess) - not just running with defaults. The "remove old watermark" dropdown mirrors the desktop GUI's - pick a position to erase (delogo), independent of whether a new watermark is also being added; combine both in one run to replace an old logo with a new one. The 🌙/☀️ button in the header opens the theme menu - auto/dark/light/high contrast, an accent color and the interface language (per-browser, saved in localStorage, independent of the desktop app's theme; see waves 19-21 below).

Below the script list, a live CPU/RAM/GPU/VRAM/Encoder panel (polled every 2 seconds) mirrors the desktop sidebar's hardware readout - same utils/shared/hardware.py source (including the separate NVENC encoder-utilization reading, see above), so you're not "blind" to what a render is actually doing to the machine just because you're using the browser instead of gui_manager.pyw. If the desktop app or the bot already queued up work of their own, a "🗂️ N בתור" badge appears in the header (same worker.pending_counts_by_lane() the desktop's queue label uses) - without it, a run submitted here while something else is queued would just look stuck with no explanation.

"📊 סטטיסטיקות" in the header opens a modal with average render duration and run count per script - the same job_history.get_average_render_durations() behind Discord's /render_stats, previously only reachable from the bot or (per-run only, no averages) the desktop's history list. The same modal also mirrors ui/stats_tab.py's side panel (item: personal stats dashboard): a 24-hour CPU/RAM/GPU history graph (utils/shared/hardware_history.py, same source the desktop's own graph reads), a "🎮 שעות משחק" hours-played-per-game summary (utils/media/game_playtime.py - accumulated in 5-second ticks by gui_manager.pyw's existing game-detection loop, so this only grows while the desktop app is actually running, same limitation turbo mode already has), and a "📅 קליפים שנלכדו" bar chart of clips captured per day over the last 30 days (utils/media/clip_stats.py, derived from workspace/clips file timestamps - deliberately not broken down by game, since the capture/editing pipeline is Rocket-League-only right now).

"🔔 הפעל התראות" asks the browser for notification permission (only on that click - browsers refuse to prompt automatically on page load) and, once granted, pops a native browser notification when a run finishes if you've switched away from the tab - useful for a render that takes longer than you want to babysit a browser tab for.

"⚙️ הגדרות" lets you download data/config.json as-is, or import one - the only way to move settings between machines used to be copying that file by hand. Importing backs up whatever's currently there to config.json.bak first (same safety net as the desktop's "reset settings" button); the change only takes effect the next time the desktop GUI or the bot starts, since it's a file on disk, not live state in this server's process.

By default the server only listens on 127.0.0.1 (this machine only). Pass --lan to also bind 0.0.0.0 so it's reachable from another device on the same network (e.g. a phone) - the console then prints both the loopback and LAN URLs. Off by default and worth being deliberate about: anyone who gets the token URL while --lan is active can trigger script runs on this machine.

--lan serves over HTTPS, not plain HTTP - the traffic (including the access token itself) actually travels across the local network in that mode, not just within this machine, so it's encrypted rather than sent in the clear. A self-signed certificate (data/web_app_tls_cert.pem/web_app_tls_key.pem, gitignored) is generated automatically on first --lan run and reused as long as it's still valid and covers the machine's current LAN IP; the browser will show a one-time "connection isn't private" warning since it isn't signed by a public CA - that's expected, click through it (the same trade-off every local dev-server with HTTPS makes, e.g. Jupyter or Vite's --https). The default loopback-only mode stays plain HTTP, since that traffic never leaves the machine.

Phone access over Tailscale (19.10.2026): --tailscale, or the "📱 גישה מהטלפון (Tailscale)" checkbox in the general settings screen (saved to data/remote_access.json - the server reads it at startup, so WebLauncher.vbs / _web_launcher_impl.bat start in that mode without any flag), also binds the PC's Tailscale IPv4 - detected at startup (utils/shared/tailscale.py: an interface address in 100.64.0.0/10, the one named "Tailscale" first), never 0.0.0.0 - next to 127.0.0.1. Plain HTTP there: Tailscale already encrypts (WireGuard) and only devices in your tailnet can reach it; the token is still required. The console prints the phone link (MagicDNS name from tailscale status --json when available, else the 100.x address), and the settings screen shows it with the token. In that mode the Origin check also requires the host to be loopback, that address or its MagicDNS name - a foreign name pointing at the PC (DNS rebinding) is rejected even when Origin and Host match. No Tailscale interface: a clear message and loopback only. Both sockets are bound before the token is written (a busy port exits with code 3 and leaves data/web_app_token.txt alone). Changing the setting takes effect on the next server start.

Paired devices (22.10.2026): the PC token above is new on every start, so a phone or ZimaOS lost access after each restart. Under "📱 גישה מהטלפון" in the general settings, "➕ צמד מכשיר" asks for a name and shows a one-time code as a QR and a link (/pair?code=... on the Tailscale address, else the --lan one) - valid 5 minutes, single use, kept in memory only. Opening the link shows a confirm button (opening alone doesn't use the code up - QR scanners and link previews open links on their own); confirming gives the device its own token in an HttpOnly clipforge_device cookie (400 days). A script without a browser POSTs {"code": ...} to /api/pair/token, gets the token once, and sends Authorization: Bearer <token>. That token survives restarts; data/paired_devices.json (gitignored) keeps only its SHA-256, with name, paired-at, last seen and last IP (last seen reaches the file at most every 10 minutes per device). The settings list unpairs one device or all - immediately, open WebSockets included (closed with 4401) - and a device unused for 90 days expires (on by default, a checkbox there). Pairing, the list, unpairing and "🔄 החלף טוקן" work only from the PC itself (PC token and a 127.0.0.1 / ::1 request); "🔄 החלף טוקן" doesn't touch paired devices. A paired device never gets the PC token: /api/access_token answers it 403 and /api/remote_access leaves out token_url. Device tokens are never accepted in ?token= (URLs end up in logs). Code: utils/shared/device_pairing.py, entrypoints/web/routes/devices.py, the checks in web_app._authenticate / _require_access_token.

Export folder (23.10.2026): General settings > "📂 תיקיית ייצוא" picks where the tools write their final outputs - videos and the files next to them (thumbnail, captions, gif). They go there directly, also on a network drive, not as a copy; the studio still renders to <name>.partial.mp4 and renames it in the same folder. The setting is data/export_settings.json (gitignored; CLIPFORGE_EXPORT_DIR overrides it). Empty = workspace/ as before. If the folder is missing or not writable when a run starts, outputs go to workspace and the run log says so at the start and the end - the run doesn't stop. Saving checks the folder (not inside data/ or the project's code, writable) and offers to create a missing one. Reports, run manifests, caches and clips for review stay in workspace. Rocket's own output_dir wins; its default (workspace) follows the export folder, and so do the weekly recap and Wrapped. The studio's thumbnail/stream endpoints and /api/open_folder serve and open files under the export folder too (web_app._is_under_workspace), and the auto-backup copies from it. Code: utils/shared/export_dir.py, /api/export_dir in routes/system.py. Step 2: under "תיקייה לכלי מסוים" the studio, timelapses, highlight reels and the graph editor can each get their own folder ({"tools": {...}} in the same file); it wins over the general one and receives the outputs directly (<folder>/<orientation>, no extra <tool>/ level; the graph editor keeps its outputs/ level, <folder>/outputs/<orientation>, and its screen still lists what is in <general folder>/dataviz and workspace). A tool's folder that is unavailable falls back to workspace with the same log line; CLIPFORGE_EXPORT_DIR still overrides everything. Scripts call export_dir.output_dir(<tool>, ...). Rocket keeps its own output_dir field.

A "🎮 סקריפטים" / "🤖 בוט" tab bar in the header switches between the original script-running view and Discord bot management - start/stop the bot (with the same auto-restart-on-crash behavior as the desktop's "🤖 ניהול דיסקורד" tab, up to 5 crashes in 5 minutes before it gives up and waits for a manual restart), a live log stream, and RSS feed / Steam game tracking CRUD - all reading/writing the same data/clipforge_data.db tables the bot and desktop GUI already use, so a feed added from the browser shows up in the bot immediately.

A "🌐 הגדרות כלליות" tab covers what ui/global_settings_tab.py/ui/style_settings_tab.py handle on the desktop: Discord webhook URL, backup folder (via the same server-side folder browser) + auto-backup toggle, night-mode toggle, turbo-mode toggle, theme color (the same 5 presets + a custom color picker - applied live to this page's own accent color immediately, no restart needed here), GUI language, and "reset to defaults" (backs up to config.json.bak first, same as desktop). All of it reads/writes the actual config.json on disk on every request rather than a cached copy, so it reflects whatever the desktop GUI last saved. Theme/language changes only affect the desktop GUI on its next restart (same as on desktop itself) - this page's own light/dark toggle stays independent, since a browser and a desktop window reasonably get to disagree on that.

An "⏰ תזמון" tab gives the web app full add/view/toggle/remove control over scheduled tasks - the desktop GUI's "⏰ תזמון" tab was previously the only place that could add one (Discord's /schedule_* commands can only view/toggle/remove, see above). Reads/writes the same utils/bot/scheduler_store.py-backed data/scheduled_tasks.json all three surfaces share, so a task added here shows up in the GUI and in /schedule_list immediately.

A "🎬 Rocket Shorts" tab covers ui/rocket_settings_tab.py's settings form: import/export folder + overlay image (via the same server-side folder/file browser), the current clip counter (with +1/-1 buttons), the 5-entry quality preset dropdown (encoder/ffmpeg preset/CQ, same PRESETS table as desktop), recap toggle + period (week/month/year) + day, and the automatic-captions (Whisper transcription, + a translate-to-English toggle) - all reading/writing the same data/rocket_settings.json/data/counter.txt files rocket_shorts_publisher.py actually consumes, so a change here takes effect on the next Rocket Shorts run regardless of which surface (desktop or browser) triggers it. Its "🌊 תצוגת גל קול" button (for tuning story_cutter.py's noise threshold before a run) is here too - pick any audio/video file through the same server-side file browser and it draws the same ~700-bar peak waveform as ui/rocket_settings_tab.py's canvas, via the same pydub decode on the server (/api/waveform, run off the request thread so decoding a long file doesn't block anything else).

Each script gets a ⭐/☆ favorite toggle (reads/writes the same config.json's favorite_scripts CSV as ui/sidebar.py, so a favorite marked here shows starred on the desktop too, and vice versa) - favorited scripts and the 5 most-recently-run ones (from the same job_history ui/sidebar.py's "🕐 הורצו לאחרונה" shortcut uses) get their own section above the category groups. The full category list condenses behind a "📋 הצג את כל הסקריפטים" button by default once you have at least one favorite (matching the desktop's rationale: a brand-new install with zero favorites shouldn't hide anything, since there'd be no way to tell you need to expand it).

On load, the page checks GitHub Releases for a newer version the same way gui_manager.pyw does (utils/shared/update_checker.py, a comparison against utils/shared/version.py's APP_VERSION) and shows a dismissible banner with a link to the releases page if one exists; a network failure or already being up to date both just mean no banner, same silent-by-design behavior as the desktop check. The banner's "⬇️ עדכן עכשיו" button applies the update for real (item: true auto-update, same apply_update() as the desktop) - a JS confirm() first, then POST /api/update_check/apply runs git pull --ff-only + uv sync on the server and reports success/failure text right in the banner; needs a manual restart of the server process afterward either way.

"🩺 אבחון" in the header runs gui_manager.pyw's same utils/shared/diagnostics.py preflight checks (FFmpeg/FFprobe found, Python interpreter resolvable, DB reachable, every my_scripts/*.py file compiles, free disk space, workspace/ is writable, cross-process render lock not stuck) and lists ✅/❌ per check in a modal instead of a Tkinter dialog - same checks either surface asks for.

A "🖼️ גלריה" tab mirrors ui/stats_tab.py's clip gallery and export wizard: every .mp4 in workspace/clips (newest first) as a thumbnail grid (a JPEG frame at 20% of each clip's duration, extracted via utils/media/thumbnail_generator.py and cached under workspace/.thumbnail_cache/ keyed by filename+mtime so a replaced clip gets a fresh thumbnail automatically - a clip whose frame fails to extract just hides its <img> instead of showing a broken one), an inline <video> preview of whichever one you select (streamed with Range support so you can seek, unlike the desktop's "opens your OS's default player"), and the same two export buttons - "🎵 TikTok Export Pro" generates the same utils/bot/seo_generator.py SEO sheet and opens TikTok Studio's upload page in a new tab; "📺 YouTube Export Pro" does the same, then either uploads automatically through the same utils/bot/youtube_uploader.py (if you've set up client_secret.json - progress polled and shown as a percentage on the button, same as the desktop) or opens youtube.com/upload for a manual upload if you haven't. Both export endpoints (and the thumbnail endpoint) validate the clip path stays inside workspace/clips before touching it. Each card also has a "🏷️ תייג" button - content-based auto-tagging (utils/media/clip_tagger.py) extracts 3 keyframes and asks Gemini for a short list of content tags ("Goal", "Save", "Victory Screen"...), falling back to a local Ollama model if Gemini is unavailable/unconfigured - same visual-AI infrastructure story_cutter.py already uses for its keep/discard judging, just returning descriptive tags instead. Strictly on-demand (the button, never automatic on gallery load) so it never silently consumes Gemini quota; results are cached (workspace/.tags_cache/, same filename+mtime keying as the thumbnail cache) so re-opening the gallery shows the same tags without calling the AI again. A search box (filters by clip filename) and a "🏷️" tag dropdown (populated from whatever's already in workspace/.tags_cache/ - never triggers new AI tagging by itself) sit above the grid so a growing gallery stays navigable; both filters combine with AND, and the desktop's ui/stats_tab.py gallery got the same two controls, reading the exact same tag cache (utils/media/clip_tagger.py's read_cached_tags/list_all_cached_tags) so a clip tagged from the browser is immediately filterable from the desktop app too, even though tagging itself still only happens from the web gallery's "🏷️ תייג" button.

A "📈 אנליטיקס YouTube" section below the export buttons closes the loop after upload (item: YouTube Analytics feedback loop) - until now utils/bot/youtube_uploader.py was one-way (upload and forget); every successful upload (from either surface) is now logged to clipforge_data.db (utils/bot/youtube_publish_log.py), and a "🔄 רענן" button fetches current views/likes/comments for every logged video via videos().list(part=statistics) (utils/bot/youtube_analytics.py) - deliberately the plain YouTube Data API, not the separate YouTube Analytics API, since basic lifetime counts are all that's needed and it avoids that API's extra quota/complexity. Reading video statistics needs a scope the upload-only setup never requested (youtube.readonly); youtube_uploader.SCOPES now asks for both scopes together, and _get_credentials() detects a cached token that's missing the new one and transparently re-runs the consent flow (opening a browser on whichever machine is running the server) instead of failing with an opaque 403 the first time analytics is used. Refresh runs in a background thread (poll via /api/youtube_analytics/refresh_status) since that first re-consent can block on human interaction for an unbounded time - the same reason /api/clips/youtube_export itself is backgrounded. Only videos uploaded after this feature shipped are logged - there's no way to retroactively discover earlier uploads' video IDs, so older videos simply won't appear in the list.

Each logged video also gets a "🖼️ תמונות ממוזערות" button (item: A/B thumbnail testing) - 3 candidate thumbnails (frames at 20/50/80% of the clip's duration, utils/media/thumbnail_candidates.py) generated automatically right after upload, while the original clip file is still guaranteed to exist, and cached under workspace/.thumbnail_candidates/<video_id>/ (keyed by video ID, not the clip's own path, so the candidates stay available even after the source clip is later moved/archived/deleted). Clicking one sets it as the video's actual YouTube thumbnail (thumbnails().set - needs only the existing youtube.upload scope, no further re-consent) and snapshots the view count at that moment (utils/bot/youtube_thumbnail_log.py); the next analytics refresh then shows "🖼️ +N מאז ההחלפה" next to that video, letting you see whether the switch actually helped. This is deliberately sequential comparison (switch, then compare before/after), not simultaneous split-testing - YouTube Studio's own "Test & Compare" feature does that internally but isn't exposed anywhere in the public Data API, so there's no way to build true A/B testing against it.

Ctrl+K (or "⌨️ Ctrl+K" in the header, for touch devices with no keyboard shortcut) opens the same quick-search command palette as ui/widgets.py's CommandPalette - type to filter every script by name or filename and pick one to select it, without scrolling the sidebar. The desktop's "🔔 מרכז התראות" notification drawer isn't ported - it's a running feed of every log_message() call across the whole desktop app (bot events, background jobs, etc.), and the browser doesn't have that same multi-source stream to feed a duplicate of; the run console here already shows everything relevant to what you're doing in this tab.

Category filter on the desktop's unified log (item: log search + filter) - the free-text search (toggle_log_search/search_log, highlight-all-matches) already existed on both the desktop's "📝 מרכז לוגים מאוחד" and the browser's run console; a row of small toggle chips next to it (SYSTEM/ERROR/AUTOMATION/SETTINGS/DISCORD/DOCKER - every category any log_message() call actually uses, all on by default) now lets you hide whole categories, useful once the log has scrolled past a few hundred lines. Every line is kept in self._log_history (category, formatted text, color tag) regardless of visibility so toggling a category back on reveals its earlier lines too, not just new ones; "🧹 ניקוי" clears this history alongside the visible text so a fresh start doesn't get repopulated by an old category re-toggle. Desktop-only, same reasoning as the notification drawer above: the browser's run console is one script's raw stdout (no discrete categories to filter by), not a multi-source categorized feed like the desktop's unified log.

A "🔊 סאונדבורד" tab mirrors ui/soundboard_tab.py's three fixed effects (🚨 Airhorn, 💥 Boom, 🎮 Game Over) - winsound.Beep plays through the speakers of the machine running the server, not the browser, so this behaves correctly even browsing from a phone in --lan mode (the streaming PC is what should make the sound, not the phone).

A run started from the browser now has the same completion side effects as one started from the desktop GUI (ui/dashboard.py's _record_and_notify/_run_backup_mirror/_start_shutdown_countdown), reading the current config.json at the moment the run finishes rather than whatever was set when the run started: a Discord webhook notification (sent on both success and failure, matching the desktop), an auto-backup mirror of every workspace/vertical|horizontal|square folder that already exists (each on-demand created by whichever script has actually rendered that orientation) to the configured backup folder on success, and - if "💤 מצב לילה" is on - an actual OS shutdown countdown on success. Since there's no native window to pop a cancel dialog over here, a red banner appears at the top of the page instead with a "🛑 בטל כיבוי" button (calls /api/cancel_shutdown, i.e. shutdown /a) - visible only while that browser tab stays open, so leave it open (or check back) after a night-mode run if you might want to cancel it.

GET /health is the one route that skips the access-token check entirely (item 28) - an external uptime monitor (UptimeRobot, Healthchecks.io, a cron curl, ...) has no way to supply the token, and shouldn't need one just to confirm the process is still up. It does no disk/DB/worker I/O and always returns {"status": "ok"} with a 200 - fast and unable to fail on its own, so a monitor alert reliably means the server itself is actually down, not that this one check hit an unrelated error.

All API routes are rate-limited per source IP (via slowapi) - a default of 60 requests/minute, tightened further on the endpoints that actually change something: 10/minute on /api/run, /api/stop, /api/rotate_token, /api/settings/reset, /api/settings/import, and /api/undo/last_move_batch, and a stricter 5/minute on /api/credentials/check_validity specifically, since each call there burns real Gemini/Twitch/Discord API quota rather than just touching local state. It's keyed by IP rather than by the access token, since more than one device/tab can share the same token. A request over the limit gets a 429. This matters most with --lan on: without it, anything on the LAN holding the token URL could otherwise hammer these endpoints with no limit at all.

Archive tab for scripts a "super" script has made fully redundant (item)

A new "🗄️ ארכיון"/"Archive" tab in the top nav (web_app.py's ARCHIVED_SCRIPTS, a small fixed set - not a generic tagging mechanism) lists scripts that a unifying "super" script (see below, and video_editor.py above) already covers completely, so they no longer clutter the main script list - they're still fully runnable, just one click away in this dedicated tab instead (clicking one switches to the Scripts tab with it already selected). Scoped deliberately narrow: a script only qualifies once nothing it does is missing from its replacement - timelapse_maker.py (superseded by video_editor.py --target-seconds) and the six scripts Super Manager/Super Streamer absorb (see below) qualify; scripts that only partially overlap (rocket_shorts_publisher.py, story_cutter.py, duplicate_scene_detector.py, file_renamer.py - each has a genuine capability no unifying script replaces) stay in the main list on purpose.

Studio and graph editor layout (waves 57+)

Fixes and reorganization from a screenshot audit of both screens at 1440x900 and 1920x1080.

  • Wave 57 - layout fixes. The studio's live player sizes itself from the height actually left in the player column (_veLiveRoom, 61-editor-live-player.js: the column's max-height minus the rows above the player and its control bar) instead of 55% of the window - at 1440x900 the bottom 166 px and the controls were hidden behind an inner scroll. In the studio the log drawer is folded to one line while nothing runs (uiStudioConsoleOpen, a manual open is remembered), opens when a run starts or fails, and the freed height goes to the columns. In the graph editor the panes drop the 760 px .tc-section cap (the preview fills its column; outputs and "new episode" span the full width, outputs in a grid), opening the full editor or the data window switches to an editing layout (settings at half width, tabs in one row, the episode list folds away - "📋 Episodes" brings it back), and "what to produce", Run/Stop, the status and the progress bar moved to the top bar (always shown; the ☰ only in full screen).
  • Wave 58 - the studio, organized. One place to pick a style: the top-bar picker in every mode, with the five most-run styles in a "⚡ Most used" group at its top (_veRenderProfileChips now fills that group; _veStyleOptions lists each style once for the library) - the quick chips in the status bar and the own-style chips in simple-mode step 3 are gone (step 3 is only the look). Help moved to its own 🛟 menu (glossary, shortcuts, command preview, tour, what's new - the "new" dot is on it), and ⋯ has two titled parts, "The project" and "How settings are shown". "Restored the clip edits and targets" is a toast when nothing needs deciding - the full-width banner only appears when the project's saved settings differ (it carries "Apply"). The breadcrumb is hidden in simple mode (it describes the inspector, which simple mode does not show). Targets: YouTube, Shorts, TikTok and Reels in one row, the rest behind "➕ More targets" (veMoreTargets); a chosen target always stays visible.
  • Wave 59 - the graph editor, organized. The separate "episode settings (edit)" form is gone: its eight fields are the "⭐ Basics" group at the top of the full editor (dataviz/schema.py, CHART_CHOICES is tested against dataviz.charts), and "↩️ Reset all" in the editor bar deletes the episode's overrides file (the old form's reset). Picking a theme in "light vs dark" stages it as a draft in the full editor. Episode actions (duplicate, next in series, channel kit) are in a ⋯ menu next to the episode name; chart type, target, language and publishing package fold into "⚙️ Changes for this run" with a one-line summary; the controls under each card (queue, tags, hide) are one row that fades in on hover or focus (opacity, so they stay clickable and always show on touch screens); "➕ New episode" at the top of the list opens the form in a dialog (dropping a file opens it too, and it closes after creating); the explanation at the top of the list shows only when there are no episodes; and the line under the live preview shows the date and the time in the video (📅 / ⏱, from X-Frame-Date / X-Frame-Fps) with the frame number on hover.
  • Wave 60 - shared by both. A 🔍 button in both top bars opens the app-wide search (openCommandPalette, Ctrl+K - the header with the search box is hidden in full screen); "✕ Close" is a visible button at the end of both bars (the studio's moved out of ⋯, same id veFocusBtn); and a run of one tool shows in the other's top bar as a chip with its progress (uiMarkRunOwner in 65-ui-shell.js - each tool marks itself when it starts a run, cf-progress/cf-finished drive the chip, a run started elsewhere shows as "Run"), clicking it goes to that tool.