release: OpenScene 0.3.0 - #180
Conversation
…Settings (#170) * feat(updater): ask about an update instead of waiting to be found in Settings The updater checked on launch, downloaded what it found, and held it ready without saying so. The only place that state appeared was Settings → Updates, so a user who never opens that panel keeps running an old build while a downloaded update sits on disk. It now asks, following opencode's showUpdaterDialog: "OpenVideo 0.3.0 is downloaded. Restart to install it?" with Restart and Later. On a platform that cannot replace itself — unsigned macOS, a package-managed Linux install — it offers the download page instead. The part worth copying from opencode is what it does *not* say. A startup check that reports "you are up to date" every launch trains the user to dismiss the box that also carries the real update, so up-to-date, failed, and disabled are silent on launch and reported only from the new Check for Updates… menu item, where the user asked. Nothing restarts on its own; Later leaves the update ready. The decision of what to show is a pure function in shared/updater.ts, and the wiring lives in a new updaterPrompt.ts rather than beside setupUpdater. That is not tidiness: updater.ts imports electron-updater, which reads app.getVersion() at module load, so anything importing it cannot be tested outside Electron. With the dialog and the browser handoff passed in, both the decision and the wiring are covered. Closes #169 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(updater): log what the updater does, so a failed update can be diagnosed Asked to check why an update was not happening, I could only find out by copying the packaged app out of its DMG and running its binary from a terminal — which showed the updater working perfectly: found 0.2.0, downloaded it, started the Squirrel.Mac proxy. Nothing was broken; nothing was visible either. electron-updater says a great deal about what it is doing, and by default says all of it to a console no packaged app has. So "the update is not working" was undiagnosable from inside the app, which is the actual defect here even though the update mechanism is fine. autoUpdater.logger now writes to updater.log in the app data folder, and the Updates settings panel says where that is. Written synchronously and best-effort: a logger that throws, or that loses lines because the process is quitting to install, would be worse than none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(updater): show download progress, and fold the menu item into one app menu Three things. The blocking review finding: on macOS the template pushed role: 'appMenu' and then a second entry also labelled OpenVideo, so the menu bar ended up with two app-named menus — the real one and a stub holding a single item. Check for Updates… now sits inside the one app menu, just under About, which means the standard items it used to get for free from the role are now listed explicitly. Windows and Linux have no app menu, so it goes in Help there. Tests cover all three cases by faking process.platform. A downloading update reported nothing but its version, so a 150MB transfer looked identical to a stall. The state carries percent and byte counts, Settings draws a bar, and the label reads "Downloading 0.3.0… 42%". Before the first progress event there is no number, and the bar is deliberately indeterminate rather than sitting at 0% as though nothing were happening. Two details that matter more than the bar. electron-updater fires download-progress many times a second and every transition is broadcast to every window, so the controller drops reports that round to the same whole percent — throttled at the source rather than asking subscribers to cope. And a report arriving after the download finished is ignored, so a late event cannot knock 'ready' back to 'downloading'. The non-blocking note about the log growing without bound: one rotation at 512KB. Not a rolling archive; nobody needs last year's update checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(updater): prompt from the state start() already resolved Review note on #170: startup called controller.start(), which checks, and then promptForUpdate, which checks again. check() short-circuits on 'ready' and 'available' but not on 'up-to-date', so the common case — nothing to update — made a second network request on every launch. promptForUpdateState takes an already-resolved state; promptForUpdate is now check-then-prompt on top of it, which is what the menu path wants because there the user asked for a check. Startup uses the state start() returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…nk (#172) * fix(editor): stop the timeline folding into its own toolbar when shrunk Reported as the timeline shrinking upwards past a certain point. Measuring the real layout — the whole ancestor chain from app-shell__body down, with the compiled stylesheet — showed the direction is fine: the row shrinks from 365px to 100px as the split moves, its top edge descends, and the panel tracks its row exactly with no overflow. What is missing is a floor. The timeline panel is a toolbar of fixed height above a flexible track area, so as the row shrinks the toolbar keeps all 46px and the track area is what collapses. At 85% program on a 542px workspace the track area measured 52px — less than one 56px video track. Past that point the only thing left on screen is the toolbar, so the panel appears to fold into its own header. The row now has a pixel minimum of 150px: the toolbar, the ruler, and one video track. The percentage clamp stays as the coarse limit, but it cannot promise this — 25% of a short window is not a usable timeline, and the failure got worse the smaller the window. The program row keeps its 0 minimum on purpose. Floor both and the grid overflows instead of yielding. Closes #171 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(editor): assert the timeline floor by size, not by its exact CSS token Review note on #172: pinning the literal minmax() string means tuning the floor later breaks the test for the wrong reason — a changed number reads as a regression. The number is now checked against what the pieces measure: toolbar 46px, ruler 20px, and the shortest video track 56px. The token assertion stays but only loosely, enough to catch the minmax(0, …) it replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…d core (#174) * feat(mobile): React Native scaffold that runs the desktop app's shared core Choosing React Native over Flutter rested on an untested assumption: that src/shared — 4,105 lines holding the timeline model, clip placement, the shot-length table, pricing, and narration timing — crosses unchanged. This branch tests it rather than assuming it, because if it were false the reasoning behind the choice was wrong. It crosses, with one defect found on the way. The mobile typecheck reached the whole shared core and stopped on exactly two lines: models.ts and updater.ts used NodeJS.Platform, so the shared core quietly depended on a Node type environment. The desktop build could never reveal that. HostPlatform replaces it, and NodeJS.Platform now appears only in ffmpegDiscovery, which is Node code and the right place for it. Verified by running, not by typechecking. Metro bundled 708 modules in 2.7s and the app renders a real plan on an iPhone 17 Pro simulator: 30s becomes 12/12/4/4, the planner reports that 30s is not reachable and the plan runs 32s, and the cost comes out $3.20 against 32s at $0.10/s. Tapping 45s re-runs it to 12/12/12/8/4 and $4.80, with the per-shot figures summing to the total. They did not sum at first. The screen truncated the formatted estimate at the first '.', turning $1.20 into $1, so the shot lines contradicted the total — found by looking at the screenshot rather than by any test. The seam vendors nothing: Metro watches only ../src/shared, not the repository root, which would pull the Electron sources into the bundler graph. The mobile tsconfig includes the shared core so it is typechecked as the app sees it, which is what caught NodeJS.Platform. tests/sharedCorePortability.test.ts keeps it that way — it fails if a shared module reaches for a Node built-in, Electron, the DOM, or a NodeJS.* type. Versions come from create-expo-app rather than from me; my first hand-written pins were wrong by three majors on Expo and four minors on React Native. The video pipeline is deliberately absent. It is built on spawning a system FFmpeg binary, ffmpeg-kit was archived in early 2025, and replacing it with AVFoundation and Media3 Transformer is framework-independent work that does not belong in a scaffold. Closes #173 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): plan, image generation, and provider keys on three screens Builds the companion app on top of the scaffold: shoot and generate on the phone, approve the spend, edit on the desktop. The blocker was byte handling, not the adapters. imageGenerationAdapters had no node: or electron imports — only Buffer, in three places. So the requests and the response parsing move to src/shared/imageGeneration.ts returning base64, and the main process keeps nothing but the Buffer conversion it needs to write files. Both apps now send the same request and parse the same response, and the existing adapter tests still cover it through the wrapper. That module needed its own base64 encoder for the URL-response path. Node has Buffer and btoa; Hermes has neither reliably. Depending on either would have made the module portable only on paper. Three screens. Plan sizes a video into provider-legal shots, prices it, and holds an approval that clears whenever the length or model changes — approving one price and generating another is the failure the desktop gate exists to stop. Image generates for real against a stored key. Settings holds the keys in Keychain or Keystore, reports only whether one exists, and never reads one back for display. Bottom tabs rather than the desktop's top strip: a desktop window has chrome to hang tabs from and a phone's reachable area is the bottom. No navigation library — three sibling screens with no stack, params, or deep links do not need one, and it would be the largest dependency here. Screens stay mounted so switching tabs does not discard a typed prompt or a generated image. Two things found by looking at the simulator rather than by any check. The title sat under the Dynamic Island and the tab bar's bottom padding was a guess; react-native-safe-area-context replaced both with the real insets. And a provider with nothing stored offered "Clear" as its primary action — a button inviting the user to delete a key that does not exist. It now reads Save, disabled, and only becomes a destructive "Remove stored key" when there is something to remove. Closes #175 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ive export (#178) * feat(mobile): a timeline editor driven by the desktop's own editing rules Import from the photo library, select a clip, split, trim, nudge, delete, scrub, zoom, undo and redo — every operation calling placeClip, moveClip, trimClipLeft, trimClipRight, splitClip or deleteClip from src/shared. Nothing here reimplements a rule; the hook owns only what the desktop's editor hook owns: selection, playhead, and the undo stack. The touch model is inverted from the desktop's. There is no hover and no right-click, so tool-then-target does not transfer: a clip is selected first and acted on second. Tapping empty lane scrubs, because a separate scrub bar would cost vertical space a phone does not have. An operation the shared rules reject says why rather than doing nothing. On a touch screen a silent no-op reads as a missed tap. Two layout faults found by looking at the simulator. The toolbar's horizontal ScrollView stretches its children to the content height by default, so every tool rendered as a full-screen column; it needed alignItems and a non-growing style. The same applied to the timeline scroller. The app states in-screen that it cannot export yet, rather than letting that be discovered after an edit. Closes #176 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): native export via AVFoundation, and the shared plan both pipelines read FFmpeg is not the path, and this is why rather than an assumption. ffmpeg-kit was archived in early 2025; the surviving fork react-native-ffmpeg-kit@6.1.2 still publishes its JS wrapper and its podspec still depends on the ffmpeg-kit-ios-min CocoaPod. That pod's spec is still on trunk and answers 200 — but the xcframework it points at, github.com/arthenica/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-min-6.0-ios-xcframework.zip returns 404. The release assets went with the archive, so every wrapper built on them installs and then fails at pod install. Checked by fetching it, not by reading about it. So the pipelines are the platforms' own. They cannot read a filter_complex string, which is why compileFfmpegTimeline does not transfer — but the layer underneath it does. src/shared/videoCompositionPlan.ts states the facts both need: which source, which slice of it, where on the timeline, in what layer, at what gain. AVFoundation builds an AVMutableComposition from that and Media3 will build an EditedMediaItemSequence, without the ordering rules being written twice. The plan keeps the inversion the FFmpeg overlay chain also needed: layers come out bottom row first, because AVFoundation draws the first layer instruction on top and the timeline's first track is the topmost. It drops clips that would composite to nothing, and folds a muted track over a clip that is not silent, which is what the editor already does. Each video segment gets its own AVFoundation track. Sharing one would serialise clips that are meant to overlap, which is the entire point of multiple tracks. Android throws a coded error instead of returning a path. A silent no-op on an export is worse than a failure: the user believes they have a file and finds out otherwise when they try to share it. The same reasoning rejects an export whose source is missing from the device — it would produce a shorter video than the timeline shows. Closes #177 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): narrow on track kind before reaching for the audio mix The previous commit left the desktop build broken. The composition-plan test selected the audio track by id and then read track.mix, which does not exist on the video arm of the union. I saw npm test pass and moved on. Vitest does not typecheck, so the suite was green while tsc was not — the build is the check that would have caught it, and I read its output only after committing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): projects in the app's own storage, and an export the user receives The editor held one timeline in memory and an export stopped at a temp path with no way to reach it. Both are now what a phone app is expected to be. Projects live inside the app rather than in a folder the user picks. The desktop is folder-backed because a desktop user thinks in a filesystem; a phone user does not, and a document they have to file away themselves is not how a phone app behaves. LocalProjectSnapshot and parseTimelineDocument already sit in shared, so the schema and the validation were settled — only the store and a screen were missing. Imported media is copied into the project instead of referenced. A photo-library URI stops resolving when the original is deleted or the permission is revoked, and a project that silently loses a clip between sessions is worse than one that costs a copy. Every accepted edit writes. A phone app is backgrounded and killed without warning, so a save button is a thing to forget. Undo history does not cross projects: stepping back into another project's timeline would be nonsense. A stored project is read through the shared validator rather than trusted, and an unreadable one is reported as absent so a single broken file cannot hide the list. Deleting only ever removes a directory inside the app's own storage, which is the rule the desktop follows for the same reason. Export now lands somewhere. It saves to the photo library, and falls back to the share sheet when that permission is declined — refusing photo access should not cost the render. Rendering and delivering fail separately, so a finished export is not reported as failed because a share sheet was dismissed. The native module is now required optionally. Requiring it outright threw at import time under Expo Go and took the whole app down, turning "export is unavailable" into "the editor will not open". The button is disabled with the reason stated, rather than failing when pressed. Closes #179 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): a project list and an editor, instead of five peer tabs The flat tab bar put Projects, Edit, Plan, Image and Settings at the same level, which said they were equally important and unrelated. They are not: a project is the thing, the editor is where you work on it, and generation is something you reach for while editing one. So it is two levels, as every editor of this kind is. The project list is the root. Opening a project enters the editor, which has its own bar — back, the project's name, settings — and a Generate control that opens a sheet with video, voice, image, and the assistant. Those open over the editor rather than beside it, because none of them is a peer of the project. Voice does the half that works. Narration sizing is shared and pure, so it runs; synthesis does not, because the speech adapters still return a Node Buffer — exactly what blocked image generation until that byte handling moved into shared. The screen does the sizing and says which part is missing, rather than offering a button that cannot work. The assistant says the same kind of thing rather than showing a chat box that cannot act: its tools read and write a project on disk from a long-lived process, and the app has neither. The pieces that did cross — shot planning, pricing, narration sizing — are already reachable from this menu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(mobile): how to build the dev client, and the locale trap that blocks it The Swift exporter was written but never compiled, because Expo Go cannot load a local native module. It compiles and links now, verified rather than assumed: BUILD SUCCEEDED, VideoExport 1.0.0 present in Podfile.lock as a local pod, and the editor's export note switched from "needs a development build" to the AVFoundation text — which is driven by requireOptionalNativeModule, so it is a direct report that the native side resolved. The step that blocked it is worth writing down. pod install dies with Unicode Normalization not appropriate for ASCII-8BIT (Encoding::CompatibilityError) when LANG and LC_ALL are unset: CocoaPods calls unicode_normalize on the installation path, which without a UTF-8 locale is ASCII-8BIT. This machine had both unset, so expo prebuild reported success while the pod install inside it had already failed. LANG=en_US.UTF-8 is the entire fix, and nothing in the error text points at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(mobile): show the project's menus instead of hiding them in a sheet The Generate dock cost two taps and told you nothing until you opened it. Every entry behind it belongs to the project you already have open, so they are now tabs inside the project: Edit, Video, Voice, Image, AI. This is not the flat tab bar from before. That one put Projects beside Voice as though they were the same kind of thing. These tabs exist only once a project is open, so the hierarchy holds and the menu is still visible. Export moves to the project bar. It sat in the editing toolbar between Split and Delete, which are tools that change one clip; export produces the whole video and belongs with the project's name and its back button, not among them. It also reads the project from disk rather than from the editor's state. Every accepted edit already writes, so the stored copy is current, and the button no longer needs to live wherever the editor's state happens to be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): pick any provider and model, and approve spending per feature The mobile screens each hard-coded three models and one credential slot, while the shared catalog already described 35 media models across 12 providers and 4,954 chat models across 153. The app looked like it supported a tenth of what it knew about. Every media screen now renders the catalog for its domain. Models whose adapters are not ported are shown but disabled with the reason, because hiding them understates the app and offering them hands the user a tap they cannot act on. Spending is asked for per feature, not per app. Allowing every future image is a different decision from allowing every future video, and the two do not cost remotely the same, so "always" is stored against the feature and can be cleared for one without clearing the others. The AI tab was a placeholder; it is now a real tool-calling agent that talks to the provider directly, since a phone has no privileged process to route through. It never runs a tool it proposed — free tools get a plain approve/deny, and anything that charges shows the price first and goes through the same once/always/reject prompt the buttons use. Only tools whose adapters actually run on the device are declared. A generate_video tool would be a lie today, and a model that is offered a tool will call it. Verified on the dev client: the picker, the spend prompt, the standing permission surviving a screen change, and Reset clearing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): generate video on the device, not just plan it The Video tab could price a plan and then told the user to go and run it on the desktop. That was the honest thing to say while the adapters were Node-only, but it is not a video feature. The Veo and Sora adapters move into shared, cut at the download rather than at the bytes. Images could be lifted by returning base64, because an image is small enough for that to be free; a ten-second clip is megabytes and base64 inflates it by a third before anything reaches the disk. So the shared half creates the job, polls it, and returns *where* to fetch the result — the desktop reads that into a Buffer as it always did, and the phone hands the URL to the native downloader and never holds the clip in JavaScript at all. The desktop keeps its old function signatures and its tests pass unchanged, so nothing about the existing behaviour moved. On the phone, shots run one at a time: in parallel they would multiply the spend before the first failure could stop it, and a failure stops the run rather than paying for the rest of a sequence that can no longer be assembled as planned. Finished shots are appended to the project's video track, not placed at their planned start — the plan's timing assumes every shot succeeds, and a gap where a failed shot would have gone silently changes the cut. The agent gets generate_video now that it is real, with progress reported into the chat, because a bare spinner for several minutes tells the user nothing. Not verified: an actual paid generation. That needs a provider key, which is the user's to enter. What is verified is the shared half — request, polling, snapping, and that a rejected key never appears in an error — against a stubbed fetch, plus the screens on the dev client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): a preview, playback, draggable clips and a media bin The Edit screen could rearrange a timeline it could not show you. There was no picture, no playback, and the only way to move a clip was a ±1s button — you were editing a document, not a video. What plays at a given moment moves into src/shared/timelinePlayback.ts, because three places have to agree on it: the desktop's program monitor, the phone's preview, and buildCompositionPlan. The rule is the document's own — first track is topmost, a fully transparent clip lets the layer below through, a gap is black. A preview that disagreed with the export would be worse than no preview, since the user trusts what they saw. The renderer's own copies of the two primitives are deleted in favour of it. The preview is one player re-pointed as the playhead crosses clips, not one per clip; a phone will not keep a dozen decoders alive and a timeline is allowed a dozen clips. Reaching the end of a clip jumps to the next one rather than stopping, so playback runs the cut, and a gap is advanced by a timer because there is no player to report progress over one. Clips are dragged now, with 22pt trim handles — a 4px edge like the desktop's is unhittable under a fingertip, and a miss trims when the user meant to move. Only a *selected* clip takes the gesture: the timeline scrolls horizontally and so do these drags, so tap-then-drag is what lets a timeline wider than the screen still be scrolled. The first attempt looked like it did nothing because the enclosing ScrollView was claiming the pan; the clip now takes it in the capture phase and the scroll view is frozen for the duration. The media bin is the file-management half: what the project holds, how big it is, how many clips use it, adding another instance without copying the file again, and deleting — which removes the file and every clip that referenced it, because leaving those behind points the timeline at something that is gone and fails at export instead of now. Verified on the dev client with a real clip: the preview shows frames, playback advances picture and playhead together, a drag moved a clip from 0s to 3.6s, "+ Add" placed a second instance, and deleting cleared both clips and the file from disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(video): add the Runway and Luma engines, and eleven more models Six video models were runnable and seven sat greyed out. Runway turned out to be the cheapest way to fix that: it fronts Seedance 2.0, Veo 3.1, HappyHorse and Gemini Omni Flash behind one endpoint, so a single adapter and a single key make nine models reachable rather than one. Luma's Dream Machine adds two more. Runnable video models go from 6 to 17. Everything here is read off the providers' own current docs rather than recalled — Runway and Luma both publish machine-readable references, so the model ids, the endpoints, the task statuses, the duration ranges and the per-second credit rates are quoted, not guessed. That matters more than usual: a wrong model id is a 404 the user pays nothing for but a wrong *rate* is a bill they did not agree to. Runway bills in credits at $0.01 each, so the pricing table carries credits-per-second ÷ 100 at the 720p tier, which is the tier the adapter requests. Two things the docs settled that a guess would have got wrong: every Runway text-to-video model takes landscape or portrait only, so a square request renders landscape rather than being rejected after the spend was approved; and Runway's output URLs are pre-signed and expire in a day or two, so they carry their own credentials and the key must not be resent to the CDN. The shot-length table for Runway is the *intersection* of the models it fronts, not any one model's range — Gen-4.5 stops at 10, Seedance starts at 4 — because a length legal for the model you picked and illegal for the next one you try is the failure the planner exists to prevent. Two tests changed rather than broke: they asserted Runway and Luma were unimplemented, which is no longer true, so they now guard Kling, MiniMax and Aleph 2.0 — Aleph edits a source video this build does not send. Also replaces the emoji controls in the editor. `⏮` rendered as a colour emoji on a blue tile, nothing like the controls around it; the transport, the back chevron and the gear are now shapes drawn from Views, themed and identical on both platforms. Skip back/forward step between edit points rather than a fixed number of seconds, because the positions that matter on a timeline are the cuts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(mobile): connect a provider where you need it, not somewhere else The picker used to say "add a key in Settings". That is an instruction, not a control: the user had to leave the screen that was blocked, find one provider in a flat list of a dozen, and come back. Generation is gated on exactly one provider at a time — the one whose model is selected — so that is the only one worth putting in front of them, and it now appears inline under the picker with its key field and Save. Settings is grouped by what a key lets you do rather than by which keys exist: Video, Images, Voice, Assistant. Each provider says how many of its models actually run here, and providers you can use sort first — a list led by three greyed-out engines reads as a broken app rather than a partial one. A provider serving two domains appears under both, because in each place it answers a different question; it is one key underneath, so connection state is read by slot and reports the same in both. One connect flow, one component, used by both — the alternative was two that drift. Fixes a real bug found while testing this: Settings is a modal, so the screen underneath never unmounts and never re-read the keystore. Connecting Runway there left the Video tab still saying "not connected" until the tab was changed and changed back. Closing Settings now bumps a version the media screens watch, and every dismissal path goes through the same close handler rather than only the Done button. Verified on the dev client: stored a key in Settings and watched the badge flip and the card collapse; the picker's inline card swapped from Google Veo to Runway with the selected model and disappeared once that provider was connected; the plan re-derived to Runway's 5/10s shot lengths. The test key was removed afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(mobile): a model select, and a plus for adding providers The horizontal strip of model chips did not survive the catalog growing. Seventeen runnable video models meant the choice was mostly off-screen, and a row you have to scroll sideways hides how much is in it — the user could not see that Seedance or HappyHorse were even options. A select shows one thing, the model in use, and puts the rest one tap away in a sheet grouped by provider, so the list reads as "who can do this" rather than a flat wall of names. Groups follow the provider order already used elsewhere, which puts the ones you can actually run first. Unavailable models stay listed with their reason, as before: hiding them understates the app and offering them hands the user a tap they cannot act on. The plus beside it is a different question, which is why it is a different control. The select chooses among models you can already run; the plus connects a provider so there are more of them. Both sheets are built from the same provider list and the same connect component, so there is still exactly one connect flow. Applied to all three domains — video, voice and image — since the choice is the same shape in each. The inline connect card the picker used to grow is gone with it: the plus is where connecting lives now, and the select just says which provider is missing a key. Verified on the dev client: the sheet lists Runway's nine models with descriptions and Aleph dimmed with its reason, the plus opens the connect list, and all three screens show the same control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(mobile): size the model select to match the controls beside it The stacked provider-over-model layout stood about twice the height of the Length and Aspect ratio chips below it, which made one setting among several read as the loudest thing on the screen. Provider and model now share a line at chip height, and the plus is a square that matches. The sheet rows came down with it: the whole point of that list is comparing models, and it now fits all ten Runway entries on one screen instead of six. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(mobile): stop quoting a price on the video screen The estimated-cost panel — a total, a per-shot breakdown and a list-price footnote — was the largest block on the screen, for a number the user had already determined by picking a model and a length. It goes; the pricing stays where it is actually useful and is handled internally. Nothing is deleted from the pricing module. The agent still quotes a total when it proposes a plan, the desktop still shows one, and the mobile image screen still prints its one-line estimate. What changed is that the video screen no longer makes a price the thing you look at before generating. The approval prompt keeps a headline, because "allow?" on its own tells the user nothing they can weigh — it now names the work, "4 shots · 30s", rather than a figure. The prop is renamed from `cost` to `headline` so it does not claim to hold something it no longer always holds. Concern worth recording rather than acting on: an approval without a price is weaker consent than one with it. This is the user's own app and their own keys, so it is their call, and the prompt still says plainly that it charges their account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(video): continue each generated shot from the last frame of the one before A multi-shot plan produced shots that shared a prompt and nothing else. Every one is rendered blind, so the subject drifted, the light changed, and the sequence read as separate clips rather than a cut — restating the continuity keys in each prompt only ever narrowed that, never closed it. Shots already ran one at a time to bound the spend. That order now earns its keep for a second reason: the tail of each finished shot is pulled out as a JPEG and handed to the next as its first frame, which cannot be done in parallel because the frame does not exist yet. Where a start frame can actually be sent, it is: Veo inline bytes, already supported Runway a data URI, which also moves the request to /v1/image_to_video Sora no — its reference input is multipart this adapter does not send Luma no — keyframes take a hosted URL, and there is nowhere to host supportsReferenceImage() states that, and the frame is dropped rather than sent to a provider that would reject it mid-sequence, after the earlier shots have been paid for. Luma now refuses one outright instead of quietly generating something unrelated to what the user asked to continue. With a frame in hand the prompt changes too: the model can see the continuity keys rather than being told them, so it is asked to carry on from what it is looking at instead of re-reading a description of it. Frame extraction is AVAssetImageGenerator in the existing local module. Two details it needed: a tolerance, because an exact time that is not a keyframe fails outright, and backing off a tenth of a second from the end, because the final presentation time often has no decodable frame. The capability is probed by function rather than by module, so a dev client built before this has export but reports continuity unavailable instead of failing with a native error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): add your own OpenAI-compatible provider The catalog is 153 providers long and still cannot cover a self-hosted server, a gateway, or a regional deployment. Anything speaking the chat-completions wire format needs no new code — a base URL, a key and the model names — so it should not need a release either. Custom providers lead the picker: the user added them deliberately, which is a stronger signal than a popularity list. Their keys live in the same keystore under their own slot, and the id is namespaced so it can never collide with a catalog one. Chat only, and said so on the form. Media generation is not one protocol — every image and video provider here has a hand-written adapter for its own request shape and its own polling — so a "custom video provider" field would take a URL and then fail at the first call. The base URL must be https. This field takes an API key, and a key sent over http is readable by anything between the phone and the endpoint. The stored file is validated on read rather than trusted: it is editable between sessions, and a malformed entry would otherwise surface as an unexplained request failure long after the edit. Verified on the dev client with Alibaba's compatible-mode endpoint: it leads the provider list, its two models are selectable, and it reports its own missing key. Typing the form on the simulator hit a Korean IME, so the store was exercised by seeding the file and restarting — the parse, list, select and connect paths are what that covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(mobile): pin the Android package id expo prebuild derives it from the slug otherwise, so it would be silently re-derived if the slug ever changed — and the package id is what an installed app is identified by, so a change orphans every install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(mobile): make the Android native module compile, and read frames there too The Android half of the export module never compiled. Its stub throws from the body of an AsyncFunction lambda, which infers `Nothing`, and the builder reifies its return type — `Nothing` cannot be one. Nothing had ever built this module for Android, so the error only surfaced on the first APK. The refusal is now a lambda typed as returning Unit, which is the fix and also says why it is written that way. Frame extraction is implemented rather than left absent. It is a few lines of MediaMetadataRetriever, and without it the shot-to-shot continuity the video screen offers would have been quietly missing on Android alone — the toggle would have greyed itself out with a message about rebuilding a client that was already current. Two details it needed, the same two the iOS side needed: back off from the exact end, because the final presentation time usually has no decodable frame, and seek with OPTION_CLOSEST rather than CLOSEST_SYNC, because a sync-frame-only seek can land seconds short of the end of a shot — the wrong picture to hand to the next one. Export itself stays unimplemented on Android and still says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): apply the OpenVideo icon instead of the Expo scaffold's The app shipped with the create-expo-app placeholder — a blue letter A on graph paper, with the construction guides still visible. resources/icon.svg already holds the real mark, so the mobile set is generated from its geometry rather than drawn again. There is no SVG renderer on this machine and adding one as a build dependency to produce six PNGs is not worth it, so scripts/renderIcons.py reproduces the same 1024-space geometry with Pillow. The numbers are copied from the SVG verbatim so the two can be checked against each other by reading them side by side. Two things the first render got wrong, both worth keeping written down: An SVG linearGradient defaults to objectBoundingBox units, so each element's sweep spans that element, not the canvas. Painting everything through one canvas-wide gradient gave every part the same slice of the ramp — the mark came out uniformly blue, with the purple and the mint both off the ends. Each piece now gets a gradient scoped to its own box, and the playhead and empty track are white as the source has them. The adaptive icon's background was still the scaffold's pale blue, which would have shown at the mask's corners and around the foreground on any launcher that scales the layer down. The Android foreground sits at 62% inside the safe zone because the outer third is masked away, and the monochrome layer is a silhouette because themed icons are recoloured wholesale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): pinch to zoom the timeline, and a ruler to zoom against Zoom was two small buttons stepping by 1.5×, and the timeline carried no scale at all — at one zoom a clip was a wide bar and at another a stub, with nothing on screen to say which. Pinch is anchored on the midpoint between the fingers. Without that, zooming walks the timeline sideways under you: scaling alone holds position zero still, so the further right you are the further the content jumps. Keeping the moment under the fingers fixed is the difference between zooming and rescaling. It is a PanResponder rather than a gesture library — two touch points and a distance is the whole gesture, and the alternative is a native dependency and another dev-client rebuild. The ruler's step comes from a fixed 1/2/5/15/60 progression picked so labels land about every 90px. A computed step would give 7.3-second gridlines, which is arithmetic nobody reads; the familiar numbers are what make a glance tell you the scale. Scrubbing moves onto the ruler. It used to require tapping an empty lane, which a timeline full of clips does not have — so the fuller the edit, the harder it was to move the playhead. Fit-to-window is a third button because it is the one zoom pinching cannot reach in a single go, and the one you want after a long edit. Verified on the dev client: pinching stepped the ruler 5s → 2s → 1s with the clip growing to match, fit collapsed a 2.8s timeline to exactly the lane width, and tapping 2s on the ruler moved the playhead and seeked the preview to that frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: release 0.3.0 v0.2.0 is already tagged, and the release workflow keys off the version in package.json — promoting to main without a bump publishes nothing. A minor rather than a patch: this carries a whole mobile application, and the desktop gains real changes with it — the Runway and Luma video engines, the shared playback resolver its program monitor now reads from, and eleven more video models. The mobile app.json version moves with it so a build reports the same release rather than the scaffold's 1.0.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Rebranded since this PR was opened. Once #182 lands on `dev`, this promotion carries it, and 0.3.0 ships as OpenScene. Note for the release notes: the appId moves from `tech.theorvane.openvideo` to `tech.theorvane.openscene`. Anyone on 0.2.0 will not be offered this as an update — an appId identifies an installed application, so to their machine OpenScene is a different app rather than a newer one. They keep the old install and need to download once. Artifacts are also named `openscene-0.3.0-` rather than `openvideo-`. |
sjungwon03-ai
left a comment
There was a problem hiding this comment.
This release PR for OpenScene 0.3.0 introduces extensive mobile support and related components along with other updates. The changes are additive and align with a feature release; no code-quality blockers identified.
Approved.
* refactor: rename the product from OpenVideo to OpenScene Prose and UI only, which is what .agents/rebrand.md and the compatibility list in AGENTS.md already established for the rename before this one — the `window-loom-*` keys still in that list are its fossils, kept for the same reason. The split fell out cleanly: capitalised OpenVideo was always the brand, lowercase openvideo was always an identifier. So productName, the window title, every document and every visible string move; storage keys, the appId, the executable and artifact names, the MCP server name and the package name do not. The one that matters most is `openvideo.` — the mobile keystore prefix. Renaming a key does not migrate what is stored under it, it orphans it, and every API key the user has entered lives behind that prefix. The Electron appId is the same kind of hazard from the other direction: it identifies an installed application, so changing it would orphan every install and silently stop auto-update for anyone on 0.2.0. `getOpenVideoMcpDefinition` was caught by the first pass and put back — the exclusion matched `OpenVideoMcp` at a word boundary and that name continues into `McpDefinition`. Renaming it would also have left the class it returns still called OpenVideoMcpServer, which is worse than either name alone. All of those legacy names are now written down under Compatibility Identifiers with the reason each one stays, so the next rename does not have to rediscover it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: move the application identity to OpenScene The rename before this one changed what the product is called; this changes what it is. The appId, the Linux binary and package, the artifact filenames and the mobile bundle id all become openscene. The appId is the one with a cost, and it is worth being exact about: an appId identifies an *installed* application. Anyone already running 0.2.0 keeps their OpenVideo install and will not be offered 0.3.0 as an update — to their machine it is a different app, not a newer one. They have to download it once. That break is the price of the rename, taken deliberately and in one go rather than leaked out over several versions, and it belongs in the release notes rather than in a support thread. Mobile moves for free: nothing has shipped under the scaffold's com.anonymous.mobile, so there is nothing to orphan. Storage keys, the MCP server name and the source identifiers still do not move, for the reasons already written down under Compatibility Identifiers — those orphan data rather than installs, and no rename is worth losing a user's stored API keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: point the live links at the renamed repository The repository is now Theorvane/openscene. GitHub 301s the old paths, so nothing was broken, but a README badge and an issue-template link that name a repository the project no longer uses are simply stale. Only live links move. The planning documents under docs/ keep their original text — they are a record of what was done at the time, and rewriting the commands in them would make them describe a session that never happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: bring the README up to date with the rename and the last three releases The hero was the loudest problem: a hand-made image saying OpenVideo in 60px type at the top of a page that said OpenScene everywhere else. It is now generated by docs/assets/renderHero.py from the same palette and the same icon primitives the app uses, so the next brand or copy change is an edit rather than a design task. Four claims had gone stale, in descending order of how much they would mislead someone: - "Pre-release, runs from source, no installer or auto-update" — there have been signed, notarised installers with working auto-update since v0.1.0. The banner now points at the releases page and warns that the appId change means an OpenVideo install will not update itself across the rename. - `git clone .../openscene.git` followed by `cd openvideo`, which simply does not work. - Generation was described as two speech providers and two video ones. It is 17 runnable video models across four, eight image models and seven voices. - Image generation had a workspace and no mention. The mobile app had none at all, so it gets a section — and the point of it, which is that it runs the desktop's editing rules rather than its own approximation of them. Its one real gap, Android export, is stated there rather than left to be discovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): make the app submittable to both stores Identity is `com.sloki9637.openscene` on both platforms, the display name is OpenScene, and version, build number and version code are all set. The manifest was asking for permissions the app never uses. RECORD_AUDIO, VIBRATE, SYSTEM_ALERT_WINDOW, the image and audio media permissions and legacy external storage all arrive from transitive libraries; they are now removed, leaving INTERNET and the two video-read permissions the importer actually needs. Reviewers ask about permissions an app does not visibly use, and every extra one is another row to justify in Data Safety. Release signing is a config plugin rather than an edit to build.gradle, because prebuild rewrites android/ from scratch and a hand edit does not survive it. No secret is in the repository: the plugin reads four Gradle properties that belong in ~/.gradle/gradle.properties, and falls back to the debug key when they are absent so a local release build still works for testing. Generating the keystore is deliberately left to the user — it is a credential, and losing it means never updating the listing again. iOS carries its photo-library usage strings and declares ITSAppUsesNonExemptEncryption false, which is correct for standard TLS and the system keychain but is a declaration in the user's name, so RELEASING tells them to verify it rather than trust it. Two smaller corrections: userInterfaceStyle was "light" on an app that is entirely dark, which let the system draw light chrome over it, and the slug was still the scaffold's "mobile". Verified by building: `bundleRelease` produces a 49MB AAB, and the merged manifest carries exactly the three permissions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(mobile): sign in to OpenAI with ChatGPT, over an app link The desktop catches the authorization code on a loopback port. A phone cannot listen on one, so the redirect is the app's own scheme and the system browser hands it back — openAuthSessionAsync opens an ASWebAuthenticationSession on iOS and a Custom Tab on Android. Both are separate from the app, which is the point twice over: the page can use the browser's existing session, and the password is typed somewhere this app cannot see. The half that is not platform-specific moves to src/shared/oauthPkce.ts — the authorize URL, the state check, the token bodies, and reading the account id out of the access token. Hashing deliberately stays out of it: PKCE needs SHA-256 and there is no portable one, so the caller computes the challenge with node:crypto or expo-crypto and passes it in, which keeps the module free of any environment assumption. The state check earns its own test. Without it, anything that can make the app open a URL could hand it an authorization code and have the app redeem it, signing the user into an account that is not theirs. A stored API key still wins over the sign-in: it is what the user typed most recently, and the sign-in only serves the models that backend runs. **Unverified, and it may not work.** The client id is the Codex CLI's, registered against a loopback redirect. Whether OpenAI's authorize endpoint accepts a custom scheme cannot be established without completing a real sign-in, which is the user's to do — probing it returned an identical Cloudflare 403 for both redirect forms, so that told us nothing. If the scheme is not registered the authorize page will refuse it, and the refusal surfaces as OpenAI's own message rather than as something vaguer. The desktop's implementation is untouched. It works and it is released; moving it onto the shared module has no functional payoff and is not worth doing days before a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: require the mobile screen in the same pull request The desktop and mobile are two front ends over one core, but nothing written down said so, and the default without it is that a feature lands on the desktop and mobile catches up later — which is how the two drift into behaving differently on the same project. The rule that matters most is the second caller. A feature implemented on one surface leaves the shared core with a caller on one side only, and it is the second caller that proves the seam was drawn in the right place. That is not theoretical here: `NodeJS.Platform` in shared code and image adapters returning a Node `Buffer` were both caught by the mobile typecheck and by nothing else. It also says what to do when a surface honestly cannot have something — window capture has no phone equivalent, Android export is not written yet — which is to show it disabled with the reason rather than omit it. Silence reads as an oversight; a stated limit reads as a decision. Repeated in CLAUDE.md rather than only linked, because that is the file the agent actually reads, and a rule one hop away is a rule that gets skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: retake the screenshots, which still said OpenVideo Every one of them showed "OpenVideo Edit Agent" and "Tell OpenVideo what to do…" at the top of a README that says OpenScene. They are captures of a running app, so the rename could not reach them; they had to be taken again. Taken over the DevTools protocol rather than by clicking the screen. Driving the mouse needs macOS accessibility control granted to the app that hosts the shell, which is a broad permission and would have needed that app relaunched; a renderer debug port, opened locally and reverted immediately, clicks real DOM elements instead of guessing coordinates and captures the window with no desktop around it. The viewport is overridden to the 1440x860 the README shows, because the real window is narrower and was squeezing the monitor and the inspector. They also carry a year of UI the old ones predate: an Image Generation studio, which had a section here and no picture, and Veo 3.1 in the video model picker. The editor shot is of an empty project. Importing media opens a native file dialog, which the protocol cannot reach — so the choice was an honest empty timeline or leaving an image that names the wrong product, and the alt text now says which it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Promotes
devtomain, which is what triggers the release workflow.What ships
A whole mobile application, and the desktop changes that came with it.
Mobile (new) — projects stored inside the app, a timeline editor with
preview, playback, draggable clips and a media bin; video, image and voice
generation against your own provider keys; a tool-calling assistant that asks
before every call; per-feature spending permission; native export on iOS.
Desktop (real changes, not just carried)
video models became 17.
timelinePlayback.ts: the rule for what plays at a given moment now lives inshared, and the program monitor reads it. A preview that disagreed with the
export would be worse than none.
bytes, with their behaviour unchanged and their tests untouched.
Verification
727 tests, typecheck and build green on every commit in the branch. Each mobile
screen was checked on a development client rather than assumed.
Known gaps, stated plainly
generation and frame extraction all work there; only the render does not.
which is the user's to enter. The shared half is covered against stubbed
fetches.
🤖 Generated with Claude Code