Skip to content

release: OpenScene 0.3.0 - #180

Merged
sjungwon03 merged 5 commits into
mainfrom
dev
Jul 31, 2026
Merged

release: OpenScene 0.3.0#180
sjungwon03 merged 5 commits into
mainfrom
dev

Conversation

@sjungwon03

Copy link
Copy Markdown
Member

Promotes dev to main, 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)

  • Runway and Luma video engines, and eleven more video models — 6 runnable
    video models became 17.
  • timelinePlayback.ts: the rule for what plays at a given moment now lives in
    shared, and the program monitor reads it. A preview that disagreed with the
    export would be worse than none.
  • The Veo and Sora adapters moved to shared, cut at the download rather than the
    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

  • Android export is not implemented. It says so when tapped. Editing,
    generation and frame extraction all work there; only the render does not.
  • No real paid generation has been run end to end — that needs a provider key,
    which is the user's to enter. The shared half is covered against stubbed
    fetches.
  • Voice synthesis is not ported to mobile; the screen says so and cannot charge.

🤖 Generated with Claude Code

sjungwon03 and others added 4 commits July 30, 2026 20:06
…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>
@sjungwon03 sjungwon03 changed the title release: OpenVideo 0.3.0 release: OpenScene 0.3.0 Jul 31, 2026
@sjungwon03

Copy link
Copy Markdown
Member Author

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 sjungwon03 added type:release Release promotion area:ci CI and developer tooling priority:high High-priority work size:XL Very large change status:ready Ready for implementation labels Jul 31, 2026
@sjungwon03 sjungwon03 self-assigned this Jul 31, 2026
@sjungwon03
sjungwon03 requested a review from sjungwon03-ai July 31, 2026 02:06

@sjungwon03-ai sjungwon03-ai left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sjungwon03-ai sjungwon03-ai added the review:approved Latest-head review approved label Jul 31, 2026
* 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>
@sjungwon03
sjungwon03 merged commit e6df687 into main Jul 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ci CI and developer tooling priority:high High-priority work review:approved Latest-head review approved size:XL Very large change status:ready Ready for implementation type:release Release promotion

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants