FilmOpen Software Specification
The desktop application that reads, browses and (later) edits FilmOpen projects.
| Version | 2.4 |
| Date | 18 September 2026 |
| Status | Describes the code at the end of milestone 5.01 (Android) and milestone 5.1 (the plug-in architecture), each built on a branch of its own from main at 025d98c and both merged into main on 18 September 2026 as this one 2.4 (each close took 2.3, the next number after the 2.2 both knew). 5.01, closed on 18 September 2026 on milestone-5-01: the Android runner and its wrappers (§3, Appendix A), a phone’s projects in the app’s own folder (§6.4, with §7.1’s providers and §9.2’s dialog), one door for incoming links (IncomingLinks, §6.6) with a sign-in callback exchanged once per process, Google Drive with an Android client and FilmOpen’s own code exchange and refresh (§6.10), the system’s back closing the tree (§9.1), the eye on a key box (§9.4), and the agent layer over adb (§4.3; the MCP Specification 1.5). 5.1, closed on 17 September 2026 on milestone-5.1: plug-ins as folders run in QuickJS-NG in an isolate per call (filmopen_js, §6.11), their manifests, keys and strings (filmopen_plugins), the runtime with its doors, consent, grants and usage records (filmopen_plugin_host, the PluginRuntime seam of §7.1), Settings → Plugins and a plug-in’s page (filmopen_plugins_ui, §9.4), a plug-in’s card on characters, locations and the project, Plugins… on a page’s file menu (§9.3), Provider keys by owner of keys with milestone 4.9’s names moved (§6.7), and the stock Default AI assistant and Template. Before them, milestone 4.9 (keys for the first version, Settings → Provider keys, and dictation with a usage log), closed on 16 September 2026 on the branch milestone-4-9-platform and merged into main the same day: the platform files, the credential store and the key checker (§6.4, §6.7), the usage log and its records (§6.7), Settings → Provider keys and Settings → Usage (§9.4), and dictation, its turns decided on the device (§9.6), with the seam that ends every open dictation when the app does — beside milestone 5 (media), closed and merged the same day: binary I/O in the source layer (§6), the media package and one class for a piece of media (§6.9), the media folder of a new project (§9.2), the reference card with its uploader, thumbnails and viewer (§9.3), the player (§9.3, Windows through Media Foundation), Google Drive as a second media source (§6.10, checked against a stand-in and, after the close, against the owner’s own Drive; the client reaches a build only from the keys folder outside the checkout, docs/FilmOpen-Dev-Keys.md), and the same card on a location. Before them, the issue recorder’s phase 1 (§6.8), milestones 4.7 and 4.8, and MCP step 3. §6.7’s brokered route and §16 record what is planned and not yet built, the start-up check of open chains among it. The two milestones closed on branches of their own — 4.9 left main at 1.9, and numbered its close 2.2 over the 2.0 and 2.1 the issue recorder and milestone 5 had taken main to meanwhile — and were merged into main on 16 September 2026 as 2.2 |
| Companion documents | FilmOpen Project Specification 1.11, a draft (docs/FilmOpen-Project-Specification v1.0.md) defines the file format this software reads and writes. FilmOpen — Milestone 1, 2 and 3, the Plan / Results pairs for milestones 3.5 and 4, and milestone 4.5’s plan (docs/milestone-4-refactor.md) and results (docs/FilmOpen-Milestone 4.5 Results.md) record what was built, why, and what comes next. docs/FilmOpen-MCP Specification v1.0.md is the agent layer as an agent uses it — the tools, the shapes, the identifiers, the recipes; docs/FilmOpen-MCP-Plan.md (step 2, done) and docs/FilmOpen-MCP-Plan-Step3.md (the Linux runner) are its plans, and docs/FilmOpen-MCP Results.md its record. docs/speech.md studies dictation, which milestone 4.9 built (§9.6). docs/FilmOpen-Portal-Specification v1.0.md (0.1 draft) records what filmopen.ai will need when it brokers generation, beside §6.7, and docs/FilmOpen-Managed-Usage-Notes.md, private and never published, what each platform allows for managed usage. Milestone 4.6’s list is docs/FilmOpen-Milestone 4.6-ui-cleanup.md and its record docs/FilmOpen-Milestone 4.6 Results.md; milestone 4.7’s are docs/Milestone 4.7 character and location.md and docs/FilmOpen-Milestone 4.7 Results.md. Milestone 4.8’s plan and record are docs/FilmOpen-Milestone 4.8 Plan.md and docs/FilmOpen-Milestone 4.8 Results.md; docs/platforms/ holds the public platform pages that milestone wrote; and milestone 4.9’s plan, record and proposals are docs/FilmOpen-Milestone 4.9 Plan.md, docs/FilmOpen-Milestone 4.9 Results.md and docs/FilmOpen-Milestone 4.9 Next steps.md. The issue recorder’s plan is docs/FilmOpen-Issue Recorder.md, its record docs/FilmOpen-Issue Recorder Results.md and its proposals docs/FilmOpen-Issue Recorder Next steps.md; docs/automated-bug-tracking-plan.md plans its phase 2 — a report’s path from the app to a public GitHub tracker, and the agents that replicate, fix, verify and build (15 September 2026). Milestone 5’s plan and record are docs/FilmOpen-Milestone 5 Plan.md and docs/FilmOpen-Milestone 5 Results.md, and its proposals docs/FilmOpen-Milestone 5 Next steps.md. Milestone 5.1’s plan, record and proposals are docs/FilmOpen-Milestone 5.1 Plan.md (the plug-in architecture), docs/FilmOpen-Milestone 5.1 Results.md, docs/FilmOpen-Milestone 5.1 Next steps.md and, for a colleague’s Windows session, docs/FilmOpen-Milestone 5.1 Windows validation.md; plugins/README.md is the guide for a plug-in’s author, with plugins/filmopen-plugin.d.ts and plugins/template/ and the website’s docs/FilmOpen-Website Plan.md; milestone 5.02’s plan is docs/FilmOpen-Milestone 5.02 Plan.md (server readiness). docs/follow-up-milestone-5.md gathers every open item the merged milestones left for the owner (16 September 2026). test_plans/ holds the numbered smoke tests run at the end of every milestone, by hand and through test_plans/run_mcp.py; milestone 5 added test_plans/040_media.md, milestone 4.9 test_plans/050_keys-dictation-usage.md and milestone 5.1 test_plans/060_plugins.md. The AI model catalogue, assets/models/, is specified by the Project Specification §14.6–§14.9. docs/Linux-VPS-flutter-dev-setup.md is the owner’s notes on setting up the Linux box. Milestone 5.01’s plan, record and proposals are docs/FilmOpen-Milestone 5.01 Plan.md (Android), docs/FilmOpen-Milestone 5.01 Results.md and docs/FilmOpen-Milestone 5.01 Next steps.md; docs/FilmOpen-Milestone 5.01 Session Analysis.md is the building session’s own account of the milestone, written for the owner’s second opinion. |
| Repository | filmopen (aaronbergen/filmopen on GitHub), branch main, where the owner merges every line of work. History: milestone 5.01 on milestone-5-01 (from main at 025d98c, closed on 18 September 2026) and milestone 5.1 on milestone-5.1 (from the same main, closed on 17 September 2026), both merged into main on 18 September 2026, the specifications reconciled here; milestone 4.9 on milestone-4-9-platform and milestone 5 on milestone-5, both closed and merged on 16 September 2026 (4.9 branched at 9695325, before the issue recorder’s merge, so its close reconciled the specifications’ numbers with main’s); the issue recorder’s phase 1 on filmopen-recorder-49b, merged on 15 September 2026; milestone 4.8 on milestone-4.8 and milestone 4.7 on milestone-4-7, both merged on 15 September 2026 (their branches stay while their worktrees do); milestone 4.6 on ui_cleanup; the model catalogue on keys; MCP steps 2 and 3 on mcp (step 3 first on mcp_step3); milestone 4.5 on filmopen-milestone4-refactor; milestones 3.5 and 4 on filmopen-claude (milestone 4 also on fable-asus, the donor of 4.5’s ports); round-03-c (milestone 3), round-02-c (milestone 2), c (milestone 1). Those older branches are deleted except filmopen-claude, which keeps milestone 5’s first steps and is not to be merged |
Contents
Section titled “Contents”- Purpose
- Design principles
- Technology
- Architecture
- The model layer
- Services: where files come from
- State: providers
- The tree
- The user interface
- Localisation
- The sample project
- Testing
- Conventions for contributors
- Known limitations
- Third-party code
- Future points to address Appendix A — File map Appendix B — Glossary of code names
1. Purpose
Section titled “1. Purpose”A FilmOpen project is a folder of plain JSON files describing a film completely: characters, outfits, locations, props, styles, documents; the script as seasons, episodes, sequences and scenes with their blocks; shots and cues; models, platforms and plugins; render batches; commentary; and official pointers. Every entity file is versioned per author (ch_main-hero_30yo_john123_v2.json), forking copies a file into a new author’s name, and a small _official.json pointer names the version the project has chosen. The intent is that a film is developed the way software is developed: many people, many versions, comparison, and a deliberate choice of what is official.
FilmOpen the application is the reader and editor for such folders. Milestone 1 delivered the reader: open a folder, see the whole project as a tree, inspect any object, see every version of it side by side, see the media rendered for it and what people said about it, and be warned about everything the format’s rules say a reader should warn about. Milestone 2 made it a desktop application that lives on a machine: it knows where a project’s media is (the manifest, §4.4 of the format), where the shared library of models, platforms and plugins is, where its own preferences and log are, which projects the user has opened, and it can create a project folder. Milestone 3 made it a writer: a new project comes with its creator’s first project file, the project file is edited in a form that saves as you type, any version can be forked, a director sets official with a star, anyone picks the version they work from with a check, and a version can be labelled.
The application is not the format. The Project Specification is authoritative about what files mean; this document is authoritative about how the application is built. Where the application simplifies the format’s rules, this document says so.
1.1 Audience of this document
Section titled “1.1 Audience of this document”A developer, or an AI development session, who has read the Project Specification and needs to continue the code without re-deriving its structure. It explains what exists, the reasons behind the shape of it, and the rules that must stay true when adding to it.
2. Design principles
Section titled “2. Design principles”These follow directly from the format’s own principles (Project Specification §2) and from decisions made during milestone 1.
- Warn, don’t enforce. No file is rejected for being imperfect. A header that disagrees with its filename, a field of the wrong type, a segment that is too long, an unknown file: each is a warning the user can see, never a crash and never a silent fix. The loader tolerates any JSON that parses to an object.
- Never guess. When the format says a reader must report rather than choose, the application reports. An ambiguous reference (several authors, nothing official, no viewer) is unresolved, not first-match. A folder with several project authors and no official pointer asks which to open. An implicit epoch falls back to
defaultonly, never to “whatever exists”. - The files are the only state. Every write goes to a file through the project’s
ProjectSource, and the project is re-read afterwards; nothing is edited in memory and saved later. The app writes only under the viewer’s own handle (§17, §18.3 of the format): a form is offered on the viewer’s own version and Fork on everyone else’s; the official pointer is a director’s to write and to remove; the pick lives in the viewer’s own commentary file. Reads and writes are confined to the project folder; the media root and the library are sandboxes of their own, and the library is never written. Creating a project refuses a folder that already holds files. - The model knows the format, not the screen. Everything in
filmopen_formatandfilmopen_storeis pure Dart, free of Flutter and free of user-facing text. Diagnostics are codes with arguments. The core is testable without a widget tree and is, since milestone 4.5, a package of its own that a command-line tool or a server could import. - Language-independent by construction. Every string a person reads comes from a localisation file, including the display labels for the format’s own field names, entity types and counts. The format’s JSON keys are never translated; the screen is.
- One navigation path. Whatever is clicked, in the tree or in the detail pane, goes through one controller that sets what is shown, highlights the matching tree row and reveals it.
- Small, honest surface. The application says what it does not do (placeholders show a message, the README says “nothing writes yet”). Product text is not aspirational.
- Model after API Dash. The window layout follows the API Dash desktop client: a narrow icon rail, a resizable sidebar, a main pane. One component is adapted from it under its licence; the rest is FilmOpen code.
3. Technology
Section titled “3. Technology”| Concern | Choice | Notes |
|---|---|---|
| Language and toolkit | Dart 3.13, Flutter 3.47 (stable) | Windows desktop is the first target; the same code runs in Chrome for previews and in widget tests. |
| State | flutter_riverpod 3 |
Notifier / AsyncNotifier / Provider / FutureProvider; no code generation. |
| Split view | multi_split_view 3.6 |
Wrapped in filmopen_ui/split_view.dart, adapted from API Dash. |
| Folder picker | file_selector |
Desktop only; hidden on the web. Also Settings → Plugins’ Install from file… (filmopen_plugins_ui, milestone 5.1), a zip. |
| Preferences | shared_preferences (SharedPreferencesWithCache) |
Theme, language, viewer handle, sidebar width, verbose logs, recent projects, library folder, the account-signed-in hint, and each provider key’s state (providerKeys.<owner>/<slot>, §6.7). See §6.4 for where the file lives. filmopen_keys’ tests use shared_preferences_platform_interface’s in-memory store, a dev dependency. |
| Application folders | path_provider |
The app-support folder (preferences, shared library) and, on mobile, the log folder. Desktop log folders are computed from the environment (§6.4). |
| File browser and URL schemes | url_launcher |
The mobile “show in folder” branch (desktop uses the OS command directly), and opening the browser for account sign-in and the account pages on filmopen.ai. In filmopen_keys, a platform’s guide and keys page (§9.4). |
| Account | supabase_flutter |
The filmopen.ai account (§6.6): sign-in with Google, Discord, GitHub or an e-mailed code (PKCE), the session, the profile’s display name. Public client configuration only; no service key anywhere in the app. Declared by filmopen_account, and since milestone 5.01 a dev dependency of the root package too: test/account_card_test.dart builds a stand-in client over a MockClient to drive a sign-in callback through the link door without a socket. The app itself reaches Supabase only through filmopen_account. |
| Library warnings | logging |
The Supabase libraries report a failed callback exchange or a persist error through package:logging only; account_auth.dart forwards their warnings into the app log as account.libraryWarning, codes only. |
| JSON tree | voo_json_tree 0.1 (MIT) |
Only in filmopen_forms: the tree the JSON editor opens on (§9.3, milestone 4.6 item 2). The package draws and navigates the tree; editing a value is the editor’s own (a tap on a value asks for it and writes it into the document the text shows), since the package reports no edits. Its toolbar is hidden (its text is English only). It brings flutter_bloc, bloc, provider, equatable, voo_responsive and voo_tokens into the build as transitive dependencies; nothing in FilmOpen uses them. |
| Website | filmopen_state/consts.dart |
kFilmOpenDomain is dxv.filmopen.ai, the development site, in a build that has development mode (kDevelopmentModeAvailable), and filmopen.ai in a release build or one built with FILMOPEN_ENFORCE_GUARDS; kFilmOpenSiteUrl is its https:// root. Every link to the website (the specification in About, the account page and its help) and every sentence naming it (the account card and the sign-in dialog) reads it. Sign-in itself goes to the Supabase project (FILMOPEN_AUTH_URL), which is not this domain. |
| Brand marks | flutter_svg |
The provider marks on the sign-in dialog’s buttons (assets/providers/, see its README for sources); the FilmOpen logo itself is a PNG (assets/branding/). |
| Credentials | flutter_secure_storage |
The account session and the PKCE verifier on desktop (§6.4), declared by filmopen_account; and a user’s provider keys (§6.7), declared by filmopen_platform, whose DeviceCredentialStore is the only code that calls it for them. DPAPI on Windows, the Keychain on Apple platforms, libsecret on Linux. |
| Key checks | http 1.6 |
Only in filmopen_keys, a runtime dependency since milestone 4.9: the one check request Save makes to the address a platform file names (§6.7), over https to one of its hosts, following no redirect, given up after 15 seconds, its body never read. Its MockClient stands in for every platform in the tests; elsewhere http is a dev dependency, for in-process server stand-ins. |
| Test clocks and preferences | fake_async ^1.3.1 (dev: filmopen_dictation, filmopen_platform), shared_preferences_platform_interface ^2.4.1 (dev: filmopen_keys, filmopen_state) |
A dictation’s timers and the microphone’s frames are driven by a fake clock in their tests (§9.6), and the Provider keys tests read the in-memory preferences shared_preferences documents for tests (milestone 4.9, D29). Neither ships in a build. |
| Microphone | record 7.1 |
Only in filmopen_platform, since milestone 4.9: dictation’s audio as 24 kHz mono PCM16 (§9.6), started only on a gesture. BSD-3-Clause; on Windows through record_windows over Media Foundation; the Linux implementation links only GTK and runs parecord while recording, which the Linux agent build never does, since it records from a fake. |
| Live transcription | web_socket_channel 3.0, stream_channel 2.1 |
In filmopen_dictation, since milestone 4.9 — web_socket_channel only there, stream_channel in the MCP server too (below): OpenAI’s live transcription over a WebSocket whose handshake follows no redirect (§9.6), carried as a StreamChannel so that the tests drive a session through a StreamChannelController without a socket. |
| Deep links | app_links |
The browser’s sign-in result arriving as ai.filmopen.desktop://auth/callback — since milestone 5.01 through filmopen_platform’s IncomingLinks door (§6.6), the one door on every platform; on Windows also single-instance forwarding (a second launch hands its command line to the running instance and exits). Declared directly because the Windows runner links the plugin by name. |
| Localisation | flutter_localizations, intl 0.20, Flutter gen-l10n |
ARB files in packages/filmopen_l10n/lib/l10n/, one per language; generated code committed; flutter gen-l10n runs inside that package. |
| The player | video_player_win ^3.3.0 (BSD-3-Clause) |
A clip or a sound plays through Windows’s own Media Foundation: one plugin DLL in the bundle (video_player_win_plugin.dll, 1.6 MB in a debug build) and no third-party codec library at all. It is the fallback the milestone-5 plan’s D16 named, taken because the licence check that decision required failed: media_kit_libs_windows_video 1.0.11 — the newest published — downloads mpv-dev-x86_64-20230924-git-652a1dd.7z, whose FFmpeg was configured --enable-gpl --enable-version3 --enable-nonfree and whose mpv was built without -Dgpl=false; a GPL (and in part non-redistributable) library cannot ship inside an application under the FSL. What plays is therefore what the person’s Windows already decodes; the web and Linux have no player yet and say so. |
| Android | the Android runner (android/: Kotlin, the Gradle Kotlin DSL, the Android Gradle plugin the template pins), the debug keystore |
Milestone 5.01: the application id is ai.filmopen.app (the code’s namespace stays ai.filmopen.filmopen), the label FilmOpen, the minimum SDK Flutter’s default (24), the compile and target SDK Flutter’s default too (36 — the emulator’s Android 17 is API 37, a preview, and a preview is no target), INTERNET declared for every build; the merged manifest also carries RECORD_AUDIO, which the record plugin brings for dictation and Android asks the person for at first use. flutter_deeplinking_enabled is false in the manifest (milestone 5.01, step 2): FilmOpen reads its links itself through the IncomingLinks door (§6.6), and Flutter would otherwise push every callback as an unknown route and put its URL in a Flutter error. The build-time keys reach Gradle as they reach Dart — Flutter’s dart-defines project property, decoded in app/build.gradle.kts — so the Google redirect scheme of the Android OAuth client is a manifest placeholder filled from the keys folder, and inert in a build without the client. The launcher icons are derived by scripts/make_icons.py (an adaptive icon over the dark ground). Every build of this milestone is signed by the debug keystore; a release keystore belongs in ../filmopen-keys/android/. scripts/run-android.* and scripts/build-android.* pass the keys as the Windows wrappers do |
| Build-time keys | Flutter’s --dart-define-from-file, String.fromEnvironment |
A secret a build needs — today the Google OAuth client, tomorrow Apple’s signing material — lives in one folder outside the checkout and reaches the Dart code as a compile-time constant (docs/FilmOpen-Dev-Keys.md). scripts/dev-keys.sh finds the folder (FILMOPEN_KEYS, else filmopen-keys beside the repository, so every worktree shares one), the run-/build-windows wrappers pass the flag, and the agent build’s launcher passes it too. A build without it is not broken: every value is empty and whatever needed one says so — which is the state every check, every critic and every contributor without the keys builds in, and a check pins it (DriveClientConfig.current.isConfigured is false in a test run, so a key put back into the source fails the suite). Three lines keep it out of git: the folder, .gitignore, and scripts/check-no-keys.sh, which reads what is staged and prints the file and line but never the value |
| Google Drive | googleapis_auth ^2.3.3 (BSD-3-Clause, google.dev), http, crypto (the PKCE S256 challenge of the consent address), flutter_secure_storage, shared_preferences, path_provider, url_launcher |
filmopen_drive (§6.10), the second media source. googleapis_auth exchanges the code on a desktop — on Android FilmOpen posts the exchange itself, the library sending an empty client_secret where it has none (§6.10, milestone 5.01 D24) — and the refresh is FilmOpen’s own POST on every platform; the consent address, the PKCE verifier and the loopback listener are FilmOpen’s own, because the library’s own consent flow does not ask Google for offline access and exposes no way to, and Google issues a refresh token only for access_type=offline — without which the application would stop working an hour after every sign-in (the §9.4 review); the four Drive v3 calls FilmOpen makes are plain http over whatever client it is given, so a check speaks to a MockClient and never to a socket, and the generated googleapis package (tens of megabytes for one API of hundreds) is not a dependency (D40). The tokens live in the credential store — Keystore-backed on Android, which is meant to outlast a reinstall by flutter run and a restart of the emulator, uninstalling the app forgetting it; that it really does persist is not yet confirmed, being one of the things only a person at the keyboard can check (milestone 5.01, H3 and H4); the folder ids this machine remembers live in the preferences, and neither ever reaches a project file. Since milestone 5.01 the project holds two clients: the desktop one with its secret, and an Android one with none, registered against the package and the signing certificate, whose redirect is a scheme of its own rather than the loopback (§6.10). |
| Media and thumbnails | image ^4.3.0 (MIT), crypto ^3.0.6, convert ^3.1.1, http ^1.2.2 |
filmopen_media (§6.9): image decodes and encodes a thumbnail (§6.3) and draws the stock pictures for a clip, a sound and anything else; crypto and convert measure a file’s SHA-256 as it streams into the store (§12.2: measured on ingest, never invented); http fetches the URL door’s bytes, and the client is always the caller’s, so a test speaks to a MockClient and never to a socket. |
| Issue recorder images | image ^4.3.0 (MIT) |
Pure Dart JPEG encoding of the issue recorder’s screenshots (§6.8; the owner’s answer to the plan’s §9.3: keep every shot, as JPEG, since Flutter paints only PNG or raw pixels), and packages/filmopen_recorder/bin/compare_shots.dart, which replay uses to compare a person’s picture with an agent’s. Declared by filmopen_recorder and, since milestone 5, by filmopen_media for the thumbnailer (the row above). |
| Packages | a pub workspace (Dart 3.6+) | workspace: in the root pubspec.yaml, resolution: workspace in each of the packages under packages/, one pubspec.lock at the root. A package imports only what its own pubspec declares: that is the boundary (§4.1). Every package that imports Flutter says so; the format, the store and the test support import none. |
| Plug-in engine | QuickJS-NG v0.16.2 (MIT, vendored), hooks ^2.0.0, code_assets ^1.1.0, native_toolchain_c ^0.19.0, logging ^1.3.0, ffi ^2.1.4 |
Only in filmopen_js, since milestone 5.1 (the note’s D6): QuickJS-NG’s release amalgamation and FilmOpen’s C shim, compiled by the package’s build hook (hook/build.dart, native_toolchain_c’s CBuilder) for whichever platform is built — flutter build, flutter test and dart test all run it — and bound with @Native through ffi’s UTF-8 helpers. The hook’s tooling runs at build time and ships nothing; the engine is compiled into the application. Chosen over a Flutter FFI plugin with CMake, whose library exists under neither flutter test nor dart test. |
| Plug-in hashes | crypto ^3.0.6 |
In filmopen_plugins and filmopen_plugin_host, since milestone 5.1 (the owner’s answer of 17 September, the note’s D1): the SHA-256 of each stock file the app installs into the library (plugins/.stock.json) and of a plug-in’s manifest and code for its consent (§6.11). |
| Plug-in requests | http ^1.6.0 |
In filmopen_plugin_host, since milestone 5.1: ctx.http’s one door, with the credential added there, following no redirect off the platform’s hosts (§6.11); a MockClient stands in for every platform in its tests. |
| Plug-in archives | archive ^4.3.0 (MIT) |
Only in filmopen_plugins, since milestone 5.1 (the owner’s answer of 17 September, the note’s D1): reading a plug-in’s zip for Install from file… (§6.11), in memory, every entry checked before anything is written. Pure Dart. |
| Tests of the pure packages | test |
filmopen_format, filmopen_store and filmopen_test_support depend on no Flutter at all; the first two have tests of their own, which run on dart test. |
| Agent transport | flutter_driver (in the SDK) |
Flutter’s driver extension, installed only by the agent entrypoint test_driver/agent_main.dart (§4.3) through packages/filmopen_automation, a dev dependency of the app: nothing under lib/ or in a product package imports it, and no release build reaches it. |
| Agent server | dart_mcp 0.5, stream_channel, vm_service 15 |
Only in packages/filmopen_mcp, the command-line MCP server that drives a running app over the driver (§4.3); it reads its own command line, with no argument library, and makes the driver’s VM service connection itself with vm_service, the client flutter_driver uses. The app carries no MCP code. |
| Lints | flutter_lints 6 |
flutter analyze at the root covers every package. |
| Tests | flutter_test |
Unit, fixture and widget tests; 40 at the end of milestone 1, 83 at milestone 2, 126 at milestone 3, 154 after the sign-in dialog, 191 at milestone 3.5, 279 at milestone 4, 384 at milestone 4.5, 389 with the agent layer’s two packages, 468 at MCP step 2, 475 at MCP step 3 (on Windows, where the Linux-only launcher tests pass by returning), 491 at milestone 4.6, 498 with the two merged, 556 with the issue recorder, 805 with milestone 5 (media) — the packages’ own suites and the app’s together. |
Platforms enabled: windows, web, android (milestone 5.01, a device or the emulator, driven by the agent layer over adb); linux as an agent runner (linux/ GTK embedder, launch(platform: linux) under Xvfb). iOS and macOS are not configured; the code has branches for them (paths, file reveal) that compile but have not run. Android is configured and runs (milestone 5.01) — a runner and a device, the app installed and driven — but like Linux it is not yet a product platform promised to users: there is no player there, no release signing and no store listing, and a phone’s projects live in the app’s own folder. Linux is a runner for the agent layer alone.
3.1 Commands
Section titled “3.1 Commands”flutter pub get # resolves the whole workspace: one lockfile at the rootflutter analyze # the root and every packageflutter test # the application's own tests (test/); a package's run inside itscripts/test_all.sh # every suite: dart test in the pure packages, flutter test in the others and at the root (test_all.ps1 on PowerShell)python scripts/check_catalogue.py # the model catalogue (Project Specification §14.6–§14.9) and the pages in docs/platformscd packages/filmopen_l10n && flutter gen-l10n # after editing an ARB file; commit the generated filesflutter run -d windowsflutter run -d chromeflutter run -d web-server --web-port 8642 --web-hostname localhost # headless previewflutter run -d windows -t test_driver/agent_main.dart # the agent build (§4.3): the real host, then agent.jsondart run packages/filmopen_mcp/bin/filmopen_mcp.dart serve # the MCP server over stdio; --help lists the one-shot commandsflutter build windows --release # build\windows\x64\runner\Release\filmopen.exeThe Windows toolchain needs Visual Studio Build Tools with the C++ desktop workload; flutter doctor must show Visual Studio – develop Windows apps as OK.
4. Architecture
Section titled “4. Architecture”4.1 Packages
Section titled “4.1 Packages”Since milestone 4.5 the code is a pub workspace: the packages under packages/ — all but two for the product, the other two for the agent layer (§4.3) — and the application itself at the root (lib/: main.dart, app.dart, consts.dart, the dashboard and Settings). A package imports only what its pubspec.yaml declares, and only through the other package’s barrel (lib/<name>.dart); nothing under a package’s lib/src/ is imported from outside it. Each package carries a README.md (what it is for and what it must never do) and, where it has tests of its own, a test/; what a session working in it keeps to is its row in CLAUDE.md §4. In this document a file is named by its package and basename — filmopen_format/stems.dart stands for packages/filmopen_format/lib/src/stems.dart; the application’s own files keep their lib/ paths.
| Package | Holds | Depends on |
|---|---|---|
filmopen_format |
the format as pure Dart: the filename grammar, documents, the index and resolution, epochs, kinds, character age, diagnostics codes, the manifest, short names, copy-left, the flat JSON rows, the ProjectSource interface with its in-memory source, canonical JSON, natural ordering, and the model catalogue’s platform files with its index’s platform listings |
— |
filmopen_store |
where files come from and how they are written: the folder source (dart:io behind a stub), the loader, the writer, the creator and template, Guards, the log, the library install |
format |
filmopen_test_support |
the fixtures every suite shares (loadFixture, fixtureSource, readOnlySource, sampleAssetsRoot) |
format, store |
filmopen_l10n |
one ARB per language, the generated AppLocalizations, labels.dart and field_labels.dart |
format |
filmopen_ui |
the theme, Breakpoints, the shared widgets: cards, chips, empty states, messages, the split view, the short-name field, the logo, the genre picker, the entity icons, the media image |
format, l10n |
filmopen_platform |
the operating system and the bundle: the bundled sample as a source, the on-disk copy of assets, the application’s folders, the usage folder, the file browser, and the credential store, key verifier and microphone seams with the device’s store (§6.7) | format, store |
filmopen_js |
QuickJS-NG bound for one call at a time (§6.11): the C shim, the build hook, runJs in an isolate of its own with its limits and doors; knows nothing of FilmOpen |
— |
filmopen_plugins |
what a plug-in is, as pure Dart (§6.11): the manifest and its schemas, key ids and store names, the hook vocabulary, the locale files and their namespaces, the package a plug-in is read through, and the zip it travels in | format |
filmopen_plugin_host |
the plug-in runtime (§6.11): the registry, the stock install, trust and consent, grants, machine settings and storage, ctx’s doors — the key door among them — the calls and their usage records, a transform’s versions and a render’s takes; installed by the app into filmopen_state’s PluginRuntime seam, the web getting the seam’s empty runtime |
js, plugins, state, media, platform, store, l10n, format |
filmopen_state |
Riverpod state: settings and Settings’ open tab, the open project, navigation (Selection, TreeNode, TreeBuilder), PageAccess, the constants the packages read, and the seams of §7.1 (OpenDrafts, DraftObserver, FieldRegistry, AutomationHost, RouteEvents, ExitTasks) and §4.3 (the semantics walk) |
format, store, platform, l10n |
filmopen_media |
one class for a piece of media wherever it is stored (§6.9): the MediaStore interface and its registry, the local store, ProjectMedia, the importer that names an upload as a take, the thumbnailer and the stock pictures, the cache |
format, store |
filmopen_media_ui |
what media looks like (§9.3): MediaSourceUi and its registry — the one door a source’s own user interface comes in by — the media-root picker of New project, the thumbnail tile and strip, the upload sheet and its doors, and the viewer |
media, store, ui, l10n, platform, format |
filmopen_drive |
Google Drive as a media root (§6.10): DriveMediaStore, DriveAuth and the credential store, the four Drive v3 calls, the cache a fetched file lands in, and DriveSourceUi — the panel New project shows for it. The whole of what the application knows about Drive: nothing else names it but one registration in lib/main.dart |
format, store, media, media_ui, l10n |
filmopen_dictation |
dictation (§9.6): the microphone at a box’s right and its menu, the popup, the session controller with its usage chain, OpenAI’s live transcription and its setup from the bundled catalogue | format, store, platform, state, ui, l10n |
filmopen_keys |
Settings → Provider keys (§6.7, §9.4): a section per owner of keys and a card per key slot, each key’s state, Save and Remove, and HttpKeyVerifier; Settings → Usage (§6.7, §9.4); only the app imports it |
format, store, platform, state, ui, l10n |
filmopen_plugins_ui |
Settings → Plugins (§9.4): the list, the consent screen, a plug-in’s page from its manifest — its machine settings drawn by the forms’ plug-in field widgets, as a card on a page is — Install from file… and the preferred providers, all through filmopen_state’s PluginRuntime; only the app imports it |
format, state, ui, l10n, forms |
filmopen_recorder |
the issue recorder (§6.8): Recorder, TapeWriter, snapshotSource, ReportFolder (_io/_stub), captureWindowJpeg; bin/compare_shots.dart for replay |
format, store, platform, state |
filmopen_account |
the filmopen.ai account: sign-in, the profile, the callback registration, the account card and the sign-in dialog | format, store, state, ui, l10n, dictation |
filmopen_tree |
the tree view, the tree pane with its project menu, the new-project dialog, Copy to a folder… | format, store, state, ui, l10n, dictation |
filmopen_forms |
one form per type in two modes, the autosave, the per-type tabs, the reference section, the JSON editor, the file menu, the write actions, GuardLine |
format, store, platform, state, media, media_ui, ui, l10n, dictation |
filmopen_compare |
two columns and copy-left | forms and what it depends on |
filmopen_browser |
the pages: the browser page, the entity page and its tabs, the overviews, the categories, the takes, the commentary, the script | tree, forms, compare and what they depend on |
| the app (root) | main.dart (the log, the licence, preferences, the credential store and the key verifier, the microphone, the transcriber and the usage log, the media sources, the container and the host’s attachment), app.dart (MaterialApp, the root and window boundaries), consts.dart (the version, the specification URL), screens/dashboard.dart (the rail, whose ? opens the support dialog and stops a recording), screens/support_dialog.dart (Get Support and About), screens/settings_page.dart (the tab bar over General, Provider keys and Usage), the assets, the runners, and the tests that pump the whole app |
every package but filmopen_mcp (filmopen_recorder among them) |
filmopen_automation (the agent half, §4.3) |
the driver extension’s install and the data channel the MCP server speaks to; from step 2, the real AutomationHost; from the issue recorder, its Recording doors |
recorder, state and below (a dev dependency of the app; no product package imports it) |
filmopen_mcp (§4.3) |
the MCP server, a command-line program | nothing of the app: flutter_driver, dart_mcp, vm_service |
The dependency graph. Arrows point downward only, and a package can import nothing its pubspec does not name:
app → every package but filmopen_mcpfilmopen_browser → tree, forms, compare (and everything they reach)filmopen_compare → formsfilmopen_tree, filmopen_forms, filmopen_account → dictation, state, ui, l10n, store, format (forms → platform, media and media_ui as well)filmopen_dictation → state, ui, l10n, platform, store, formatfilmopen_keys → state, ui, l10n, platform, store, formatfilmopen_recorder → state, platform, store, formatfilmopen_state → platform, l10n, store, formatfilmopen_platform → store, formatfilmopen_plugins → formatfilmopen_js → nothing of the appfilmopen_plugin_host → js, plugins, state, media, platform, store, l10n, format (only the app imports it)filmopen_ui → l10n, formatfilmopen_l10n → formatfilmopen_store → formatfilmopen_test_support → store, format (a dev dependency of the store, the state, the forms, the recorder and the app)filmopen_automation → recorder, state, store, format (a dev dependency of the app; no product package imports it)filmopen_mcp → no package of the app (filmopen_automation as a dev dependency, for one test)Forbidden, and impossible once the pubspecs say so: a lower package importing a higher one; filmopen_format, filmopen_store or filmopen_test_support importing Flutter or a plugin; filmopen_state importing a widget package (the tree model names its icons as a TreeIcon and the tree view chooses the glyph); a product package importing the agent’s own packages (filmopen_automation, filmopen_mcp); dart:io anywhere but the _io half of a file that is in threes — the store, the platform, the user interface, the account, the recorder, and the media packages (filmopen_media/local_file_io, filmopen_media_ui/player/media_player_io, filmopen_drive/drive_cache_io and drive_sign_in_io, and no other half of any of the threes — the isolate half takes dart:isolate and the pickers take file_selector) — and never on a path the web build reaches — and two files that never reach the web build, filmopen_test_support/sample_on_disk.dart (a test’s way to the sample) and filmopen_recorder/bin/compare_shots.dart (a command-line tool of replay’s, never bundled into any build). Platform code comes in threes — x.dart (a conditional export), x_io.dart, x_stub.dart — so the same call compiles on the web and answers “not available” there. filmopen_l10n/labels.dart is the single bridge from the format’s codes to human text.
What the split buys: an agent sent to Compare loads that package, the barrels of filmopen_forms and filmopen_format and Compare’s row in CLAUDE.md §4 — not the writer, the account or the tree; two agents in two packages touch different files and different pubspecs, and their one shared file is the ARB pair.
4.2 Data flow
Section titled “4.2 Data flow”project folder ──┐media root ─────┤ three ProjectSources (folder / assets / memory), each its own sandboxshared library ──┘ listPaths(), readText(), locate(), diskPath(), related() ▼ProjectWriter ◄── (save / fork / official / pick: writeText, deleteFile on the project's source, then a re-read) ▲ProjectLoader ──► FilmProject (manifest, media root, library; index: entities by stem (with origin), │ groups by type+tag, epochs, versions, official pointers, commentary, batches, │ takes (with media location), other files, warnings, project candidates, │ selected project) ▼projectProvider (AsyncNotifier) ◄── libraryProvider (FutureProvider: the library folder, seeded on first run) │ ├─► treeModelProvider ──► TreeBuilder(project, l10n, viewer) ──► TreeModel(roots, index by selection key) │ │ ▼ ▼browserProvider (BrowserState: selection, highlighted node, toggled rows) ◄── TreePane clicks / DetailPane links │ ▼DetailPane: switch on Selection → ProjectOverview | ProjectChooser | CategoryView | EntityDetail | BatchDetail | OtherFilesViewEverything is derived: change the project, the viewer handle or the language, and the tree and pane rebuild from the index. Nothing is cached beyond Riverpod’s own memoisation. A write is a file write followed by refresh(): the new index replaces the old, and because the browser resets its navigation only when the source changes (projectSourceKeyProvider), the page being edited stays where it is. main builds the ProviderContainer itself and hands it to the AutomationHost in use (§7.1) before runApp.
4.3 The agent layer — packages/filmopen_automation, packages/filmopen_mcp, test_driver/agent_main.dart
Section titled “4.3 The agent layer — packages/filmopen_automation, packages/filmopen_mcp, test_driver/agent_main.dart”The agent layer lets a coding agent, a critic or later a support agent drive a running FilmOpen through named operations instead of the desktop (docs/FilmOpen-MCP-Plan.md). Step 1, view only, step 2, control, and step 3, the headless Linux runner, are in place (docs/FilmOpen-MCP Results.md §1–§10): the data channel’s protocol, the host that answers it, and the MCP server’s tools, proved on Windows by the acceptance walk and the release check, and on a headless Linux box under Xvfb. The layer is two packages in the workspace beside the product’s, and one entrypoint outside lib/.
packages/filmopen_automationis the in-app half.installAgentExtension()installs Flutter’s driver extension withhandleAgentCommand(protocol.dart) as its data channel. A message is the literalpingor a JSON object naming anopand its arguments:state,navigate(a selection as{"kind", "type", "tag", "epoch", "stem"}),open_project,set_tab,set_field,tap(by identifier, or by label through a host’sSemanticsTaps),tap_at(a diagnostic, never a step of a recorded sequence),find,semantics,screenshot,log_tail,set_viewer,set_locale,set_theme,record_start,record_stop,record_state(answered by a host withRecording, the issue recorder’s doors, §6.8:refusedrecordingwhen one already runs,busywhile one is starting or stopping,idleonrecord_stopwhen none runs,failedrecorderwhen it cannot start or its folder cannot be completed — a host withoutRecordinganswersnot_available),quit(answered by a host withAppExit, which writes the open drafts, puts back the viewer, language, theme and development mode the session started with, waits for a recording still starting and stops one that is running, and exits once the answer has left — or refuses withrefused/draftswhile a draft cannot be written). The answer is{"ok": true, "result": …}or{"ok": false, "code": …, "detail": …}: the code one ofunknown_op,bad_args,not_available,not_found,refused,intercepted,timeout,failed, and the detail a code, a stem or an identifier — never text a person wrote. Typed text and a tapped label are never repeated, an unexpected failure travels as its type, andlog_tailcarries each event’s time, level, name and data but never an error’s message or stack, nor the data of the account’s events. Arguments are checked before the host hears of them; what no host is asked (a ping, a message that is not an op, an unknown op) is answered at once. The ops a host answers run one at a time, none starting before the one ahead of it has ended, and each is answered within 30 seconds of arriving —timeoutwhen it cannot be, withqueued(it never runs) orrunning(it may still land, and the log says when). Every op goes toAutomationHost.current: the product’s no-op host answersnot_available, and the agent build installsDriverAutomationHost(driver_host.dart). That host:- reads the providers for
state: the project, the browser, the rail’s page, the settings,FieldRegistry.stems, the view’s size and pixel ratio; - navigates through
navIndexProviderandbrowserProvider.navigate, refusing a target the project does not hold; - opens a project, and sets the viewer, the language and the theme, through the project’s controller and Settings’;
- types through
FieldRegistry; - finds, taps and describes the page through the semantics tree it asks the binding to keep (
filmopen_state/semantics_walk.dart, which the issue recorder shares). Its taps — by identifier, by label, and a tab — go through the platform dispatcher’s semantics-action callback rather than straight on the semantics owner, the door a screen reader’s own request takes, so a recording started withrecord_starthears an agent’s taps too (§6.8, §7.1). A tap lands on the node a click would reach, and a disabled control isrefused. A tab is tapped through the tab bar’s own gate: it answersrefusedwith the code of its tooltip, orinterceptedby Compare’s fork question.
- reads the providers for
The semantics data gives rects in logical pixels. It withholds a text field’s value outside the project’s own identifiers (field:, epochs., new-project., new-entity., settings.handle, tree.filter, dictation., and since milestone 5.1 plugin:, a plug-in’s settings on this computer), and masks e-mail addresses in every text — as state does in the handle, the project’s name and its path. screenshot paints AutomationHost.windowKey’s boundary at the view’s pixel ratio — the whole view, every route, dialog and menu included (the owner’s answer to the issue recorder plan’s §9.7, which overturns that plan’s own D7) — falling back to rootKey only where no window boundary is built. log_tail reads RingLogSink: the log’s last 500 lines, without an error’s message or stack. An op waits for its frame and the animations it started, up to 5 seconds, never for a file. The package is a dev dependency of the app, imports filmopen_recorder, filmopen_state, filmopen_store and filmopen_format and nothing above them, and no product package ever imports it.
test_driver/agent_main.dartis the only place the extension is installed. Before any binding exists it installsDriverAutomationHostand then the extension, and it runs the ordinarymain, which attaches the application’s container to the installed host. It then writesagent.jsoninto the log folder (§6.4): the VM service URL (dart:developer’sService.getInfo()), the process id and the start time. The file is written beside itself and renamed over it, at once and again if the URL changes within the first minute — a write that fails is tried again on the next tick — so a server can find a running agent build without being told;quitremoves it while it still names the process. The URL carries the service’s token, so the log names only the file.lib/main.dartdoes not know the file exists, and a release build has no VM service, no handler and no listener. This is Flutter’s own shape for the driver extension (a target outsidelib/), so there is no dart-define and no branch inmain.packages/filmopen_mcpis the MCP server: a Dart command-line program on the Dart team’sdart_mcpthat a client spawns (claude mcp add filmopen -- dart run …/bin/filmopen_mcp.dart serve). Nothing in the app imports it.- Tools. It offers one tool per op of the data channel:
state,navigate,open_project,set_tab,set_field,tap,tap_at,find,semantics,page_shot(the opscreenshot),log_tail,set_viewer,set_locale,set_theme,record_start,record_stop,record_state,quitandping. Beside them arescreenshot(the whole view as Flutter rendered it, every route and dialog included, but not the native window frame or a native dialog),wait_idle,enter_text(the driver’s own text entry into the box that has the focus, for the dialogs’ boxes, Settings’ handle and a provider key’s box, which are not aFieldRegistryform, and into a key’s box only a visibly fake value, since the text passes through the client’s transcript;bad_args/textwithout a text, and nothing tells whether a box was focused),launchandstop. - Answers. An op tool sends its op with the call’s arguments, which the app checks. It answers the result as JSON text, or a picture as image content, and the app’s refusal as an error result carrying its code. A failure on the link answers
no_connectionwith the error’s type only, since an error can quote the service’s URL and its token. The link prints nothing of its own, and the lines of a failed launch travel with every token cut out.screenshotwrites its PNG to apathonly as a new.pnginside the server’s working directory, or over one it wrote itself (bad_args/pathotherwise): a client must not overwrite a file through a picture. A picture shows what the screen shows, where the semantics data masks an e-mail address. - The link. The server keeps one link (
AppLink, over Flutter’s driver), reuses it, drops it after a failure and replaces it for another URL. It runs its tool calls one at a time, so two never race to open the link, or to launch and stop the app. Every wait is bounded and nothing is left open, sinceFlutterDriver.connectnever gives up and cannot be stopped: the link opens the VM service’s WebSocket itself, with an HTTP client it closes by force when the time runs out (10 seconds), and buildsvm_service’s client on it; finds the isolate that carries the driver extension (ext.flutter.driver, asked again until the same 10 seconds have passed, an isolate paused at its start resumed as the driver resumes it); closes the connection when either step fails; and only then hands it to the driver (VMServiceFlutterDriver.connectedTo), whose requests, window shots and idle waits each get 45 seconds. A request past its time drops the link. When the client goes, the calls still queued are answeredshutting_downwithout running, an app the server launched is stopped, and the call under way is given 45 seconds. Nothing answering isServiceUnreachable, and a service without the extension isNotAnAgentBuild(not_agent_build), even where the time runs out while one of its isolates is asked about; neither names a URL. - Which app. The app is the one a call names (
vmServiceUrl, anhttpURL on the loopback; anything else isbad_args); else the onelaunchstarted, while it runs, forgotten with its link once itsflutter runhas ended by itself; else the one named at start (--vm-service-url, orVM_SERVICE_URL, which the executable reads once and refuses unless it is on the loopback); else the one the open link reaches; else the agent build thatagent.jsonannounces in the log folder (§6.4) — anhttpURL on the loopback — while its service answers. The answering service is the proof, since a process id is handed to another program once its process ends. The server computes that folder from the environment as the app does, and reads the file only when no link is open. - Launch and stop.
launchrunsflutter run -d windows|linux|<an android device's serial> -t test_driver/agent_main.dartfrom the repository. On Linux it issetsid xvfb-run -a -s "-screen 0 <size>x24". On android (milestone 5.01, step 4) thedeviceargument is required and stands where the platform’s name would — a machine may have a phone and an emulator attached at once — whilesizeandhomeare refused rather than ignored, there being no screen to make and no folders to give:state.viewreports what the device has. A launch on a Linux host is put in a process group of its own whatever the target, so the last-resort kill takes the dart tool and itsadbchildren with it. Android writes noagent.jsona server could read (it is inside the app’s sandbox), so a launched android app is known fromflutter run‘s output alone and a command in another shell is given--vm-service-url.sizeis<width>x<height>within 320–4096 (1280x720when omitted): the Xvfb screen and the window on Linux,FILMOPEN_WINDOWon both runners — the view’s size on both, since the Windows runner adds the frame the origin’s monitor gives a window (AdjustWindowRectExForDpi) and leaves the window 1280×720 outer, as it always was, when the variable is unset.homeon Linux is a folder whoseshare/state/cachebecome the app’s XDG directories; omitted, a fresh folder under the temp directory is made and removed onstop, so a launch never reads the person’s~/.local/share/filmopen. It reads the service lineflutter runprints, connects, waits for an idle frame, appliesproject,themeandlocale, and answers the state; a refusal after the app started carrieslaunched: true, andstopstill ends it. A launched app that ends by itself is forgotten with its link, and what its launch left on disk — a Linux launch’s own folder — is removed as it is forgotten. Aflutter runthat ends or runs out of time before its app answers islaunch_failedwithexitedortimeout, its exit code and its last 40 lines, every service URL’s token and every e-mail address cut out.stopends an app the server launched with the app’s ownquit, over a link opened for it when none is, so the open drafts are written and the settings put back, lets it end by itself (up to 30 seconds), and only then sendsqto itsflutter run(by: quit); an app that cannot answerquit, or has none, is ended withqalone (by: q); aflutter runstill running 30 seconds later is ended with its whole process tree (by: kill) — on Linuxkill -TERM -- -<pid>of thesetsidgroup, then-KILL. A quit still writing a draft when its time runs out (timeout/running) is waited for, never cut short. Any other app — and one a call names while a launched app runs — it ends only through the app’s own channel:quit, then a wait of up to 30 seconds for the service to stop answering. Any other refusal of the quit, a draft the app cannot write among them, is passed on, and the app stays. It never ends a process it did not start. On android the app ends its own process oncequithas written the drafts, put the session’s settings back and answered: Flutter’sexitApplicationis the desktop embedders’ door and does nothing there, so the activity would go to the background, the process would live on andflutter runwould wait for an app that never ended (milestone 5.01, D25). That line istest_driver/agent_main.dart’s alone — the agent layer’s own entrypoint, which no product build reaches — and it is what lets astopfrom a shell the server knows nothing about end an app at all. An app the server launched stops when the client goes — asked over the open link toquitfirst, a few seconds at most, so that its drafts and settings are handled — even one whoseflutter runhas not started yet. - The command line. The executable’s other commands (
state,navigate,tap,set-field,enter-text,find,log-tail,page-shot,record-start,record-stop,record-state,stop,quit, and more) do the same, once each, from a shell, andlaunchrunsflutter runin the foreground — on Linux with XDG folders of its own, named on stderr for the commands that follow (XDG_STATE_HOME); a bad--sizeis a usage error;stopasks the build to quit through its channel and waits for it to end.launch android --device <serial>(milestone 5.01, step 4) runs it on a device: the serial stands where the platform’s name would,--sizeand--homeare usage errors there, and the commands that follow take--vm-service-urlfromflutter run’s own line, an android app’sagent.jsonbeing inside its sandbox.scripts/agent_walk.pyruns the acceptance walk of the MCP Specification §7 through the server as a client, on either platform. The executable’s own options (--vm-service-url,--repo,--help) stand anywhere before a--, and every other word is the command’s, sotap --labeland a negative number reach it. A command exits 1 when the app refuses or cannot be reached, with a sentence, never an error’s text, and its process ends as soon as it has answered. A URL that is not on the loopback is refused (exit 64).
- Tools. It offers one tool per op of the data channel:
Rules: the app never imports the MCP library; nothing under lib/ and no product package imports filmopen_automation; the agent layer reaches the app only through the doors the UI uses — browserProvider.navigate, FieldRegistry and the forms’ own setters, the writer through WriteActions, copy_left.dart — never a second write path. Step 2 (control) attaches to §7.1’s seams and adds no other.
5. The model layer
Section titled “5. The model layer”5.1 Filename grammar — filmopen_format/stems.dart
Section titled “5.1 Filename grammar — filmopen_format/stems.dart”FileNameParser.parse(path) implements Project Specification §5.4 and returns one of a sealed set:
| Class | Filename shape | Holds |
|---|---|---|
VersionStem |
<type>_<tag>[_<epoch>]_<author>_v<n> |
type, tag, epoch?, Author, v; stem, versionsKey (ch_main-hero_30yo), groupKey (ch_main-hero) |
OfficialStem |
<type>_<tag>[_<epoch>]_official |
type, tag, epoch?; versionsKey |
CommentaryStem |
cm_<type>_<tag>_<author> |
target type, tag, author |
BatchStem |
rd_<id>_<author> |
id, author |
TakeName |
<deliverable>_<source stem>_r<id>_<renderer>_<n>.<ext> |
deliverable, source VersionStem, batch id, renderer, n, extension; batchStem |
UnknownName |
anything else | the path |
Rules enforced by the parser: exact token counts per type (library types carry an epoch, spec types do not); v<n> with n ≥ 1; take number n is a positive integer without leading zeros; the reserved owner official is never accepted as an author; a take’s batch token is r followed by at least one character. parseStem(stem) parses a stem without extension, as found in forkedFrom and batch source fields.
namingWarnings(parsed) returns warnings (as codes) for any segment outside [a-z0-9][a-z0-9-]{0,19} or longer than ten characters (§5.2). It is applied to every parsed name, including takes and commentary.
Author (filmopen_format/author.dart) splits owner[.workspace]; equality is by full handle.
EntityType (filmopen_format/entity_type.dart) is the enum of the nineteen prefixes with category (library / spec / commentary / renderBatch), hasEpoch, isVersioned, and the dotted-tag containment rules parentType / containedType (outfits nest under characters; areas under locations). Deliverable is the enum of take prefixes with isImage. Neither enum carries display text.
5.2 Documents — filmopen_format/entity.dart
Section titled “5.2 Documents — filmopen_format/entity.dart”Entity: one versioned file. Holds theVersionStem, the project-relativepath, the completejsonmap (unknown fields preserved) andrawText, the file’s text as read, which a save hands back to the source so a file changed meanwhile is never overwritten. Typed accessors (name,versionLabel— §7, with the earlierlabelspelling still read,forkedFrom,notes,kind,stringList(key),blocks) never throw on a wrong JSON type.headerMismatchesandfieldTypeWarningsreturn warning codes.OfficialPointer: from an_official.json;fromJsonreturns null when theofficialfield is missing, which the loader reports. Keeps its wholejsonso the writer can rewrite it without losing fields.Commentary/CommentaryEntry: onecm_file withpickandpickedAt(preferis the 1.5 spelling, still read) — the author’s pick — and entries (on,at,rating±1,text,block,field); keeps its wholejsonfor the same reason.RenderBatch/RenderItem: onerd_file with its items; totals cost.Take: a media file discovered by name; never listed inside an entity (§12.1).
Two helpers, stringField(json, key) and numField(json, key), are the only way JSON scalars are read. A third, parseInstant(text), is the only way a timestamp is read as an instant: RFC 3339 with Z, z or an offset (§1.4); a value without a zone would be local time on whichever machine reads it, so it counts as absent, like anything that does not parse.
5.3 The index — filmopen_format/project.dart
Section titled “5.3 The index — filmopen_format/project.dart”FilmProject is immutable after loading and holds:
| Field | Content |
|---|---|
entitiesByStem |
every versioned entity by full stem |
groupsByKey |
EntityGroup per type+tag; each group holds EntityVersions per epoch (spec types use the single epoch null); versions are sorted by author handle then number |
officialsByKey |
pointers by versions key |
commentaries, batches, takes |
as discovered |
companionFiles |
named by the grammar but not JSON (a plugin’s .js beside its manifest) |
otherFiles |
not named by the grammar; reported, never touched |
warnings |
every ProjectWarning raised while loading |
projectCandidates |
every pj version in the folder, stable order |
selectedProject |
the pj version in use, or null when the user must choose (needsProjectChoice) |
projectOfRecord |
the loader’s own §4.1 choice — the official pj, else the sole author’s — which a viewer’s pick never replaces; directors are read from it |
Queries: group(type, tag), groupsOf(type) in natural tag order, childGroups(type, parentTag) for dotted tags one level down, officialPointer / officialEntity / isOfficial, takesFor(group), commentaryFor(group), batch(stem), groupCounts. withSelectedProject(entity) answers the chooser without reloading.
5.3a Project-level knowledge — filmopen_format/project_kind.dart, filmopen_format/epochs.dart
Section titled “5.3a Project-level knowledge — filmopen_format/project_kind.dart, filmopen_format/epochs.dart”ProjectKind is the enum of §8.1 (short-film, film, mini-series, series) with older spellings read as their nearest value (fromName: 1.4’s franchise, and the short-lived short / feature / miniseries), isEpisodic (seasons and episodes rather than installments and acts, §10.1) and defaultRootKey (the story-root list a blank project of that kind starts with, §8.5). recommendedGenres is the vocabulary of Appendix A.9. EpochDeclaration / declaredEpochs(json) read the project’s epochs object in the order §8.2 defines (unnumbered first in file order, then by order); encodeEpochs(list, previous:) writes it back renumbered 1..n, keeping fields the app does not know. FilmProject exposes kind, declaredEpochList, defaultEpoch (the first declared, else default), epochLabel, sortedEpochs(...) (declared order, then natural), isDirector(author) (§8.3: read from projectOfRecord, never from the viewer’s own fork of the project file, which forViewer selects; where the folder has no file of record, the origin of the selected file decides — followed back through forkedFrom, whoever wrote each link, to the project file that forks nothing — so no fork appoints anybody and the original’s author can write a first pointer; a loop or a source the folder lacks decides nothing. ProjectWriter.saveEntity keeps a project file’s forkedFrom as it is, so the chain cannot be cut through the app), commentaryBy, ownPickStem, pickOf(group, viewer) and isPickOf.
5.4 Resolution — FilmProject.resolve
Section titled “5.4 Resolution — FilmProject.resolve”Implements §6.1 in a simplified but faithful form. Given a type, a reference (tag or full stem), an optional epoch, whether that epoch was explicit, and an optional viewer:
- A full stem names its file exactly.
- No such tag → unresolved (
noEntity). - Epoch: for library types the wanted epoch is the given one or the project’s default epoch (the first it declares, §8.2;
defaultwhen it declares none). If there is no file for it: an explicit epoch, or the default itself, is unresolved (noEpoch); an implicit epoch falls back to the default and marksfellBack; no file at the default either → unresolved (noEpochOrDefault). - Author — the viewer’s pick at that epoch (
FilmProject.pickAt, §6.1 step 2): the viewer’spickwhen it names a file here (a pinned stem, or the official pointer when it is<versions key>_official), then — for a workspace handle — their owner’s; then the official version; then the viewer’s own highest version, then their owner’s, which is what an author sees while the project has chosen nothing; then, if exactly one owner has versions, that owner’s highest. A pick beats official and nothing else does: an author who has said nothing is on official, and forking records a pick, so an author’s own work is not lost behind the project’s choice (§13.1, §13.4). The project’s ownpjversions skip the own-highest step and count author handles rather than owners in the last one, so the check and the loaded project agree (§4.1).pickAtreturns aPick(entity,PickReason: picked, tracksOfficial, official, own, soleOwner, andstaleSince) and is the one source the tree, the header and the version menu read. Compare is the exception: its columns are the viewer’s own latest and whatever they selected, so it readspickAtonly to draw the check on a heading.Pick.staleSincenames the official pointer that superseded the viewer’s own pick —setAtstrictly afterpickedAt, both read withparseInstantand compared to the second, so a fraction another tool wrote does not make equal seconds later (§1.4, §13.2). It is null whenever either time is missing, unreadable or without a zone, when the seconds are equal, when the pick names the official version, when the pick tracks official, and for a pick a workspace inherits from its owner, which the workspace cannot abandon. It never changes whatpickAtreturns: no timestamp decides (§7).Pick.showsCheckis what a check is drawn from — false forPickReason.official, since the star already says official is in use (§13.4). - Otherwise unresolved (
ambiguous, listing the owners);hasNoPicknames that state for the UI.
§6.1’s other half — a file named by an official pointer resolves through official only — is resolve(..., referencing:), with inUseFor(versions, viewer, referencing:) behind it: for a file an official pointer names, officialPickAt(versions) — official, else the sole owner’s highest (by handle for the project file, as in pickAt), and nothing an author picked; for anything else, pickAt. nameOf takes the same referencing. It is passed where a version’s own references are rendered (today the Script tab’s speakers) and not where the reader is browsing: the tree, the version row and the pages keep showing each viewer the version in use for them, so a fork is still what its author sees. A proposal sees its author’s world; official sees the official world.
5.5 Project selection — ProjectLoader._selectProject
Section titled “5.5 Project selection — ProjectLoader._selectProject”Implements §4.1: a valid official pj pointer wins; else the highest version when exactly one author handle has project files — handles, not owners as §6.1 step 3 counts them, so a workspace’s translated fork of the project file asks rather than being opened as the owner’s latest; else no selection and the UI asks. Several project tags in one folder also ask. An official pointer to a missing file is a warning and falls through as if absent; one naming a version of another entity or epoch is reported (officialWrongTarget) and dropped, so it is never anybody’s official version. The choice is kept twice: as selectedProject and as projectOfRecord, which nothing replaces. FilmProject.forViewer(viewer) then applies the viewer’s own pick for the project entity (their pick in cm_pj_<tag>_<viewer>.json, §4.1): the provider calls it after every load, so a viewer who forked the project file works from their fork while everyone else keeps the official one.
5.6 Diagnostics — filmopen_format/diagnostics.dart
Section titled “5.6 Diagnostics — filmopen_format/diagnostics.dart”ProjectWarning(kind, args, path) with WarningKind (naming, duplicate stem, header missing or mismatched, field type, official pointer problems, unreadable JSON — including duplicateKey, which carries the repeated name so the sentence can be in any language (§18.1) — project selection, since milestone 2 the manifest and library kinds: noManifest, manifestTagMismatch, dataLocatorMalformed, dataLocatorUnsupported, dataRootUnavailable, dataRootAbsolute, libraryStemShadowed, libraryUnavailable, since milestone 3 noEpochsDeclared, §8.2, and since milestone 4.5 pickOtherEntity — a pick naming an existing file of another entity — officialWrongTarget and timestampUnreadable, a pickedAt or setAt that is not an instant with a zone). A second commentary file with the same stem in another folder is a duplicateStem, and the first found is the one kept and written. ResolutionProblem(kind, type, reference, epoch, owners) with ProblemKind (five kinds). Both have a toString() for logs only; the UI renders them through the localisation layer.
5.7 The manifest — filmopen_format/manifest.dart
Section titled “5.7 The manifest — filmopen_format/manifest.dart”filmopen-project.json (Project Specification §4.4) is the one file in a project that is not somebody’s versioned work. ProjectManifest.fromJson reads tag, data and note tolerantly (a missing or mistyped field is null and the loader warns). DataLocator is the typed “where”: LocatorType (local, dropbox, google-drive, onedrive, icloud, url) or an unknown name kept as text; isLocal, isAbsolutePath (POSIX, drive-letter and UNC forms). ProjectManifest.template is what the creator writes. projectManifestFileName is the one constant that names the file.
5.8 Short names — filmopen_format/short_name.dart
Section titled “5.8 Short names — filmopen_format/short_name.dart”The format’s segment (§5.2) as a person types it: ShortName.pattern ([a-z0-9][a-z0-9-]{0,19}), validate (a ShortNameProblem: empty, invalid characters, bad start, too long), validateHandle (an author handle, §5.6: owner[.workspace], each side a segment — more dots is tooManyDots — and the owner never ShortName.reservedOwner, official, which is reserved; the filename grammar’s reservedOwner is the same constant), isLongerThanRecommended (over ten: warn, never refuse), normalise (free text to the nearest segment, never inventing one). The filename grammar in stems.dart uses the same pattern, so there is exactly one place the rule lives; filmopen_ui/short_name_field.dart is the input box built on it.
5.9 Origin — Entity.origin, Take.media
Section titled “5.9 Origin — Entity.origin, Take.media”EntityOrigin { project, library } says which root a versioned file came from; EntityGroup.isFromLibrary is true when every version of an entity came from the library. Take.media is the MediaLocation resolved at load time, because a take may sit in the media root rather than the project folder: AssetMedia(key) for the bundled sample, FileMedia(path) for a folder on disk and BytesMedia(bytes) for a session copy’s own media, which is in memory and never on disk.
5.10 Media names, references and paths — filmopen_format/media_names.dart, media_refs.dart, media_paths.dart
Section titled “5.10 Media names, references and paths — filmopen_format/media_names.dart, media_refs.dart, media_paths.dart”What a piece of media is, what an application calls an upload, and where a hand-supplied reference goes — one place, because the importer that writes the file, the page that looks for its thumbnail and the filename grammar that must parse what was written all depend on the answers agreeing.
MediaKind { image, video, audio, other } and mediaKindOf(name) read the lower-cased extension against mediaExtensions (Project Specification §6.3, the plan’s D6); anything else is other, which the importer refuses. uploadPrefixFor(kind, type) is the one place the owner’s rule lives — a prefix names a file’s purpose, never its origin (§5.5): a picture is a cs take, a clip a cl take, a character’s audio its vo voice sample, and any other sound an am take, an outfit included, since an outfit has no voice (§9.0). uploadTakesFor names the files of one upload action, numbering from 1 per purpose and leaving a file that is not media without a name; nextBatchId(at, taken:) is eight lower-case hex digits of the second the import began, and takes the next free one when that author already has it — the clock proposes, the check decides (§5.2, §12.3). thumbnailPathFor(ref) answers thumbnails/tn_<take stem>.jpg, relative to the project folder, for a reference that is a take — by its stem or by its file name — and null for anything else. Only a take’s name is unique in a project by design (§5.4: the batch id and the take number are in it), and it stays unique whatever folders stand before it; stills/front.png and posters/front.png would claim one tn_front.jpg between them, so a reference whose basename is not a take’s has no thumbnail of its own in this version and the tile draws the kind’s stock picture. The grammar says where a name ends, never the last dot, since a workspace handle (john123.locale-th) and a dotted tag (sh_5.2) carry one. isInReservedFolder(path) says which files a reader understands by the folder they are in (§4.2) — render/, thumbnails/, conform/, plugins/ (milestone 5.1: a plug-in’s locale files, README and assets; its manifest and code are still read by their names), and never _inbox/.
media_refs.dart holds §9.0’s rule for where a reference a person hands over goes: withReferenceAdded(json, type:, kind:, reference:) copies the document, puts a picture in the plain array when refs is one and in images when it is grouped, a clip in motion, a sound in sound — or, on a character, in voice.refs — turning a plain array into {images: […]} the first time another kind arrives, and setting preview to the first picture it adds when the file has none and never over one the author set. It adds only: no reference is removed, none is repeated, and a value of another shape is left for the JSON editor (§17). referencesOf(json) lists what a file names, in file order without repeats — what the reference section shows.
Where a media folder sits is media_paths.dart (D34, D35), and it is one
place for the same reason the names are: the New project dialog proposes a
folder, the creator makes it, the manifest records it and the loader resolves
it, and all four have to agree about what ../film-data means. splitPath and
joinPath take a path apart on either platform’s separators; isAbsolutePath
knows a drive letter, a UNC share and a leading slash; mediaFolderResolvedFor
answers where a project’s media root really is, and mediaLocatorPathFor what
the manifest should record for a folder a person chose — relative where it
sits under or beside the project folder and absolute where it does not;
isPortableMediaFolder is the question the dialog asks so that it can say, while
the person can still change it, that an absolute path is one only this machine
can follow. Every one of them takes required bool windows rather than reading
the machine it runs on: a dialog on one platform never types another’s paths, and
a check on either platform can ask about both.
6. Services: where files come from
Section titled “6. Services: where files come from”ProjectSource (filmopen_format/project_source.dart) is the abstraction the loader reads through. One source is one root; a project’s media root (§4.4) and the shared library (§14.5) are sources of their own:
String get label; // folder name, or a neutral labelString? get rootPath; // absolute path when it is a folderFuture<List<String>> listPaths(); // root-relative, '/'-separated, recursiveFuture<String> readText(String path);MediaLocation? locate(String path); // AssetMedia(key) | FileMedia(path) | nullString? diskPath(String path); // absolute path on this machine, for the file browser; null if not on diskFuture<ProjectSource?> related(String relativeFolder); // the folder a manifest names: '../x-data', 'media'; null if absentbool get canWrite; // false for bundled assetsFuture<void> writeText(String relativePath, String text, {String? expectedText, bool create}); // create or replace, through the same sandbox check; with expectedText the // file must still hold exactly that text, with create it must not exist — // otherwise StaleFileException and nothing is writtenFuture<void> deleteFile(String relativePath, {String? expectedText}); // absent is not an errorFuture<Uint8List> readBytes(String relativePath); // a picture, a clip, a thumbnail, wholeFuture<int?> fileLength(String relativePath); // null when there is no such fileFuture<void> writeBytes(String relativePath, Stream<List<int>> bytes, {int? length, bool create}); // the media half, streamed rather than held: same sandbox, same // create refusal, so a take that exists is never replaced (§18.3)Writes to a folder run one after another, the check against expectedText happens right before the replace, and the text goes to a sibling temporary file first (flushed, then renamed over the target), so a crash never leaves half a JSON file and two saves cannot interleave. Bytes take the same road: writeBytes streams into a sibling temporary file and renames it, so a media root never holds half a picture and a clip larger than memory copies through; create is checked once before the copy and again with the bytes in hand, and a refusal leaves no temporary file behind. Binary I/O is the whole of what a media store needs of a folder. Every document is read through filmopen_format/json_document.dart: decodeDocument refuses an object with a duplicate key (§18.1 — Dart’s decoder would keep the last silently), which the loader reports as duplicateKey, with the repeated name as its argument.
MediaLocation keeps the model Flutter-free; filmopen_ui/media_image.dart turns it into an ImageProvider — an asset key into an AssetImage, bytes into a MemoryImage, and a path on disk through filmopen_ui/file_image*.dart, whose stub answers nothing on the web, where there is no dart:io.
Implementations:
DirectoryProjectSource(directory_source_io.dart, desktop): recursive scan that skips hidden entries,*.tmpsiblings (a write in progress, or one a crash left behind) and the foldersbuild,conform,node_modules. Every read goes throughfile(relativePath), which rejects schemes, absolute paths, backslashes, empty,.and..segments, then checks that the absolute path, and the symlink-resolved path when the file exists, lies inside the folder. Violations throwProjectPathException.related()resolves..against the folder’s URI (or takes an absolute path as given) and returns a newDirectoryProjectSource— a sandbox of its own, never a hole in this one — or null when the folder does not exist. A conditional export (directory_source.dart) swaps in a stub on the web, wherecanOpenDirectoriesis false.AssetProjectSource(asset_source.dart): a folder bundled as assets — the sample project, its media rootthe-cartographer-data, the seed copy of the library — enumerated from the asset manifest.related()resolves a sibling asset folder;diskPath()finds the file beside the executable (asset_disk_io.dart:<exe dir>/data/flutter_assets/…on Windows and Linux, theApp.frameworkbundle on macOS) so that even the sample’s files can be shown in the file browser. It is read-only (canWritefalse);snapshot(label:)copies its text files into a writableMemoryProjectSourcewhose media root is the bundled folder behind anOverlayProjectSource— how every platform opens the sample, so editing, and now uploading, can be tried where nothing may be written to disk.MemoryProjectSource: aMap<String, String>for tests and fixtures, and for session-only projects (the web), with aMap<String, Uint8List>beside it for what is not text — a picture, a thumbnail — whichlocateanswers asBytesMedia, and arelatedSourcesmap for the folders a manifest may name. Writable.OverlayProjectSource: any source behind a writable session. It reads through to the folder behind it and keeps every write — text and bytes — in aMemoryProjectSourceof its own, with a set of paths a delete has hidden;createrefuses what either holds,diskPathanswers nothing for what it has written, andrelatedwraps the folder it answers in an overlay too. It is how the bundled sample’s media root takes an upload (milestone 5, D10): nothing written in a session copy outlives the session, and nothing reaches the bundle.
ProjectLoader.load(source, {library}) (filmopen_store/project_loader.dart) works from up to three roots. It reads the manifest first (a missing one is noManifest; a manifest without filmopen or tag is headerMissing; the folder without a manifest is its own media root) and opens the media root it names when the locator is local and the folder exists (else dataLocatorUnsupported / dataRootUnavailable; an absolute path is dataRootAbsolute). It indexes the library’s files first with EntityOrigin.library (a library folder that cannot be listed is libraryUnavailable and the project goes on without it), then the project’s with EntityOrigin.project — the project’s copy of a stem replaces the library’s with libraryStemShadowed and none of the replaced file’s own warnings survive (per-entity warnings are emitted at the end); a project’s official pointer replaces a library’s silently (§14.5: accepting a library entry is pointing official at it); the library’s unknown files are not the project’s “other files”. Then it walks the media root for takes only (anything else there is reference material reached by path, §6.3); a binary written into a session copy’s media root is listed and located like any other file, so an upload is a take of the project as soon as it is written. A file of a reserved folder is not an unknown file (§4.2): a thumbnails/ picture goes into FilmProject.thumbnailPaths rather than into Other files, and render/ and conform/ are passed over the same way — _inbox/ is not, since what a person leaves there is exactly what a reader should report. Finally it builds the index as in milestone 1, checks the manifest’s tag against the pj files (manifestTagMismatch) and selects the project. It logs one project.loaded debug entry with counts and timing.
6.1 Creating a project — filmopen_store/project_template.dart, project_creator*.dart
Section titled “6.1 Creating a project — filmopen_store/project_template.dart, project_creator*.dart”writeNewProject(source, {tag, author, name, kind, epochLabel}) (pure Dart) writes into any writable ProjectSource: filmopen-project.json from ProjectManifest.template, and pj_<tag>_<author>_v1.json with the header, the kind, an empty genre, the current year, one epoch (default, labelled in the user’s language, with the current year, §8.2), the creator in contributors as director (§8.3), an empty story-root list for the kind (§8.5) and timestamps. copyProjectTo(...) (_io) writes a session copy’s text files into a new <parent>/<folder>/ under the same refusal, for Copy to a folder…. createProject(...) (_io) makes <parent>/<tag>/ first, refusing a folder that already holds files (ProjectFolderNotEmptyException) and a tag that is not a segment; where folders cannot be made (the web) the provider writes the same documents into a MemoryProjectSource that lives for the session. encodeProjectJson is the one JSON encoder for written files: two-space indentation and a final newline (§18.1); rfc3339Now() the one timestamp format (§7).
6.1a Writing — filmopen_store/project_writer.dart
Section titled “6.1a Writing — filmopen_store/project_writer.dart”ProjectWriter(project) is pure Dart and does every write into an open project, through project.source.writeText / deleteFile, logging each (entity.saved, entity.forked, official.set, official.cleared, pick.set, pick.abandoned):
saveEntity(entity, json, viewer:)— replaces the author’s own file withjsonplus a freshupdated. Refuses a read-only source (ProjectReadOnlyException), a library file, no viewer (NoViewerException), another author’s file (NotYourFileException, §17 rule 2), a change to any identity field —filmopen,type,tag,epoch,author,v, the filename’s own data (IdentityChangedException, §7) — a change to a project file’sforkedFrom, removed, redirected or added (IdentityChangedException('forkedFrom'), compared by value, with a sentence of its own: directorship is read through that chain while nothing is official, §5.3a) — and a file that changed since it was read (StaleFileException, from the source). Whether to warn that the version is official (§13.2) is the caller’s decision; the editor shows a banner and still allows it.fork(entity, viewer:)— §13.1: the viewer’s next number at that epoch (v1 when they have none), a copy identical apart fromauthor,v,forkedFromand fresh timestamps, and withoutversionLabelor its 1.5 spellinglabel(a new version starts unlabelled — the owner’s rule of milestone 4.6, Project Specification 1.8 §13.1); a forked project file lists the new author incontributorswith no role; written beside the original (refusing an existing file), then recorded as the viewer’s pick (setPick) so the fork is used at once. Returns the newVersionStem.createEntity(type:, tag:, epoch:, name:, kind:, viewer:)— a new character or location (milestone 4.7): the viewer’s<type>_<tag>_<epoch>_<author>_v1.jsonholding the header, the name (trimmed), the kind when one is given and the two timestamps, written beside the selected project file withcreate, so a file of that name that appeared since the index was read is refused rather than replaced. Refuses a tag the type already has in the project or the shared library (EntityExistsException— a second file under a tag is a version, and a version is made by forking), a tag or epoch that is not a segment, an epoch the project does not declare and a type without an epoch axis (ArgumentError), and — as every write — no handle, a read-only source and an empty name. The new version is then recorded as the viewer’s pick at that epoch (setPick), as a fork is — the owner’s answer to milestone 4.7’s question 4: it is theirs to work with and official only when a director makes it so; a pick that cannot be written is logged (pick.setFailed) and leaves a usable version. Logged asentity.created.setOfficial(entity, viewer:)/clearOfficial(entity, viewer:)— writes<versions key>_official.jsonnaming the entity withsetByandsetAt(rewriting an existing pointer’s other fields, dropping itsnote), or deletes the pointer file. Both refuse anyone who is not a director of the project (NotDirectorException,FilmProject.isDirector, §8.3 — a rule development mode governs, below) — read from the project file of record, or, where the folder has none, from the origin of the selected project file through itsforkedFromchain (§5.3a), never from a fork — and the UI disables the star for the same reason.setPick(versions, viewer:, entity:, trackOfficial:)— the viewer’s pick at one epoch:pickandpickedAtin the viewer’s owncm_file for the entity (§13.4), created with emptyentrieswhen they have none.entitypins that version;trackOfficialwrites<versions key>_officialso the pick follows the star; neither given removes the pick and its date at that epoch, which returns the viewer to official — abandoning. For an entity with epochs both values are objects keyed by epoch, and an older single stem is folded under the epoch it names, so picking at50yoleaves30yoalone; the 1.5 spellingpreferis dropped oncepickis written.Commentary.pickStemAt(epoch)andpickTimeAt(epoch)read both shapes.
Development mode (filmopen_store/guards.dart, §9.5). ProjectWriter(project, guards:) consults a Guards policy before three refusals that gate a person rather than protect a file — another author’s file (GuardRule.notYourFile), setting or clearing official without being a director (notDirector), and a file of the shared library (libraryFile). Guards.enforce, the default, refuses as above; Guards.warn lets the write through, and once the write has happened the writer reports guard.overridden for each rule it went past (a warning, with the rule’s code, the stem or versions key and the handle) — a write refused afterwards, by a stale file or a changed identity, logs no override. A library file written in development mode goes to the library source it came from, never beside the project’s files, and a library the system will not write still refuses. Everything else refuses in every mode: no handle, a read-only folder, a stale file, a changed identity (a project file’s forkedFrom included), invalid content, and — in Compare — the epoch guard. The provider builds every writer with Settings.guards.
The provider adds the decisions the format leaves to an application (§13.2, §13.4): setPick(entity) on the version that is already official removes the pick rather than pinning it, since no pick means official; setOfficial removes the director’s own pick at that epoch, so the star alone marks what they use; abandonPick(entity) and keepPick(entity) are the banner’s two answers, the second re-dating the pick so the pointer no longer supersedes it. Where removing would not land on official — a workspace handle whose owner picked something at that epoch — the tracking value is written instead, which comes to the same thing.
Nothing renames or deletes another author’s file (§18.3).
ProjectWriter.writePluginRun(run:, sources:, outputs:, viewer:) (milestone 5.1, Project Specification §14.4, §12.4) writes what a plug-in’s entity hook returned, run being a PluginRun — the plug-in’s tag, which names the derived handle, its stem, the hook and the project-scoped settings it ran with: each output — whose type, tag and, for a library type, epoch must match one of the sources (PluginOutputException('noSource') otherwise) — as a new version under the derived handle <viewer>.<tag>, numbered after that handle’s highest there, with by the plug-in’s stem, forkedFrom its source, fresh timestamps and no version label, written beside its source with create; then the run’s batch rd_<id>_<viewer>.<tag>.json, kind: "plugin", naming the plug-in, the hook, the settings used and each item’s source and output. Every output is checked (validateContent) before any is written, and a write refused part-way deletes what the run had written, so a run is written whole or not at all; the person’s own versions are never written and no pick is recorded. Logged entity.pluginWritten and batch.written.
ProjectWriter.writeImportBatch(stem:, items:, viewer:, run:) writes the record of one import action (§12.3) — or, with run a PluginRun, of a plug-in’s render: a kind: "plugin" batch naming the plug-in and the hook, authored by the run’s derived handle <viewer>.<tag> and by nothing else, the one author other than the viewer a batch may have: rd_<id>_<author>.json beside the project’s files, kind: "import", one item per file with its n, source (the entity stem), deliverable, output, originalName, sha256, bytes, mimeType and, for a picture, width and height. It is a project file, so it is written here rather than by the importer: under the viewer’s own handle only (NotYourFileException), with no handle refusing as every write does, and with create, so an id that is already taken is refused rather than replacing somebody’s record of what they imported. Logged batch.written.
6.2 The log — filmopen_store/app_log.dart, log_file*.dart
Section titled “6.2 The log — filmopen_store/app_log.dart, log_file*.dart”Structured logging, pure Dart. LogLevel is verbosity from error to trace; error and warning are always written, info is the default threshold, debug is what the Verbose logs setting adds, trace is per-file detail that only FILMOPEN_LOG_LEVEL=trace in the environment turns on (the environment pins the level; the setting then cannot lower it). AppLog.instance fans each LogEntry (UTC ts, level, dotted event such as project.opened, a data object, optional error and stack) to its sinks: FileLogSink (one JSON object per line, appended and flushed synchronously so a crash loses nothing, rotated to <name>.1.jsonl past 5 MB), LineLogSink (the console in debug builds), MemoryLogSink (tests). A sink that throws is skipped, never fatal. AppLog.timed wraps a future and logs its duration or its failure. main.dart opens the file sink before anything else, routes FlutterError.onError and PlatformDispatcher.onError into it, and writes app.started. Events are identifiers, not sentences, so the log stays language-independent and groupable.
6.3 Showing files — filmopen_platform/reveal*.dart
Section titled “6.3 Showing files — filmopen_platform/reveal*.dart”revealInFileManager(path) opens the platform’s file browser with the file selected (or the folder opened): Windows explorer.exe /select, <path> (the switch and the path as two arguments — Dart quotes an argument containing spaces, and Explorer ignores a quoted /select,C:\a b\f), macOS open -R, Linux the freedesktop org.freedesktop.FileManager1.ShowItems D-Bus call with xdg-open of the folder as fallback. iOS (shareddocuments://) and Android (a content:// Documents URI for shared storage) go through url_launcher and are best-effort: iOS is not built here and its branch has not run; Android is (milestone 5.01), and its branch answers false for the app’s own files, which are app-private and no document app’s to show. It returns false rather than throwing, and the caller says so in a snackbar. canRevealFiles — whether the Show in folder buttons are drawn at all — is false on the web, on Android and on iOS (milestone 5.01, D5): a phone’s project files live in the app’s own folder (§6.4), which nothing else on the phone is shown.
6.4 Application files — filmopen_platform/app_paths*.dart
Section titled “6.4 Application files — filmopen_platform/app_paths*.dart”Where the application keeps its own files, in the place each operating system expects. Project data never lives here.
Preferences (shared_preferences) |
Shared library | Log file | Reports (the issue recorder, §6.8) | Usage log (§6.7) | |
|---|---|---|---|---|---|
| Windows | %APPDATA%\ai.filmopen\filmopen\shared_preferences.json |
%APPDATA%\ai.filmopen\filmopen\library\ |
%LOCALAPPDATA%\FilmOpen\logs\filmopen.jsonl |
%LOCALAPPDATA%\FilmOpen\reports\ |
%LOCALAPPDATA%\FilmOpen\usage\ |
| macOS | NSUserDefaults (~/Library/Preferences/<bundle id>.plist) |
~/Library/Application Support/<bundle id>/library/ |
~/Library/Logs/FilmOpen/filmopen.jsonl |
~/Library/Application Support/<bundle id>/reports/ |
~/Library/Application Support/<bundle id>/usage/ |
| Linux | $XDG_DATA_HOME/filmopen/shared_preferences.json (~/.local/share/…) |
$XDG_DATA_HOME/filmopen/library/ |
$XDG_STATE_HOME/filmopen/logs/ (~/.local/state/…) |
$XDG_STATE_HOME/filmopen/reports/ |
$XDG_DATA_HOME/filmopen/usage/ |
| Android | the system SharedPreferences store |
the app’s support folder, library/ |
the app’s support folder, logs/ |
the app’s support folder, reports/ |
the app’s support folder, usage/ |
| iOS | NSUserDefaults |
the app’s support folder, library/ |
the app’s support folder, logs/ |
the app’s support folder, reports/ |
the app’s support folder, usage/ |
| Web | browser storage | the bundled copy, read in place | none: console only | none: a recording is kept in memory | none |
A plug-in’s machine settings and storage live in the app-support folder’s plugins/<tag>/ on every platform with app files (pluginDataDirectory, milestone 5.1, §6.11), never in a project or the library.
Projects on a phone (milestone 5.01, D4). On Android and iOS a project can only live where the app reads and writes without a permission it does not ask for — the system’s document picker answers a content:// URI that no Directory can open — so projectsInAppFolder answers true there and projectsDirectory() is projects/ under the app’s documents folder, created on first use: New project makes the project there, with its media folder beside it, and Settings → About names the folder, since no dialog ever shows it. folderDialogsAvailable — whether the operating system has a folder dialog whose answer is a path — is false on the two, and the doors that need one are not drawn: Open project…, Copy to a folder…, the library card’s Choose folder… and the two Browse buttons of New project. The web answers false to both, having no folders at all; a desktop answers false to the first and true to the second. The three reach the widgets as providers (§7.1: projectsInAppFolderProvider, folderDialogsAvailableProvider, projectsDirectoryProvider), so a test pumps a phone’s answers on a desktop.
The app-support folder is the one path_provider derives from the executable’s company and product names (windows/runner/Runner.rc: ai.filmopen, filmopen). On Windows and Linux shared_preferences keeps a JSON file there, and Settings → About shows that file with a show-in-folder button; on macOS and mobile the plugin uses the system’s own preference store, which is not a file to show, so the line is absent. On a phone About names the projects folder instead (milestone 5.01), as text alone, since canRevealFiles is false there. The library sits in the same folder so everything the app owns on a machine is in one place; a library folder that cannot be read is a libraryUnavailable warning on the project, shown on the Settings library card, never a failure to open. Preferences are plain JSON: theme, language, handle, widths, verbose flag, recent project paths, library path, the one-bit hint accountSignedIn (whether a session may be waiting in secure storage, so that start-up knows whether to ask for it), each provider key’s state (§6.7), and filmopen.drive.folder.<path> — the Drive id of a media folder this machine has already found (§6.10), which §4.4 allows as a per-machine substitute for a locator, and which the path resolves again if it goes — nothing secret. Credentials go to the operating system’s credential store, not to this file: the account session (access and refresh tokens, the user record) and the PKCE verifier are written through flutter_secure_storage under the keys filmopen.supabase.session.v1 and filmopen.pkce.*, each provider key under filmopen/<owner>/<slot>, such as filmopen/default-ai-assist/openrouter (§6.7, §6.11), and Google’s access and refresh tokens under filmopen.drive.tokens (§6.10) — on Windows a DPAPI-protected file flutter_secure_storage.dat in the same app-support folder, readable only by the same Windows user; on Apple platforms the Keychain; on Linux libsecret. The web build has no credential store: there the Supabase client keeps the session in the browser’s local storage, which is the only option a browser offers and is the carve-out to the rule above. A store that cannot be read fails closed (storageUnavailable); nothing ever falls back to plain text.
Note for development: two builds of the app on one machine (for instance filmopen-a and filmopen-c) share these folders and the log file, because they share the product identity.
6.5 Installing the library — filmopen_store/library_install*.dart
Section titled “6.5 Installing the library — filmopen_store/library_install*.dart”installLibraryIfEmpty(folder, seed) copies the bundled library (assets/sample/library) into the app’s library folder when that folder holds no files (empty subfolders do not count) and never touches it afterwards. Updating the library — a git pull, a download — is the user’s or a future updater’s job, independent of the app’s own builds (§14.5). On the web the bundled copy is read in place.
6.6 The filmopen.ai account — filmopen_account/account_auth.dart, account_profile.dart, account_registration*.dart, auth_callbacks.dart, filmopen_account/account_card.dart, account_card_model.dart
Section titled “6.6 The filmopen.ai account — filmopen_account/account_auth.dart, account_profile.dart, account_registration*.dart, auth_callbacks.dart, filmopen_account/account_card.dart, account_card_model.dart”The same account as on filmopen.ai, offered on a Settings card; local projects never need it. FilmOpenAccountAuth (a singleton) initialises the Supabase client lazily with PKCE, the secure-storage adapters of §6.4 and, on the web, a callback predicate that accepts exactly the page’s own URL with a code or an error; off the web the library’s observer is given a predicate that accepts nothing, since every link comes through the app’s own door and the model exchanges it itself (below; milestone 5.01). It initialises only when asked: on the first sign-in gesture, or at start when the accountSignedIn hint is set or the app was started with a callback URL — on Windows the command line (launchArgumentsProvider), on Android the intent, both read through filmopen_platform‘s IncomingLinks door (incomingLinksProvider; milestone 5.01), the one door for links on every platform, which the card asks whenever it is built. Nothing touches the plugin channels otherwise — which is what keeps every widget test and a fresh install free of it — with one exception since milestone 5.01: the card asks the link door as it is built (app_links’ initial link and its stream), since a launch by the callback has to be honoured without a gesture; a test’s door is the unavailable one and touches nothing. The card is built more than once: Settings chooses its tab with a switch, not a TabBarView (milestone 4.9, D6), so leaving General disposes the card and returning builds another, while the links do not go away — the door answers the launch link from a cache for the life of the process and Windows keeps its command line just as long. A sign-in result may be exchanged once, its code being spent by the first exchange, so which callbacks a process has acted on is kept outside the card in AuthCallbacks (auth_callbacks.dart): claim decides whether a link is acted on, whoever was handed it and however often, and the card asks isSpent before it treats a link as a launch to wait for. A model of its own holds no such memory (milestone 5.01, step 2, round 2). A failed initialisation disposes the half-built client so a retry starts clean.
The service returns codes, not sentences: AccountAuthProblem (codeInvalid, rateLimited, identityTaken, storageUnavailable, browserUnavailable, callbackUnavailable, profileUnavailable, sessionExpired, failed, unavailable), rendered by Labels.accountProblemText; the raw AuthException.message echoes the callback URL’s error_description, and a PostgrestException prints the server’s message, details and hint; neither is shown or logged — only their codes. A display name is limited to 80 code points (what the database’s char_length counts) without control or separator characters; the server’s own refusal (23514) is nameRejected, and a profile that failed to load offers Retry. Every step is logged with dotted events (account.initialized, account.initFailed, account.signIn {method}, account.codeSent {emailDomain}, account.signedIn {userId}, account.signedOut, account.profileSaved, account.failed {op, problem, code, status}, account.protocolRegistered, …) — never the e-mail address or a token.
The card and the dialog. The state — client, user, the step in progress, the last failure, the throttle — is AccountCardModel, a ChangeNotifier shared by two widgets. FilmOpenAccountCard on Settings shows, signed out, one button, Log in to filmopen.ai, with the logo (plus Check account until a client exists, and Cancel browser sign-in while one is pending); it names no provider, so a “GitHub” on Settings cannot be read as a GitHub sign-in beside the one that will store projects. The button opens AccountSignInDialog, laid out like any single-sign-on prompt: the logo, Sign in to filmopen.ai, one full-width Continue with … button per provider (Google, Discord, GitHub) with its brand mark (filmopen_account/provider_mark.dart: the Google “G” in its colours, Discord in blurple, the GitHub mark in the theme’s ink), an or rule, then the e-mail address and Send a sign-in code, which turns into the code box. The dialog closes itself when the browser takes over or the user is in; Cancel drops its validation text but keeps a code already sent, so reopening resumes at the code box. Signed in, the card shows the logo, who is signed in, the display name — filmopen_account/account_profile.dart: the profiles row is created by the website’s signup trigger, read with single() and written with update … select … single(), since the deployed policy grants SELECT on the own row and UPDATE of display_name only (never INSERT, so no upsert); a missing row is profileUnavailable, a NULL name an empty one — with unicode-aware validation, Sign out here (this device only) and links to the website. One-shot confirmations go through showMessage; only the current step and the last problem stay inline. While a browser sign-in is pending the dialog cannot be opened (both flows share one PKCE verifier). The e-mail code is throttled per address with the seconds remaining.
The callback. The browser returns to the app through the custom scheme ai.filmopen.desktop. On Windows ensureAuthProtocolRegistered() (account_registration_io.dart) registers it for the current user before the browser is opened — three reg.exe add calls under HKCU\Software\Classes\ai.filmopen.desktop, the command being the resolved executable and "%1"; no administrator rights, logged either way — and the card refuses to open the browser when registration is not possible (callbackUnavailable), which is also the answer of the stub and of the other desktop platforms until they are built. scripts/register-login.ps1 is the manual equivalent for a developer who wants to point the scheme at a particular build. The Windows runner (main.cpp) hands a second launch’s command line to the running instance and exits, so the callback reaches the window that started the sign-in. On Android nothing registers at run time (canRegisterAuthProtocol is true on the desktops only, milestone 5.01): the scheme is the manifest’s intent filter, and the intent that returns the sign-in reaches the card through the link door and the model exchanges it itself (callbackArrived → getSessionFromUrl; account.callbackArrived {initial} in the log, never the link), whether the app was started by it or running, with a client or without — the library’s observer reads a launch link only on the web, so off the web it is told to handle none and no code is exchanged twice; the same link handed twice (app_links gives a launch link to the initial reader and to the stream’s first listener, and on Windows the command line carries it too, which the card feeds in as well) is acted on once. The client configuration (FILMOPEN_AUTH_URL, FILMOPEN_AUTH_PUBLISHABLE_KEY, FILMOPEN_EMAIL_AUTH_ENABLED) is baked in at build time and can be overridden with --dart-define; the publishable key is public by design.
6.7 Provider keys and the usage log — filmopen_platform/credential_store.dart, key_verifier.dart; filmopen_keys; filmopen_format/usage_record.dart, usage_totals.dart, money.dart; filmopen_store/usage_log*.dart
Section titled “6.7 Provider keys and the usage log — filmopen_platform/credential_store.dart, key_verifier.dart; filmopen_keys; filmopen_format/usage_record.dart, usage_totals.dart, money.dart; filmopen_store/usage_log*.dart”Recorded on 15 September 2026. The owner decided or directed the following (Portal Specification P1–P9, P14, P17 and P20):
- Usage is tracked on the device first, on the user’s own keys.
- The platforms.
- OpenRouter for text.
- fal for images, video and, for now, ElevenLabs’ voices.
- OpenAI for live transcription.
- Every other platform stays in the catalogue, marked pending, with its obstacles.
- The code is shared, so power users can add their own platforms and keys.
- Keys.
- Keys are kept by the operating systems’ standard credential stores, and never in a project.
- The Provider keys tab explains how keys are saved encrypted on the device.
- It does not show a key again once it is entered: “we won’t worry about showing the user his key after he enters it”. A user who wants to share a key shares it themselves, or uses filmopen.ai.
- Credentials that filmopen.ai hands to devices, later, go only to FilmOpen’s own signed builds.
- Usage records.
- Usage is a “very simple append-only transaction log”, of JSON objects or whatever is standard, with no SQL, no relational database, and no merges or joins; at most, the app filters it.
- A Usage tab shows each use, what it was for and what it cost.
- filmopen.ai, later, keeps the same records; the only difference shown is the route, such as “fal via filmopen.ai”.
- There, a director can set collaborators’ daily, weekly or monthly limits.
Where this section goes beyond those decisions, it is proposed. That includes the record’s statuses, its chains and the lock file: the session’s way of keeping a simple log right when a call ends late, the app crashes, or two copies of the app write at once, for the owner to weigh against “very simple”. Milestone 4.9 built the keys in its step 2, and built the usage log and the Usage tab with dictation (§9.6) in its step 3. The bring-your-own-key milestone after it adds the other calls. §16 lists what this design leaves to those milestones.
Platforms. Milestone 4.9 marks every platform in the catalogue other than the first version’s as pending.
Provider keys (built in milestone 4.9, step 2). A user’s key is kept in the device’s credential store through flutter_secure_storage, as the account session is (§6.4): DPAPI on Windows, the Keychain on Apple platforms, the Android Keystore, libsecret on Linux.
- The seams (
filmopen_platform), each a static shaped likeAutomationHost.install(§7.1) and unavailable until an entrypoint installs another, so that a build that installs nothing fails closed:CredentialStore(credential_store.dart):read,writeanddeleteof one name,isAvailableandisEphemeral, with no way to list or clear the store, which holds the account’s session too.DeviceCredentialStore(device_credential_store_io.dart) callsFlutterSecureStoragewith the name verbatim, one call at a time, and turns any failure intoCredentialStoreUnavailable, which carries the failure’s type only. Each call has ten seconds: on Windows the plugin retries a failed file write without end, so a call past that fails closed astimeout, and the queue moves on (§16).MemoryCredentialStorecounts its calls, for the tests and the agent build. The web build’s store is the unavailable one.KeyVerifier(key_verifier.dart):verify(platform, values)answers aKeyCheck,verified,rejectedorcouldNotCheck, with a code.StubKeyVerifieranswers by how a fake value ends (-okverified,-badrejected, anything elsehttp_503), for the tests and the agent build.- Who installs what (milestone 4.9 plan, D10):
lib/main.dartinstallsDeviceCredentialStoreandHttpKeyVerifier, unless an entrypoint outsidelib/installed others first;test_driver/agent_main.dartand the tests’pumpAppandpumpSettingsinstall the memory store and the stub, and the tests put the defaults back.
- How it is stored. One JSON object, a field per credential the platform file lists (
{"key": "…"}), each value trimmed of spaces and of the invisible characters a copy can bring along (a zero-width space, a byte-order mark), under its key slot’s name,filmopen/<owner>/<slot>(milestone 5.1; Project Specification §14.9, where the platform file’sauth.keychainis the base the app adds the owner to):filmopen/app/openaifor dictation’s,filmopen/default-ai-assist/openrouterand…/falfor the stock assistant’s. Milestone 4.9’s names,filmopen/<platform>, are moved once (legacy_keys.dart‘sensureLegacyKeysMoved, the note’s D17) by the first door that opens the store for a key — Save, Remove, a dictation, a plug-in’s request — and their preferences’ states at start (movePreferences), which opens nothing. An older build run since, which shares the preferences and saves a key under its old name again, makes both move again at the next start, the old name’s key and state then taking the new name’s place, since they are the newer. A value holding another character no key has, which no header could carry, is not stored. It is encrypted, not hashed, because the app must send it to the platform. Save over a stored key replaces it; Remove deletes that one name. - Where it never goes. It is never written to a project, the log, the preferences or a usage record, and no screen shows a saved key again: only its last four characters, and those only for a value of twelve characters or more. As Save reads the boxes it replaces them with empty ones, whatever the store answers, so that no undo brings a key back; and the log cuts a text box’s value from any error that quotes one (
filmopen_store/app_log.dart). - What the tab says, in plain words: the key is saved encrypted on this device, in the operating system’s credential store, for this user only; it is never put in a project, the log or the usage log, and FilmOpen never shows it again, only its last four characters; it is sent only to its own platform; and FilmOpen never shares a key, so a user who lets a collaborator use theirs gives it through a password manager, never in a chat or an e-mail, which filmopen.ai will make easier later.
- What the preferences hold (
providerKeys.<owner>/<slot>):status(stored,verified,rejectedorcouldNotCheck),at(UTC),lastFour, andcheck, the code of a check that refused the key or could not tell. The tab is drawn from them alone, so showing it opens no store. With an ephemeral store, as in the agent build, the states stay in memory, so that no preference claims a key the store has forgotten. A state that cannot be read, a value that is not text among them, is No key; a state the preferences refuse to keep is logged askeys.stateNotSaved, and the card shows it until the app restarts. - The controller (
filmopen_keys/provider_keys.dart,providerKeysProvider) is the only code that opens the store for keys, and only on a gesture. It runs one action per platform at a time, recorded inkeyActivityProvider(saving, checking or removing), from which the cards draw their busy look, so that a card built again while its key is being checked still says so; another platform’s action does not wait for it.- Save is the only door a key goes through (the owner, 16 September, after hand-off B2): one press asks the platform about what the boxes hold and, where the platform accepts it, writes the key and the answer as its state. A key the platform refused, one whose check had no answer, and a value no request could carry are not stored at all, and the card says which. Where the platform file has no
verify, Save writes the key unchecked and the card says that FilmOpen cannot check that platform’s keys. - Nothing re-checks a key already stored: a card whose state came back from a preferences backup says No key until the key is pasted again (§16).
- A store that cannot be used changes nothing, and the section says so inline.
- A verifier that throws, though none should, could not check (
verifier_failed), and nothing is saved.
- Save is the only door a key goes through (the owner, 16 September, after hand-off B2): one press asks the platform about what the boxes hold and, where the platform accepts it, writes the key and the answer as its state. A key the platform refused, one whose check had no answer, and a value no request could carry are not stored at all, and the card says which. Where the platform file has no
- The check (
filmopen_keys/http_key_verifier.dart), which Save makes before it stores anything, sends one request: the platform file’sverify.method, as written, to exactly itsverify.url, over https and only to one of the file’shosts, with the platform’s header filled from the stored values and its other headers, following no redirect, and given up after 15 seconds. It reads no body and closes the connection.- A 2xx is Verified; 401 or 403 is Rejected, with its code kept: a 403 is a key the platform knows but a check it refused (a key without the check’s permission, or a request from a country the platform does not serve), and has a sentence of its own.
- Anything else is Could not check, with
http_<status>,timeout,connection,bad_value(a value dart:io refuses in a header) orbad_request(a method that is not a token, though the parser leaves such a check out first), and the key stays stored.filmopen_l10n’skeyCheckTextgives every code its sentence, andverifier_unavailableor a code it does not know the generic one. - It runs no model and writes no usage record.
- Which platform files the tab takes (
filmopen_format/platform_file.dart;filmopen_keys/provider_catalogue.dart, out of the one reader,filmopen_state’scatalogueProvider). A file is unusable when an issue decides where a credential goes: a missing or mistyped field, a base URL over http or off itshosts, a credential id that is not a plain token or repeats one, a key’s header that is notName: valueor holds a placeholder no credential fills, or a keychain other thanfilmopen/<platform>. A problem with a page’s URL leaves that page out, and a problem withverifyleaves the check out, so that the card says FilmOpen cannot check that platform’s keys and Save stores one unchecked. A platform whose card would repeat another control’s identifier (the idsplatforms,removeandreplace; a credentialsave,removeorguide, andverify, kept reserved though step 2c took the button away) is left out and logged. - The log carries
keys.checked(the check a save makes),keys.storedandkeys.removed, each withplatformandcodeonly. A link that cannot be opened iskeys.linkFailed, with the host and the failure’s type;keys.stateNotSavednames the platform and the failure’s type; the catalogue’skeys.catalogueIssuea file, a problem and a field, andkeys.catalogueUnreadablea file and the failure’s type. - The web build has no credential store for these keys, since §6.4’s carve-out covers the account session alone. So it keeps no provider keys and makes no paid calls. Its Provider keys tab is one sentence saying that keys are kept by the desktop app, and its Usage tab says there is no usage on the web.
- A store that cannot be read fails closed.
The usage log. Every paid call the app makes on the user’s own key appends usage records. The Usage tab, in Settings, lists, filters and totals them on the device. A job brokered through filmopen.ai, later, is recorded by the portal, and the app keeps the portal’s records (Portal Specification P11).
-
Where. In the usage folder of §6.4’s table, never in a project. Besides the month files, the folder holds two files:
device.json, shaped{"v": 1, "device": "<a random UUID>", "created": "<RFC 3339 time>"}. It is made under the lock when it does not exist, or when it is read whole and holds no valid id, written beside it and renamed over it so that a crash leaves the old file or the new one; a read that fails is retried, as a write is. It sits outside the preferences so that the id stays with the machine where preferences roam..lock, the file that writers lock.
-
Files. JSON Lines: one record per line, one file per month (
2026-09.jsonl), chosen by the record’s ownatin UTC. The app never rotates, edits or deletes them. -
Writing. Two copies of the app can share the folder (§6.4). A writer:
- takes an exclusive lock on
.lock, waiting up to ten seconds while another program holds the file or its lock; any other failure to open or lock it fails at once, asio; - writes a line feed first, if the month file is not empty and its last byte is not a line feed;
- writes the record and its line feed in one write;
- flushes, and releases the lock.
A call does not start if its first record cannot be written; any later record is retried until it is written (§16), a quarter of a second after a failure, then twice as long each time, up to ten seconds. The app log’s sink (§6.2) rotates its file, so the usage log does not use it.
- takes an exclusive lock on
-
Reading.
- Readers take no lock, and read every month file.
- A line that is not a complete record, such as the end of a file cut short by a crash, is skipped.
- Every version of the record keeps
v,idandrefas version 1 defines them. A record whosevthe reader does not know is skipped with its whole chain, so a reader never counts part of a call it cannot read. - Each skip is reported once in the app log (
usage.badLine,usage.unknownVersion), with the file and line number, never the content. - Fields a reader does not know are ignored.
A record, version 1:
| Field | Type | Required | What it holds |
|---|---|---|---|
v |
integer | yes | 1 |
id |
string | yes | A random UUID (version 4), in lower case, kept wherever the record goes |
at |
string | yes | When this record was written: UTC, RFC 3339 with milliseconds (2026-09-15T13:02:11.482Z), and later than the chain’s record before it (see Later records) |
status |
string | yes | started, submitted, running, succeeded, failed, canceled or unknown |
ref |
string | after the first | The id of the chain’s first record (see One call’s records) |
device |
string | yes | The id in device.json |
user |
string | no | The filmopen.ai user id, when signed in |
app |
string | yes | The version of the app that wrote this record |
platform |
string | yes | The catalogue’s platform id, such as openrouter, fal or openai |
route |
string | yes | direct, on the user’s own key. Later, through the portal: filmopen.ai, on FilmOpen’s accounts and a balance, or filmopen.ai-key, on a key stored on filmopen.ai (Portal Specification P4, P21) |
entry |
string | yes | The catalogue entry’s id (Project Specification §14.7) |
variant |
string | no | The id of the entry’s variant, when the call used one |
kind |
string | yes | The entry’s kind: text, image, video, tts, music or analysis |
model_id |
string | yes | The platform’s name for the model, as the entry’s access row gives it |
purpose |
object | no | project and target: the file stems of the project and of the entity or shot the call was for. field: the box a dictation filled — a form field’s key, or the identifier of a box outside a form; project is the open project’s stem where the box gave none. Never the prompt or the text |
request |
string | no | The platform’s request, job or session id |
key |
string | on direct |
The last four characters of the key used |
units |
object | no | What the platform reports or the app measured, with a field for each of the catalogue’s price units (per). Non-negative integers: input_tokens, output_tokens, cached_tokens, audio_input_tokens, images, videos, characters, requests. Decimal strings with no exponent and at most six fractional digits: seconds, minutes, megapixels, credits |
estimate |
object | on started |
The cost expected before the call, as a money object |
cost |
object | on running and succeeded; on failed, canceled and unknown when known |
What the call has cost so far, or in all, as a money object |
error |
string | no | A short code of the app’s own, such as http_401 or not_found, never the platform’s message |
A money object has four fields:
amount: a decimal string with exactly twelve fractional digits ("0.042500000000"), with no sign, exponent, comma or space;currency: the platform’s billing currency,"USD"for every first-version platform;scale:12;basis:provider(the platform reported the cost),price(units at the catalogue’s price) orestimate.
The app never carries an amount through a binary floating-point number.
- Reading numbers. Catalogue prices and a platform’s reported costs are JSON numbers, and both are read as exact decimals from the number’s text.
- Rounding. An amount computed from a price with more than twelve fractional digits is rounded half away from zero to twelve. A platform’s cost with more than twelve fractional digits is not recorded as
provider; the record usespriceinstead. - Why rounding is allowed. These records are for display and never settle money (Portal Specification P11).
- The cost of a call is the cost the platform reports for it, where it reports one (
basisprovider). Otherwise it is the catalogue’s price times the units (basisprice): the units the platform reports, once they are checked to be the units the catalogue prices, or else the units the app measured, such as the minutes of audio it sent (§16).
One call’s records.
-
started. Every call on the user’s own key writesstarted, with itsestimate, before anything is sent. An estimate is the catalogue’s price for the call as asked:- a text call’s input tokens and its maximum output tokens;
- an image, a video or a piece of music as requested;
- a voice line’s characters;
- a recording’s transcription, by the recording’s length;
- a live session’s first minute.
Where the request does not bound what the call can cost, the estimate is the smallest amount it can cost (§16).
-
submitted. A queued job, meaning a request answered with a job id as fal’s queue is, writessubmittedwith the platform’srequestonce the platform accepts it. -
running. A live session writesrunningafter each full minute of audio sent, with itsunitsandcostso far. -
The final record (
succeeded,failed,canceledorunknown) is written once the call’s end is known.- A request or a queued job ends when the platform says how: its answer, the job’s final status, or an error returned before it accepted the call. A connection that fails before the request is sent (no network, a name that does not resolve, a failed TLS handshake) ends it
failed, with no cost. A local cancel or a timeout ends nothing, and the chain stays open. - A live session ends when its connection closes, whatever closed it: the user closing the popup, the platform’s time limit, or a dropped connection. It ends
succeededwith the minutes sent once the platform accepted it;canceled, with no cost, when the user closed the popup before the platform accepted it, since nothing was sent; orfailed, with no cost, if the connection never opened or the platform refused the session before it started. While the connection stays open, pausing and resuming the audio (§9.6) continue the same chain. unknownis written only when the platform answers that the request does not exist.
- A request or a queued job ends when the platform says how: its answer, the job’s final status, or an error returned before it accepted the call. A connection that fails before the request is sent (no network, a name that does not resolve, a failed TLS handshake) ends it
-
Later records. Every record after a chain’s first names that first record in
ref, and repeats the fields of the chain’s record before it unless it gives new values.v,id,at,status,ref,appanderrorare always its own. Itsatis later than that of the chain’s latest record the writer has read, by a millisecond if the clock has not moved on or has gone back. -
A correction is a later record with the corrected values and the status of the chain’s counted record, so that correcting a finished call never reopens it.
-
Which record counts. A chain is a first record, which has no
ref, and every record whoserefnames it. Records whose first record is missing still form one chain, by therefthey share. The chain’s counted record is the one with the latestat; on a tie, the greaterid.
Totals use each chain’s counted record, and count the chain once, in the month of its first record (or of its earliest record, when the first is missing):
| Status | Counts as |
|---|---|
succeeded, running |
Its cost is spent |
failed, canceled |
Its cost is spent when the platform charged; with no cost, nothing |
started, submitted |
Its estimate is pending, or possibly charged |
unknown |
Its cost is spent when known; otherwise its estimate is possibly charged |
After start-up (with the bring-your-own-key milestone’s queued jobs; not built yet), and only if the usage folder holds a chain whose counted record is submitted, the app opens the credential store and asks each platform about every such chain whose key matches the key now stored for that platform. Nothing else at start opens the store (§6.6).
- It writes the final record once the platform gives one.
- It writes nothing while the job is still queued or running, or while the platform cannot be reached or refuses the key.
- Chains submitted with a key since replaced stay open (§16).
- It never resubmits a call, and writes nothing for a
startedorrunningchain.
6.8 The issue recorder — filmopen_recorder
Section titled “6.8 The issue recorder — filmopen_recorder”A recording (docs/FilmOpen-Issue Recorder.md, phase 1) is a tape of everything a person does in the app — every tap, drag, scroll, key, typed text, draft change, navigation, tab, route, setting and log event — one line per event, with a JPEG of the whole view after each action and the project’s text files as they were before the first action and as they are after the last: a report a person can hand to support, and a support agent can replay through the MCP control on a copy of the same files (§4.3, scripts/replay_tape.py).
Starting and stopping. A person presses the rail’s ? (nav.help, §9.1), then Record an issue (support.record) on the support dialog’s Get Support tab; the dialog closes, and the ? turns into a red stop button while the recording runs (pressing it stops the recording and opens no dialog) and is disabled with a tooltip while one is starting or stopping; the agent layer’s record_start and record_stop do the same (§4.3). Recorder.start(container, {appVersion}) settles the open drafts, opens a report folder (or keeps the tape in memory where the platform writes no files at all — the web build), writes the tape’s start line, environment.json and the project’s text files (and the library’s, when the project has one), then takes every door below. Recorder.stop({reason}) reads the boxes and the state a last time, waits for the open drafts and the last picture, gives every door back, and writes stop, the project’s files as they now are and the log’s tail. Nothing runs before start, and every door start takes is given back in stop, and only the doors that were taken — a door start never reached because an earlier one failed is never released either.
The doors, all Flutter’s own bindings or the state’s seams (§7.1), so that no product widget learns that recording exists (CLAUDE.md §4):
- the pointer router’s global route (
GestureBinding.pointerRouter.addGlobalRoute): a tap, a drag (a click that moved more than 8 logical pixels) and a coalesced scroll; - the platform dispatcher’s semantics-action callback: a screen reader’s tap and an agent’s, which reach the app the same way rather than as a pointer (the note’s D5) — the recorder wraps the callback rather than the semantics owner, so whatever wraps it since still hears every action, and its own tap is marked
via: semantics; - the hardware keyboard, for a key that is not text (
Enter,Escape,Tab, the arrows, a shortcut with a modifier); - the boxes under
valueIdentifiers(§4.3), read before every click, 300 ms after the last key or draft change, at each picture and atstop— an obscured, read-only or hidden box is never read, and a box that has just appeared is read silently unless it already holds the focus and text; DraftObservers(§7.1): every draft change, with its value;RouteEvents(§7.1): every route pushed, popped, replaced or removed — a page, a dialog, a popup menu, a sheet; aDropdownMenuor aMenuAnchoropens in an overlay, not a route, and is heard only by the tap that opened it;container.listenonbrowserProvider,navIndexProviderandsettingsProvider: a navigation, a change of page and a changed setting;- the app log, as a
LogSink; - an
AppLifecycleListenerthat stops the recording (reason: exit) and completes its folder before the app exits.
The tape (tape.jsonl, one JSON object per line, LF, appended and flushed event by event so a crash leaves every line before it readable) carries seq (1, 2, …), t (milliseconds since start), at (RFC 3339 with milliseconds, UTC) and kind on every line:
kind |
Carries |
|---|---|
start |
the app version, platform, OS, view, locale, theme, viewer, development mode, the open project (masked), the selection, the page, the tab |
tap |
id / label / within / role / action / rect / point / button; press: long past a long press’s time; via: semantics for a screen reader’s or an agent’s tap; resolved: false for a click neither an identifier nor a label names |
drag |
from, to, and the identified node at from |
scroll |
the point, dx, dy (wheel ticks at one control within 300 ms coalesced into one line) and the node under the pointer |
key |
the key, its modifiers, and the focused box’s identifier when it is one of valueIdentifiers |
text |
an identifier of valueIdentifiers and the box’s whole value |
draft |
the stem, the key (null for a change that touched several — a copy-left or a block edit) and, when the key is not null, the value |
navigate |
the selection, the node id and the page |
tab |
the tab bar’s selected tab |
route |
the change (push/pop/replace/remove), the kind (dialog/menu/sheet/page/other, from the route’s type) and the route’s name |
setting |
which setting changed and its new value |
log |
the log entry’s event, level and data (masked; never an error’s message or stack, nor the data of an account.* event) |
shot |
the file (shots/0001.jpg), the seq of the action it follows, and the view |
state |
the selection, the node id, the page, the tab, the stems being edited, the last log event and whether a dialog is open |
stop |
the reason (stopped/exit) and the event count |
Events that happen while a pointer is down are written after the click that follows it, for at most 5 s — a button’s callback runs, and can close its dialog, before the pointer router hands the recorder the pointer’s up; a press held past a long press’s time is marked press: long, since replay has no long press and leaves it to a person. The picture and the state line that follow an action are read at the same moment, once the frame and its animations have settled; nothing is written after stop.
The report folder (reportsDirectory(), §6.4; <YYMMDDHHMMSS> in local time, -2 for a second recording in the same second) holds tape.jsonl; shots/0001.jpg, … (a JPEG of the whole view — AutomationHost.windowKey — after every action, encoded on another isolate with the image package, the owner’s answer to the plan’s §9.3: every shot kept); environment.json (the start event’s data, plus startedAt and the log file’s path); project/, the project’s text files as they were at start; library/, the shared library’s text files, when the project has one; project-after/, the same files as they are at stop; and log.jsonl, the app log’s last 500 lines, from the last app.started among them, masked, with error and stack dropped ("error": true stands in for one) and no data on an account.* event. On the web the recorder keeps its tape in memory and writes nothing at all.
Privacy. A typed value travels only under valueIdentifiers (the account card’s boxes never reach the tape), and a dictation’s words never do either: the popup’s dictation.text travels to an agent (§9.6) and the tape refuses it, since it is what a person said rather than what they typed into a file, and the box it fills is read again as that file’s field; an obscured, read-only or hidden box is never read; e-mail addresses are masked in every text, key, label and path; no error message or stack, and no data on an account.* event. The project’s files, and the folder path (which can hold the machine’s Windows user name), are in the report as they stand — the person’s consent to share what the folder holds comes with sending the report, in phase 2; phase 1 only writes the folder, and sends nothing.
Replay and comparison (scripts/replay_tape.py <folder>, reusing scripts/agent_walk.py’s MCP client): copies project/ to a temp folder, launches the agent build there at the recorded size, theme and locale, sets the recorded viewer and selection, then walks the tape in order — a tap by identifier or by label, a tap in a field: row followed by a draft change as set_field (a chip chosen or cleared), a tab as set_tab, every keyed draft change as set_field with the value it carried (a box scrolled out of view gives no text line) — a structured value (a relationship row) as a manual step — a text in a form’s box that left no draft (a height typed as abc) as set_field with that text, a text elsewhere as tap then enter_text, a navigation made only where the app is not already, a setting nobody clicked for as set_viewer, set_locale or set_theme, and a key, a drag, a scroll, a long press or a click resolved: false as a manual step — printed, never failed. Each state line is a checkpoint: once the lines up to its action are replayed and any autosave has landed, the picture is taken and compared and the state is compared (selection, page, the forms open, the tab). At the end, the writes and failures the tape’s log lines hold must be in the replay’s log, and the replay’s log must hold no error the tape does not; the last value each keyed draft change left must be in the replayed file; and the files must equal project-after/, key by key, the header’s clock (created, updated) and every pickedAt / setAt aside. The pictures are compared by packages/filmopen_recorder/bin/compare_shots.dart (pure Dart on the image package, so replay needs no Python imaging library): a pair under 1 % of pixels whose largest channel difference exceeds 16 is same, else different, drawn side by side (person, agent, the difference in red, the tap’s position ringed) into <out>/compare/. Known limits: the manual steps (a key, a drag, a scroll, a long press, development mode) are printed, never replayed; a picture’s measure does not see a typed value (one field’s text is about 0.1 % of the view, under the 1 % threshold — the fields and the files prove the values); replay resolves against the machine’s own shared library, not a copy of the one recorded; the Linux agent walk (scripts/agent_walk.py --platform linux) was not run for the issue recorder in phase 1.
6.9 Media — filmopen_media
Section titled “6.9 Media — filmopen_media”The application’s second place. A film’s pictures, clips and sounds live in the media root the manifest names (§4.4), which may be a folder beside the project, a folder on a service, or the memory of a session with no disk; filmopen_media is what stands between the rest of the application and that difference. It is pure Dart, with dart:io and dart:isolate behind _io / _stub pairs, so the importer and the thumbnailer run on the web and in a command-line tool as well as in the app.
One place media can live is a MediaStore: put (always create, so a take that exists is never replaced — §18.3), isAvailable (whether the place can be reached at all, which the importer asks before a byte moves, so an unreachable root is a refusal in a person’s words and not a failure half-way), open, exists, length, read, list, delete and createRoot. MediaSources registers one store per LocatorType and MediaSources.storeFor(project) answers the open project’s store or why there is none (unsupported, unavailable) — a project whose manifest names no locator is its own media root, which is the local store. No other file names a source: adding Dropbox is one implementation and one registration line. LocalMediaStore(source) is the binary side of a ProjectSource (§6), so a project folder’s sandbox rules hold for its media root; open answers a PlayableFile where there is a path and PlayableBytes where the media is in a session’s memory.
A reference is resolved twice over, because a media root on a service is a store and not a source and nothing lists it (§14, D23): a take stem is looked for among the takes the loader discovered, and then, failing that, among the import batch records — which are in the project folder, so they answer offline and for any store, and a take stem carries the batch’s id and author (§5.4) so the record is asked for by name. Without the second half an upload to a Drive root had no file, no kind and no way to open (found by the owner’s first real upload, after the close).
One class for a piece of media is ProjectMedia: the reference as the file wrote it (MediaRef: a take stem, a path or a URL, §6.3), its MediaKind, where its bytes are, whether the project holds its thumbnail (FilmProject.thumbnailPaths, collected by the loader), the name a person would recognise — the originalName the batch record kept, else the basename — and open(store). Entity.media(project) lists what a file names (§9.0). A reference with no thumbnail is not an error: the tile draws the kind’s stock picture (§6.3).
One way in is MediaImporter.import(entity, inputs, viewer:, run:) — the takes and the batch the viewer’s for an upload, and <viewer>.<tag>’s, with a kind: "plugin" batch, for a plug-in’s render, run being its PluginRun (milestone 5.1, D9). A MediaInput is a file on this machine, bytes the caller has, or a URL (http/https, the content type checked before anything is stored, the client always the caller’s). Every kind is settled before a byte is written; then, per file, in this order (the plan’s D24): the bytes stream into the store while their SHA-256 and their size are measured — a picture small enough to thumbnail is kept as it passes, a clip is not, so a file larger than memory copies through — then the thumbnail into the project folder, then, once every file is in, the batch record through ProjectWriter.writeImportBatch. A picture too big to hold has no width and height in its item, which §12.2 makes the right answer: measured on ingest, never invented. The importer never touches an entity’s file: it answers the ProjectMedia, and the caller writes the references into its draft, so the autosave, the identity lock and the stale refusal hold for media exactly as for a typed field. A record that cannot be written is logged and the take stays usable by its name (§12.1). Refusals are codes (MediaImportProblem: notMedia, empty, tooLarge, nameTaken, rootUnavailable, noViewer, badUrl, unreadable — a file gone between the picker and the import), never sentences, and neither a URL nor a server’s words ever reach an exception, a message or the log.
The thumbnail (thumbnailer.dart, §6.3): within 300 × 300, no crop and no enlarging, the EXIF orientation baked in, transparency flattened on the theme-neutral grey #808080 so one picture serves both themes, JPEG at quality 80 — or 60 when it comes out over 48 KB. It runs off the painting thread where the platform has another (off_thread*.dart: Isolate.run, and inline on the web); a 24-megapixel photograph takes under a second on this machine. Two bounds keep it from taking the build with it: the bytes kept as a picture streams past are dropped once they go over 128 MB, whatever length the answer declared, and a picture whose header says more than 80 megapixels is not decoded at all — a decode allocates four bytes a pixel whatever the file’s own size. Past either bound the stock picture stands in. What will not decode makes no thumbnail, which is a placeholder and never an error. A clip, a sound and anything else get a stock picture drawn in code (stock_thumbnails.dart: a play triangle, a waveform, a page, on the same grey at 300 × 300), written into thumbnails/ like any other, so a clone of the project shows it without the application’s assets.
MediaCache is where a store that keeps its bytes elsewhere puts them so a player can open a file: bounded at 1 GB, oldest first, and never the only copy of anything. filmopen_drive is the first store to use it (§6.10): a fetched file goes in, the key carries the project’s root as well as the path within it, and the player opens a file. Its folder is that package’s rather than §6.4’s, because a media source may change nothing outside itself; the second cloud source is what should lift it here.
6.10 Google Drive — filmopen_drive
Section titled “6.10 Google Drive — filmopen_drive”The second media source, and the proof of the shape §6.9 sets up: a source is one package and one registration line, and no other file of the application names it (the owner’s rule; the milestone-5 plan §6 measures it as a diff against an allowlist).
The client is not in the repository. The id and the secret arrive as compile-time constants from the keys folder (§3, docs/FilmOpen-Dev-Keys.md); a build without them has none, says so on the panel, and a check pins that the source carries no key. Google treats a desktop client’s secret as non-confidential, but a scanner does not and a rolled key is a day’s work, so D15’s “public configuration” means not encrypted, not committed.
The scope is drive.file, and only that. It grants FilmOpen the files
and folders it creates itself and nothing else in the Drive. The panel
says so in FilmOpen’s own words before the browser opens, beside Google’s
own consent text: FilmOpen will ask Google for access to one folder that
FilmOpen creates in your Drive, and to nothing else. It cannot see, list or
read your other files. Two things follow. The uploader offers no Drive
door — the only files FilmOpen could list are the ones already in the
project — and a file the person puts into the folder through Drive’s own
site is not visible to the application, which is why the sentence is exact
rather than reassuring.
Signing in is the flow Google prescribes for an installed application,
which differs by platform because Google’s rules do (milestone 5.01,
step 3): the consent page opens in the person’s own browser
(url_launcher) either way, but a desktop client redirects to a
loopback listener, FilmOpen’s own HttpServer on 127.0.0.1, while an app
installed from a package needs a client of the Android type —
registered against the package name ai.filmopen.app and the signing
certificate’s SHA-1 — which redirects to a scheme of its own
(com.googleusercontent.apps.<the client id reversed>:/oauth2redirect,
androidRedirectUri) that the manifest declares and that arrives as an
intent at the app’s one door for links (§6.6’s IncomingLinks, given to
DriveAuth by main as a DriveRedirects, so this package names neither
that door nor a plugin). A phone has no loopback a browser would reach.
One Google Cloud project holds both clients; DriveClientConfig answers
which one the running platform signs in with (clientIdFor), and a build
carrying only one of them is honestly unconfigured on the other
platform, the two not being interchangeable. An Android client is issued
no secret, and an empty client_secret is a wrong secret rather than an
absent one, which Google refuses: clientSecretFor answers null on
Android, and both senders leave the field out altogether. On Android that
means FilmOpen’s own code exchange
(exchangeCodeWithGoogle, beside the refresh in drive_tokens.dart),
because googleapis_auth cannot do this one: its
obtainAccessCredentialsViaCodeExchange sends
'client_secret': clientId.secret ?? '', so a client with no secret gets
an empty one. (Its refresh function does omit it — a different function,
and not the one an exchange calls; reading the wrong one is what made
round 1 of this step claim the library was enough.) The desktop exchange
is still the library’s, which has a secret to send. The exchange and the
refresh read Google’s answer through one function, so a failure is the
same code either way, and both are the halves a sign-in does not catch —
an access token lasts an hour — so each has a test reading the request’s
body. googleapis_auth exchanges the code, but its own consent
flow asks for no offline access and exposes no way to, and Google
issues a refresh token only for access_type=offline, so the address is
built here (drive_consent.dart: the one scope, access_type=offline,
prompt=consent, PKCE’s S256 challenge and a state the answer is checked
against — all of them read by a test without a browser). It needs
dart:io, so it is in threes and the web takes the stub: there is no Drive
in the browser build, and its panel says that. The tokens go into the
platform’s credential store under filmopen.drive.tokens and nowhere
else — never a project file, never a preference, never the log — and
signing out — Sign out of Google on the panel a person signed in
on, new-project.google-drive.sign-out — forgets them, and says beside
itself that everything FilmOpen put in the Drive stays there: an
application that forgets an account does not delete a person’s files. An access token that has run out is refreshed with the refresh
token, which Google does not send back every time, so the one already held
is kept. Every failure is a code (DriveError): a message from a
service can carry a file name, a folder id, an address or a token, and the
sentence a person reads is FilmOpen’s, in both languages. A Drive that
asks for a moment (429, 503) is waited for once and then said to be busy;
403 is Google’s answer both to an account that has not granted access and
to a Drive that is full, and since which of the two it is would only be in
the message, the sentence names both and the panel offers to sign in again
where the refusal happened.
The store (DriveMediaStore) is a MediaStore like any other. put
creates and never replaces — Drive would happily keep two files of one
name, so the check is FilmOpen’s, and it refuses with the same
StaleFileException a folder does, so the importer’s undo is the same on
either root. An upload is one multipart request with the size declared,
which the importer always knows by then (§6.9: a browsed file is streamed
and a download lands in a temp file first); an upload whose size nobody
knows is refused before anything is written. open fetches the file into
the application’s cache and answers a PlayableFile, so the shared player
never knows where the bytes came from (D14); with nowhere to cache — the
web — it answers the bytes. The cache folder is one for the whole machine,
so a file is keyed by the project’s root and the path within it: two
films can each name stills/front.png, and only a take’s name is unique
by design (D33). The cache lives in this package rather than
beside the application’s other folders (§6.4) for the same reason the rest
of it does: a source changes nothing outside itself. The second cloud
source should lift it out into filmopen_media.
Where the folder is. The manifest records a path,
FilmOpen/<tag>-data (§4.4), because a path is what a person reads and
what another machine resolves by name; the folder’s Drive id is
remembered per machine in the preferences, which §4.4 allows as a
per-machine substitute. A locator that names no folder is refused rather than read as the top of the Drive (CLAUDE.md §12: never guess), since that would put a project’s takes loose among the person’s own files; a folder id this machine remembers is checked before it is used, and walked again by name when the folder it named has gone. The folder itself is made by the first upload,
not when the dialog closes (D41), so a New project that was cancelled
leaves nothing in the person’s Drive, and a Drive root is available as
soon as Drive can be reached rather than only once something is in it.
list answers every file under a folder, the folders of it included,
which is the question the folder store answers for the same call: two
stores of one interface must not answer differently. Takes on a Drive
root are not discovered all the same (D23): the loader lists a
ProjectSource, and a Drive root is a store. Uploads are still reached
through refs and the batch records, which are in the project folder.
6.11 Plug-ins — filmopen_plugins
Section titled “6.11 Plug-ins — filmopen_plugins”Milestone 5.1 (docs/FilmOpen-Milestone 5.1 Plan.md), built step by step; this section grows with it. A plug-in is one folder, plugins/<tag>/ — in the shared library, in a project (a reserved folder, Project Specification §4.2), and in the repository for the stock ones — holding its manifest pg_<tag>_<author>_v<n>.json, its code .js beside it, l10n/<locale>.arb, and optionally README.md, platforms/<id>.json and assets/ (Project Specification 1.11 §14.4). plugins/README.md is the guide for people and coding agents, plugins/filmopen-plugin.d.ts the host interface as types, and plugins/template/ the plug-in to copy.
The manifest (plugin_manifest.dart). PluginManifest.parse(json, knownPlatforms:) never throws. It answers a PluginManifestParse: the manifest, or null when the plug-in cannot run on this host, and every PluginIssue found — a code (PluginProblem), the dotted field it concerns and the values a sentence needs. Refused (no manifest): a file that is not pg, a tag that is not a segment or is app (the owner of the app’s own keys), an author that is not a handle, a missing or mistyped v, name or api, an api outside supportedPluginApis (apiUnsupported, carrying the manifest’s version and the host’s), and an entry that is not a .js file beside the manifest (absent, it is <stem>.js). Reported and left out, the rest read: a field the manifest does not know (the common header’s fields and plugins are known), at its top or inside a key slot, a setting or an action — a mistyped defualt among them —, a key slot’s or a setting’s label or help that is not text, a number’s min above its max (both bounds left out), a hook (unknownHook, entity or platform), a setting type (unknownSettingType), a setting, action or key slot that cannot be used, a malformed key id in uses, an entity type the format does not know, entitySettings for a type applies does not name, a host in capabilities.network that is not a host name, a default of the wrong type (the setting kept without it). A key slot naming a platform no platform file defines is reported and kept. Settings (setting_spec.dart) are string, text, number (min, max), boolean, enum (values) or model (category, platform), each with a label and help that are locale keys and a scope of project (the default) or machine; an entity setting has no scope. Setting keys and action calls are identifiers, action ids and slots segments.
Keys (key_id.dart). A key’s id is <owner>:<slot>, both segments: the tag of the plug-in whose manifest declares it in keys, or app for the application’s own. Its store name is filmopen/<owner>/<slot> and its preference providerKeys.<owner>/<slot> — the status, the date and the last four characters, never the key (§6.7).
Strings (plugin_strings.dart). A locale file is ARB-shaped and read by the format’s own reader, so a repeated key refuses the file (notLocaleFile, as §18.1 refuses one anywhere): @@locale must be its file’s name (localeMismatch otherwise), @key metadata is skipped, a key holding : is refused (colonInKey). PluginStrings.t(key, locale:, args:) reads an unprefixed key, or one prefixed with the plug-in’s own tag, as the plug-in’s; app:<key> through the application’s lookup; any other namespace as nobody’s. It falls back from the locale (pt_BR read as pt-BR) to its language to en to the key itself, reporting each missing key once (plugins.missingString), and fills {name} placeholders, leaving one without a value as written. textOr shows a manifest’s name or description as a key where the strings have it and as written where they do not.
The package (plugin_package.dart). PluginPackage — tag, list(), text(path) — is the one interface the loader reads a plug-in through (plan §3.11); SourcePluginPackage is a folder inside any ProjectSource, and SourcePluginPackage.tagsIn(source) the plug-in folders under plugins/. PluginFolder.read(package, catalogueIds:) reads every manifest at the folder’s top (a stock plug-in and a person’s fork share a folder), the locale files of the first manifest’s l10n, and the plug-in’s own platform files, refusing one for an id the catalogue defines (platformConflict); a folder without a manifest (noManifest), a manifest whose tag is not its folder’s or whose stem is not its file name (folderMismatch), a manifest whose entry is not in the folder (noCode, the manifest still listed so that its page can say why it cannot run), a folder with no en locale file (noEnglish), a file that cannot be read as text — bytes that are not UTF-8 — (unreadable, with the failure’s type, the rest of the folder still read), and a document that does not parse are reported by the path they concern. The code is not read. PluginManifest.isByStockAuthor says only that the author is filmopen, the stock plug-ins’ handle; it grants nothing, since anyone can write under it and the template does.
The engine (filmopen_js). runJs(JsCall, doors:, cancel:) runs one call in an isolate made for it (Isolate.spawn), in a QuickJS-NG runtime made and freed for it: the script is evaluated, the function of its value called with ctx and the JSON arguments (or ctx alone, for an action), its promise awaited, and the answer is JsValue or JsFailure with a code — failed (the script’s own message, its author’s words for the person), limit (time, memory, stack, result, wall), cancelled, hung, noFunction, notJson, stalled (a promise nothing can settle), engine. The C shim (src/filmopen_js.c) keeps every JSValue, the budget and the cancel flag, so Dart passes only UTF-8 strings and integer handles and no Dart callback runs inside JavaScript. Before the script, a prelude builds ctx on one host function and removes eval, Function — the global, and the constructor of every kind of function — and Math.random; the runtime has QuickJS-NG’s base objects — §14.2’s list and the language’s other plain built-ins (Reflect, Symbol, BigInt, the weak collections, queueMicrotask, …), none of which reaches outside the runtime — and no Date, module loader, timer or I/O (Project Specification §14.2). The prelude captures JSON’s functions, Reflect.apply and the error constructors before the plug-in’s file runs, locks Error.prepareStackTrace — whose call sites would otherwise hand a script the functions on the stack, the host’s among them — and evaluates to the runner, which the shim holds: nothing of the host is on the global or reachable from the script. The runner reports through a host function, and the shim writes the outcome’s JSON — the value by the engine’s own serialiser, the message as a JSON string — so a script decides only its own value and its own failed message; a thrown error’s code is kept only as refused or badArgs, which doors give, and cancelled, limit, hung, stalled and engine are the shim’s and the runner’s alone. A request is written to JSON by the engine’s own serialiser in C, never by the script’s JSON, so its name and id are the shim’s, and the runner answers only the door names ctx uses; its bytes count against the memory limit until its door answers, a request is at most 16 MB and at most 10,000 wait at once. A microtask that throws outside the call’s promise is dropped, not the call’s outcome; a log or progress made just before a call ends still reaches its door, and no other request queued then is started; a cancel that comes before the call’s isolate has started is delivered when it starts. A script can throw an error that reads as a stack overflow, which only mislabels its own failure, since QuickJS-NG raises one as an ordinary RangeError; the capped stack protects the process either way. Every C string is read as UTF-8 with malformed bytes replaced, and a result is measured in bytes before it is copied. ctx’s asynchronous members are requests queued in C and answered by the caller’s JsDoor on the caller’s isolate — where plugin channels and providers are (D7) — and a JsDoorRefusal reaches the script as an error with its code and reason, while anything else a door throws arrives as failed without its text; sleep is answered in the call’s isolate; settings, plugin, selection and l10n.t read the call’s data synchronously, a missing string reported through a door. Limits (plan §8): memory is counted by the runtime’s own allocator, so running out is known for certain and ends the call even when the script catches it; the JavaScript stack is capped at every entry by what the current thread really has below it, less 256 KB (a 1 MB limit crashed a Linux isolate thread whose stack was smaller); engine time is counted only while JavaScript runs, never while it waits on a door, and the interrupt handler ends a call past its budget or cancelled — uncatchably; the wall clock and the result’s size are the Dart side’s. A cancel sets the C flag at once, from any thread, and wakes a call waiting on a door or asleep; a cancelled call that has not ended within 5 s answers hung and is left, since an isolate inside C cannot be killed.
The host (filmopen_plugin_host, milestone 5.1 step 3). lib/main.dart installs createPluginRuntime() into the state’s PluginRuntime seam (§7.1) — the host where there is dart:io and dart:ffi, the seam’s NoPluginRuntime on the web — and attaches it to the container, as the automation host is. Nothing runs at start: the runtime reads manifests and code as text and installs the stock plug-ins’ files; JavaScript runs on a gesture. The host reads the app through a HostEnvironment (the container’s, or a test’s in memory).
- The stock install. On its first load the runtime runs
installStockPlugins(filmopen_plugins/stock_install.dart) over the library with the bundledplugins/assets: a file the library lacks is copied, a stock file whose bundled text changed is replaced, a stock file the bundle dropped is removed unless the person changed it, and nothingplugins/.stock.jsondoes not list is touched; a deleted stock folder comes back unless the person disabled that plug-in. The project is re-read when anything changed, since the library’s index was read before the files were there. The template is stock and installed off (the note’s D11). - The registry (
plugin_registry.dart) reads every plug-in folder of the open project’s folder and the library, a project’s own first (§14.5). The version that runs is the format’s resolution of thepgentity for the viewer (§6.1); where that decides nothing, the stock author’s highest, else the folder’s highest (D13). A folder in the open project under a stock plug-in’s tag is not read: keys belong to the tag, and a film that arrives withplugins/default-ai-assist/must not take over the person’s keys or the app’s own assistant. A plug-in is trusted when its running manifest and code are exactly the files the stock record hashed (D14): it is on unless the person turned it off, and itsusesare granted as installed. Any other is off until the person allows it: Allow stores the SHA-256 of the manifest and code (plugins.consent.<stem>), and a change to either turns it off and asks again. The preferences areplugins.disabled,plugins.enabled,plugins.grants.<stem>(key ids),plugins.consent.<stem>andplugins.prefer.text/.media(a tag). A key’s platform is its owner’s declaration, and only an enabled owner’s: a disabled owner’s section is gone from Provider keys, so its keys are nobody’s to send. The keys a plug-in may send are its own slots and each granteduseswhose owner is enabled and still declares it. A plug-in one of whose calls hung (it did not end within the grace after a cancel; its isolate cannot be killed) is off, without touching the preferences, until the app starts again or the person turns it on or allows it again — their choice, which may leave a second hung isolate beside the first. Consent to other files than the ones allowed before takes back the keys granted then. - What the seam answers.
plugins(summaries in the app’s language: name, description, origin, enabled, consent, grants, hosts, types, hooks, machine fields with their values, actions, problems);keyOwners(Provider keys’ sections: the app’s ownapp:openaifirst, then each enabled plug-in’s slots);sectionsFor(type)(each enabled plug-in’sentitySettingsfor an entity type, or its project-scoped settings for the project file);providers(hook); and the changes —setEnabled,allow,setGrant,setMachineSetting,setPreferred,installArchive(a zip’s text files written as text and anything else, a picture inassets/, as its bytes) — each logged (plugins.enabled,plugins.disabled,plugins.granted,plugins.settingChanged,plugins.preferred,plugins.installed). - A call (
_run) runs a plug-in’s function throughrunJs(the engine, above) — one call at a time per plug-in and at most five in the app — a call stopped before or while it waits for one of the five leaves the queue at once and never starts, and a run cancelled before its isolate began spawns none — a call made throughctx.ai.complete— whose reported cost is added to its caller’s usage record, an action’s own answer being a message — running inside its caller’s slot, so a plug-in asking the text model it itself provides never waits for itself, and bounded by its caller instead: a call’s asks run one at a time, a call made that way may not ask again (nested), and a caller’s end — an answer, a limit, a cancel — cancels its nested call and closes its usage ledger, so no request on a key starts after it — withctxbuilt from its settings (each default, then the project file’splugins.<tag>for the project-scoped, then this machine’s for the machine-scoped), its strings resolved for the app’s language (its own keys, and everyapp:<key>its code names, fromfilmopen_l10n’s generatedappStringsByKey) and the selection. The outcome’s code is logged (plugins.limit,plugins.failed,plugins.cancelled,plugins.hung— which also turns the plug-in off); the plug-in’s own message goes to the page, never to the log. - The doors.
httpisPluginHttpDoor(http_door.dart): with a key, the key’s platform file gives the base URL forpath, or aurlmust behttpson itshosts; the credential is read from the store underfilmopen/<owner>/<slot>and put whereauth.headersays, a header of the plug-in’s own by that name dropped; a redirect is followed only to another of the platform’s hosts, overhttps, five at most; refused, with the reason the script receives, are a key not the plug-in’s own nor granted (no-grant), a key whose platform has no file (no-platform), no key stored (no-key),http(insecure), a host not listed (host), a redirect off them (redirect), a store that cannot be read (store), an answer over 16 MB (tooLarge), a timeout (60 s, ortimeoutMsup to 300 s) and a failed connection. The credential never reaches the plug-in:set-cookie,authorizationandwww-authenticateare withheld from the answer’s headers, and a credential is replaced by<key>in the answer’s text, its headers and every string and key of its decoded JSON — as sent, and in the forms an echo may have encoded it that the plug-in could decode: JSON escapes (\u0066…,\/), percent-encoding and HTML character references, the text then handed over decoded. Without a key, onlycapabilities.networkhosts overhttps, with no credential.keysanswers each key the plug-in owns or asks for, whether it is allowed and stored and when it was verified, from the Provider keys states.project.manifest,.get,.entitiesand.resolveread the open project as the viewer resolves it;storage.get/.seta JSON file of at most 256 KB, and machine settingssettings.json, in<app support>/plugins/<tag>/(pluginDataDirectory, §6.4);asseta text file of the plug-in’sassets/;loga lineplugin.<tag>.<event>with its data when it is a small object;progressthe call’sPluginCallControl;ai.completethe preferred text provider’scomplete, the model the options’ or that plug-in’stextModelsetting — only for a plug-in that declarescapabilities.ai(shown on its consent) or is itself the preferred text provider; any other is refused (noCapability). A plug-in’sstorage.setcalls run one at a time. - Platform hooks (
call) run through the preferred provider for the hook’s kind —textforcompleteandestimateCost,mediaforrender— else the first enabled plug-in providing it. Every call a person starts — an action, an entity hook, a platform hook — keeps a usage ledger (usage_chain.dart, §6.7), and a call made throughctx.ai.completerecords into its caller’s: a chain per platform whose key the call sends,startedbefore the first request carrying that key —purpose.pluginis<stem>/<hook>of the plug-in the person ran (action.<id>for an action),entrythemodela platform hook was given, elseunknown,kindandmodel_idfrom that entry’s access row, the key’s last four, an estimate of zero onbasis: estimate(D16) — and its end once the call answers:succeededwith the plug-in’s reportedcostin USD where one platform was used,canceled, orfailedwith the outcome’s code. A request whose first record cannot be written is refused (usageLog). A call that sends no key writes nothing. A platform hook is handed its catalogue entry asargs.entry, the entry’s file as it is, so the plug-in reads the platform’s name for the model and its prices.render’soutputsarehttpsURLs the host downloads, without credentials, throughMediaImporterinto the media root as takes of the source entity by the derived handle<viewer>.<tag>, in akind: "plugin"batch naming the plug-in and the hook, each with its thumbnail (D9); the answer carries their references for the caller’s draft. verifyKeyruns the hook only of the enabled plug-in that owns the key and provides it for the key’s platform — a candidate key the person just typed goes to no other plug-in — with the candidate credential usable throughctx.httpfor that one call and read from nowhere else; nothing is stored by the host. The check gives up after 20 seconds, and the candidate writes no usage record (§6.7), though any other key the check sends does; it may not ask a model throughctx.ai.complete(keyCheck).- Entity hooks (
runEntityHook,transform): refused (applies) on a type the manifest does not name, and (library) on a library file, whoever calls, since the run writes beside its sources in the project; the selection’s JSON goes in asargs.entities; the returned entities are written byProjectWriter.writePluginRun(§6.1a) — refused asstalewhen a selected file changed during the run, with only the project-scoped settings recorded in its batch, since a machine setting stays on the machine — and the project re-read.
The zip (plugin_archive.dart). PluginArchive.read(bytes, expectedTag:) reads a plug-in’s zip into memory and refuses it whole — nothing kept — when it does not read as a zip or holds nothing, claims more than 64 MB unpacked or 2,000 files, has an entry that would land outside the folder (absolute, a drive letter, a backslash, an empty, . or .. segment) or a link (by its target or its Unix mode), or has no manifest at the folder’s top, manifests of two tags, a folder not named after the tag, or a tag other than the one expected. Each entry is inflated by the archive package’s pure-Dart inflater into a buffer bounded by what remains of the 64 MB — never through entry.content, which on dart:io hands the whole entry to zlib and writes it out only once it is complete — so an entry whose directory claims a few bytes and inflates to gigabytes stops at the limit; an encrypted entry, one compressed other than stored or deflated, and one whose bytes do not match its CRC-32 — a download cut or damaged on its way — are unreadable. What an operating system adds as it zips a folder — macOS’s __MACOSX/ and .DS_Store, any hidden name — is left out, as a folder source skips hidden entries. The folder’s contents may sit at the archive’s top or inside one folder named after the tag. PluginArchive.write makes one.
7. State: providers
Section titled “7. State: providers”| Provider | Type | Responsibility |
|---|---|---|
projectProvider |
AsyncNotifier<FilmProject> |
Loads the sample at start — as a writable session copy on every platform, whatever development mode says (ProjectController.sampleOpensAsSessionCopy, milestone 4.6 items 9 and 10: following the mode, whose switch is in the preferences every build shares, opened it read-only whenever another build had turned the mode off) — with the library from libraryProvider (which it watches: choosing another library folder re-reads the open project). openDirectory(path), openSample() replace the page (loading spinner) and, on success, put the folder at the top of the recent list; each ends a closed state. close() writes every open draft (OpenDrafts.settleOpen) and sets projectClosedProvider, logged as project.closed. createProject(...) writes the folder (or a session-only project where there is no disk) and opens it, returning the new pj stem. createEntity(type:, tag:, epoch:, name:, kind:) writes a new character or location as the viewer’s v1 (§6.1a) and re-reads, returning its stem. refresh() re-reads without entering a loading state, so the current project stays on screen; of two overlapping re-reads the one started last wins (a read generation, which opening another source also bumps), and refresh answers whether its index was taken — an overtaken read replaces neither the state nor the base index. selectProject(stem) answers the chooser. copySessionTo(parentPath) keeps a session copy: every text file it holds is written into a new folder named after the project (creator.copyProjectTo, refusing a folder that holds files), and the project is opened there. saveEntity, fork, setOfficial, setPick call the ProjectWriter with the viewer from Settings and then refresh(); saveEntity answers the file as re-read, or null when its re-read was overtaken, so a form’s saver checks its next write against the text it wrote; setOfficial removes the director’s own pick at that epoch (the star alone then marks what they use), setPick on the version that is already official removes the pick instead of pinning it, and abandonPick / keepPick are the two answers to a pick an official version overtook — the second re-dating it; abandonPick answers false, writing nothing, when the viewer had no pick of their own there. Every load ends with FilmProject.forViewer on the index the loader built (kept as the base), and build listens to the viewer handle to re-apply it to that base without re-reading the folder. selectProject (the chooser’s answer) records the choice as the viewer’s pick when the project can be written and a handle is set, so it holds on the next open; otherwise it lives for the session. Every open, write, success and failure is logged. |
projectClosedProvider |
Notifier<bool> |
Close project (milestone 4.6, item 13): while true the tree pane shows its header and menu only and the detail pane Start a new project or open an existing one. The index stays loaded underneath and nothing reads it; opening or creating a project sets it false. |
projectSourceKeyProvider |
Provider<String?> |
Names which source is open (type and root path or label), unchanged by a re-read. The browser resets its navigation on this, not on every new index, so a page survives the re-read that follows a save. |
settingsProvider |
Notifier<Settings> |
themeMode, viewerHandle (empty = no viewer), sidebarWidth, locale (null = platform), verboseLogs (applied to AppLog.instance on load and on change), recentProjects (most recent first, at most twelve, each a {path, name} JSON string; unreadable entries are dropped), libraryPath (null = the app’s own folder), accountSignedIn (the §6.4 hint, setAccountSignedIn(bool), written by the account card and logged), warnInsteadOfRefuse (development mode, §9.5: setWarnInsteadOfRefuse(bool), logged; its default before anyone chooses is developmentModeDefaultProvider, true wherever the build has the mode — kDevelopmentModeAvailable, false in a release build or one built with --dart-define=FILMOPEN_ENFORCE_GUARDS=true — and the widget tests override it to false; Settings.guards is the policy every write applies). Persisted through sharedPreferencesProvider, which main overrides with a real store and tests leave null. |
libraryProvider |
FutureProvider<ProjectSource?> |
The shared library: the folder chosen in Settings; else the app’s library folder, seeded from the bundled copy on first run; else (web) the bundled copy read in place. |
recordingProvider |
Notifier<bool> (RecordingFlag) |
Whether the issue recorder is running (§6.8), set by main from the recorder’s own notifier and watched by what must not be in a recording’s pictures: a key box’s eye (milestone 5.01, §9.4). |
incomingLinksProvider |
Provider<IncomingLinks> |
The platform’s door for the links the operating system hands the app (§6.6, milestone 5.01): the sign-in’s return, on Android an intent. main installs the device’s; a test overrides this with a MemoryIncomingLinks it delivers to. |
launchArgumentsProvider |
Provider<List<String>> |
The process’s command line, overridden from main; empty in tests. On Windows a sign-in callback arrives this way when the app was not running (§6.6). |
preferencesLocationProvider |
FutureProvider<String?> |
The preferences folder, for Settings → About; null on the web. |
projectsInAppFolderProvider |
Provider<bool> |
Whether projects live in the app’s own folder — a phone — rather than in a folder the person names (milestone 5.01, D4; §6.4). The platform’s answer, overridable in a test. |
folderDialogsAvailableProvider |
Provider<bool> |
Whether the operating system offers a folder dialog whose answer is a path: Open project…, Copy to a folder…, the library folder and the media folder’s Browse are drawn only where it does (milestone 5.01, D5). |
projectsDirectoryProvider |
FutureProvider<String?> |
Where a new project is made where projects live in the app’s folder: projectsDirectory(), created; null where the person names a folder, and on the web. |
localizationsProvider |
Provider<AppLocalizations> |
The strings in effect for code without a BuildContext: the chosen locale, else the platform locale, resolved to a supported one by language code. Widgets use context.l10n; both resolve the same way. |
treeModelProvider |
Provider<TreeModel> |
Rebuilds the tree from the project, the viewer and the language. |
browserProvider |
Notifier<BrowserState> |
selection (what the detail pane shows), nodeId (highlighted row), toggled (rows flipped from their default expansion). navigate(selection, {nodeId}) is the only way to change what is shown; without a node id it looks the row up via TreeModel.nodeIdFor and expands its ancestors. toggle(node), setAllExpanded(bool). Resets to the overview when another source is opened (projectSourceKeyProvider), not on a re-read. |
treeFilterProvider |
Notifier<String> |
The sidebar filter text. |
navIndexProvider |
Notifier<int> |
Which rail page is shown. |
settingsTabProvider |
Notifier<int> |
Which of Settings’ tabs shows (SettingsTab.general, SettingsTab.keys). Settings’ tab bar follows it, so a link elsewhere can open Settings on the tab it needs (§9.4). |
catalogueProvider |
FutureProvider<Catalogue> |
The bundled model catalogue (Project Specification §14.6–§14.9), read once per run through the format’s one reader (Catalogue.read over filmopen_platform’s readBundledText): the index’s listings, every platform file pending or not, and every entry with the JSON its file holds. Provider keys, dictation and the plug-in host take what each needs from it. A file that could not be read is logged catalogue.unreadable, an issue inside one catalogue.issue, each by its code and never a message. |
providerKeysProvider (filmopen_keys) |
Notifier<Map<String, KeyState>> |
Each platform’s key state, read from the preferences unless the credential store is ephemeral; Save and Remove, one action per platform at a time (§6.7). |
providerCatalogueProvider (filmopen_keys) |
FutureProvider<ProviderCatalogue> |
Out of catalogueProvider: the platforms that are not pending and take a key, in the index’s order, and how many are pending. A platform whose card would repeat another control’s identifiers is left out and logged. |
Expansion state stores toggles away from the default rather than the expanded set, so a freshly loaded tree keeps sensible defaults (Script expanded, Characters expanded, scenes collapsed) without a reset step.
mediaStoreProvider (filmopen_state/media_provider.dart) answers the store of the open project’s media root, or why there is none — unsupported for a locator type no source is registered for, unavailable for a folder the loader could not open. It is derived from projectProvider, so every load, every re-read and every change of viewer answers a store of that project’s own. Null while no project is open.
7.1 The seams for the agent layer — filmopen_state
Section titled “7.1 The seams for the agent layer — filmopen_state”docs/FilmOpen-MCP-Plan.md drives the running application from an MCP server through Flutter’s driver extension; none of that lives in the product. What the product carries is five seams with no-op or silent defaults, in filmopen_state so that every package above it can reach them and no product package ever imports the agent’s own packages (filmopen_automation, filmopen_mcp):
AutomationHost(automation_host.dart) — what an agent may ask:state,navigate,setTab,tap(identifier),setField(stem, key, text),screenshot,semantics,logTail, the doors of the project menu and Settings (openProject(path),setViewer(handle),setLocale(code),setTheme(mode)). The recorder’s ops are not on this interface:Recording(record_start,record_stop,record_state) isfilmopen_automation’s optional host interface besideSemanticsTapsandAppExit, whichDriverAutomationHostimplements (§4.3, §6.8).AutomationHost.currentis theNoOpAutomationHost(isAvailablefalse, every answer “not available”) unless an entrypoint outsidelib/— the agent build,test_driver/agent_main.dart—installs another beforemainruns;mainbuilds theProviderContainerand hands it to whichever host is installed (attach).AutomationHost.rootKeyis theRepaintBoundarythatapp.dartwraps the page in, so a host can take a screenshot from the render tree on every platform;AutomationHost.windowKeyis the oneapp.dartbuilds above the navigator (MaterialApp.builder), the whole view with every route, dialog and menu, which the issue recorder shoots and whichpage_shotnow paints too (§4.3, §6.8).FieldRegistry(field_registry.dart) — the forms open for editing, by the stem of the file each edits. AFormSideregisters its own setter when it makes its saver and leaves when it drops it (by identity, so a departing form never removes a live one’s entry). An agent’ssetField(stem, key, text)finds the field the form names by that key and calls the field’s own setter,FormFieldSpec.set— the same function itsonChangedruns, so a birthdate is parsed, an age becomes a number underappearance, an empty name is an inline error rather than a removal — after putting the text in the box; the autosave, the identity lock and the stale refusal then hold for an agent as for a person. A control that takes no text (the kind pull-down, the genre picker, the epochs editor) declares no setter, and it, like a key no field owns, answers false.DraftObserver/DraftObservers(drafts.dart) —onChange(entity, key, value): the saver tells the observers each change with its key (null when a copy or a block edit touched several) and, since the issue recorder needs to hear what changed and not only that it did, the draft’s value at that key, deep-copied once per observer so an observer can never write into a draft or into another observer’s copy; the value is null when the key is null.OpenDraftsin the same file is the set of drafts a form holds open, which a fork and Copy to a folder… settle through this seam without knowing the forms.RouteEvents(route_events.dart) — oneNavigatorObserverapp.dartinstalls on the app’s navigator, silent until something listens: a dialog, a menu or a sheet opening and closing, named by its kind from the route’s own type (never its type’s name, which a release build minifies) and by its name when it has one. The issue recorder is its one listener; a failing listener is logged by its error’s type and never reaches the navigator.PluginRuntime(plugin_runtime.dart, milestone 5.1, §6.11) — the plug-in runtime as the pages, Settings, Provider keys and the agent layer see it, in the state’s own types (PluginSummary,PluginSection,PluginField,PluginKeyOwner,PluginKeySlot,PluginProvider,PluginCallControl,PluginCallOutcome,PluginKeyCheck): the plug-ins, the key slots, a type’s sections, the providers of a hook, the changes Settings → Plugins makes, the calls —runAction,runEntityHook,callandverifyKey— andchanges, aListenable.PluginRuntime.currentisNoPluginRuntime(no plug-in exists) untillib/main.dartinstalls the host, which no product package imports (D12). Beside it,legacy_keys.dart:ensureLegacyKeysMovedmoves 4.9’s key names once, andmovePreferencestheir states; step 4 of milestone 5.1 calls them from Provider keys, dictation and the key door together, and until then nothing calls them. The seam is deliberately the whole of what the app asks of plug-ins, and its types are the state’s own — a page’s view of a plug-in, resolved and in the app’s language — apart from the manifest’s types infilmopen_plugins(the note’s D12, kept as D36 on 17 September 2026): the screens, the forms and Provider keys import neither the host nor the plug-ins package, and a fake runtime tests them; a plug-in feature therefore touches the seam, the host and a screen.- Semantics identifiers on the controls an agent drives (§13): what a recorder writes into a tape and replay acts on, so the product never contains a line that says “if recording”. The walk that finds a control by identifier or by label and names the one under a point (
filmopen_state/semantics_walk.dart) is shared byDriverAutomationHostand the issue recorder, so both name a click’s target the same way;selectionToJson(selection.dart) is shared the same way, since the tape writes a selection too.
None of these starts a listener, takes a dependency or changes what a person sees; filmopen_state/test/seams_test.dart proves the no-op host does nothing, that the registry and the observers route as described, and that the route seam is silent with no listener and names what a listener hears; test/identifiers_test.dart proves the identifiers are unique and the registry’s typing reaches the box.
8. The tree
Section titled “8. The tree”8.1 Model — filmopen_state/tree_node.dart, filmopen_state/selection.dart
Section titled “8.1 Model — filmopen_state/tree_node.dart, filmopen_state/selection.dart”TreeNode: id (stable, path-like), label, detail (muted secondary text), tooltip, icon (a TreeIcon: an entity type, or a glyph for a grouping row — the model names the thing, and filmopen_tree/tree_view.dart chooses the Flutter icon, so the model imports no Flutter), selection (what clicking shows; null for pure groupings), children, initiallyExpanded, isOfficial (star), isPick (the viewer’s pick, a green check), isStalePick (the project made another version official after the viewer picked this one, §13.2: the label and the check are drawn in the error colour, with a tooltip), hasProblem (warning icon, error-coloured).
Selection is sealed: ProjectOverviewSelection, CategorySelection(type), EntitySelection(type, tag, {epoch, stem}), BatchSelection(stem), OtherFilesSelection. Each exposes matchKeys, most specific first (entity:<stem>, entity:<type>:<tag>@<epoch>, entity:<type>:<tag>), which TreeModel indexes so a link from the detail pane can find and highlight the right row even when it names a version the tree does not show.
8.2 Builder — filmopen_state/tree_builder.dart
Section titled “8.2 Builder — filmopen_state/tree_builder.dart”Top-level rows, in order:
- Project — name, tag, its
pjversions as children with the official star; flagged when a choice is pending. - Script — the selected project’s story roots (
seasons/episodes/sequences/scenes, whichever the file has), each unit’s own child list beneath it, in the file’s order. A scene gets Shots and Cues wrapper rows; any unit but a season gets Cues. A wrapper lists the unit’s explicitshots/cuesorder or, absent that, every<unit>.<n>group in natural order (the tooltip says which). Empty wrappers are omitted; units and warning rows never are. A unit with more than one version also gets a Versions row with one row per author (the same rule as under an epoch), so another author’s scene is one click away although the unit row shows the version in use; a Compare column heading opens its version as well. Unresolved references are warning rows with the reason; a unit that lists one of its own ancestors is a circular story reference row and is not recursed into. A unit resolved by falling back todefaultsays so in its tooltip. - Characters, Locations, Props, Styles, Misc, Documents — each entity by tag, then its epochs in the project’s declared order (§8.2), then one version row per author showing that author’s latest version (
author vN, with the version label orfrom <stem>as detail; official star; the version in use — the pick,pickAt— as a green check. Only an explicit pick wears it: official in use with nothing picked carries the star alone, and official comes before an author’s own latest (§6.1)). The tree does not list every iteration: beneath an author’s row sit only the older versions of theirs that still matter — the official one and the pick. An epoch (or an entity without epochs) where nothing is in use — several authors, nothing official, no pick — is flagged with the warning icon and says so in its tooltip. Dotted tags nest under their parent (outfits under a character as Outfits, areas under a location as Areas); one whose parent file is missing is listed at the top level with a warning. The same version rule applies to the project’s own versions and to models, platforms and plugins. An entity row is named by the version in use (FilmProject.nameOf: the pick at the project’s default epoch, else at the entity’s first epoch), and so are the epoch overview’s heading and the category lists, so a rename in the version being used shows everywhere the entity is named as a whole. - Models, Platforms, Plugins — entity, then versions.
- Render batches —
r<id>with author, kind and item count. - Other files — present when the folder holds companion or unknown files.
A pick an official version overtook (Pick.staleSince) is drawn in red on the version row that carries the check and on every row above it up to the entity — the author’s latest row when the pick is an older version beneath it, the epoch row, the library or spec entity row, the story-unit row and its Versions wrapper, a shot or cue row (which has no version rows of its own), the Project row — so a collapsed tree still shows that a page has something to ask. A unit row that shows a version pinned by a full reference is not the pick and is not painted, although its Versions row still is; the Shots and Cues wrappers, like the category rows, sit above the entity and are not painted. Nothing about resolution changes: the pick is still what the row shows (§7).
Every visible string comes from the AppLocalizations the builder is given.
8.3 Widget — filmopen_tree/tree_view.dart
Section titled “8.3 Widget — filmopen_tree/tree_view.dart”A flat, virtualised ListView over the visible rows. A row shows indent, chevron (toggles), icon, label plus muted detail as one rich text, then the star, the check or the warning icon. A stale pick paints the label and the check in the scheme’s error colour and puts the reason in a tooltip on both. Tap selects when the row has a selection, otherwise toggles. There is deliberately no double-tap handler: it would delay every single tap by the double-tap timeout. With a filter, only rows that match or have a matching descendant are shown, fully expanded.
9. The user interface
Section titled “9. The user interface”9.1 Window
Section titled “9.1 Window”Dashboard: a 64-pixel rail (Project, Settings, and the ? — nav.help — which opens the support dialog, screens/support_dialog.dart: a Get Support tab whose Record an issue (support.record) starts the issue recorder (§6.8), an About tab with the app’s name and version, the specification’s version and address and Open-source notices (support.licenses), and Close (support.close); while a recording runs the ? is a red stop button, disabled with a tooltip while one is starting or stopping; Settings keeps its own About card) beside an IndexedStack of pages. Material 3, ColorScheme.fromSeed, compact density, light and dark themes, and an AppColors theme extension for the two semantic colours the scheme lacks (the official star, a positive rating).
The browser page has two layouts (filmopen_browser/browser_page.dart, milestone 4 D11). At 700 px of window width and above it is a SidebarSplitView (sidebar 220–560 px, seeded from settings, saved on drag end) with TreePane left and DetailPane right. Below it the detail pane has the page to itself and the tree is a 280 px drawer behind an icon at the top left; choosing a row in it navigates and closes it, while opening a branch does neither. The system’s back — Android’s gesture, the browser’s Back in the web preview — closes the drawer when it is open and leaves the app when it is not (milestone 5.01, D2: a PopScope of the page’s own, since Flutter’s drawer takes no back of its own). The rail stays: the drawer belongs to a Scaffold of the page’s own, not to the application’s, so it stops where the page does. Crossing the breakpoint keeps the detail pane’s element — it is held by a GlobalKey — so a draft being typed is not written out from under the reader and the tab they are on survives the resize; the split is rebuilt rather than kept, because its controller is seeded once and would otherwise come back at whatever the layout last shrank it to.
The widths, and which axis each is on (filmopen_ui/theme.dart‘s Breakpoints). The drawer’s 700 px is measured on the window, because the tree is what moves and the reader resizes a window; every other number is measured on the pane or the card that holds the thing. The page is always 65 px narrower than the window — the rail and the divider beside it — so a LayoutBuilder answers the second kind of question and MediaQuery the first. Below 560 px of pane a form puts one field on a row whatever width the field asked for; below 300 px a comparison gives away the page’s padding and the cards’, which is 72 px back into its two columns; below 240 px it scrolls sideways at the width where it last fitted; below 260 px a label sits above what it names rather than beside it — the version row’s chips, the epoch chips and a nested attribute row; below 360 px the tree’s header puts the viewer’s handle under the project’s name (§9.2); below 720 px the project overview puts its cards in one column; and 260 px is what a card in a grid of them asks for, which is a width rather than a threshold. The web build carries no viewport meta tag of its own, as Flutter’s template does not: the engine writes one at start-up and warns on every load if it finds another.
Heights matter too, and for one reason. A header above an Expanded takes the height it asks for and leaves the body the remainder — and the headers here grow as they wrap, so at a narrow pane the remainder was nothing. Two of them now yield: the entity header above the tab bar takes what is left once the tabs have kept 240 px and scrolls inside that, with a bar to say it is cut — keeping 80 px of its own on a page too short for both, so that it is cut rather than gone; and a comparison’s column headings scroll with its rows once the page is under 240 px tall, instead of standing above them. Compare’s rows not being drawn at all — for three critic rounds — was this, and nothing else.
What a narrow pane costs, and what says so. A Row reports that it overflowed; a Wrap does not, and the difference is not what it first appears. A Wrap does bound its children to its own width — a card that asks for 260 px inside a 215 px row is given 215 — so nothing paints outside. What it does instead is crush: at a 74 px row two chips that want 60 px each come out two pixels wide, one to a line, and no console line is printed. That is why the version row and the epoch chips put their label above their chips below 260 px rather than beside them: a 72 px label column beside a Wrap takes the last of its width, and what is left is unreadable rather than absent.
9.2 Tree pane
Section titled “9.2 Tree pane”Header with project name and subtitle (folder path, or “bundled sample”), a chip showing the viewer handle when one is set — beside the name where the pane is at least 360 px wide and on a line under it where it is not, since a 120 px chip and three buttons beside the name left the name eight characters at the sidebar’s 220 px minimum — expand-all / collapse-all in 32 px buttons rather than Material’s 48, and the project menu (⋮): New project… and Open project… (the latter where a folder dialog exists — folderDialogsAvailableProvider: the desktops, not a phone, §6.4), Close project (milestone 4.6, item 13: the drafts written, then a blank page saying Start a new project or open an existing one; absent while nothing is open, as are the two items after it), Copy to a folder… (for a project held in memory — a session copy — wherever a folder can be named: the desktops, not a phone), Re-read files, then a Projects section listing the bundled sample and every folder opened before (name, path — on a phone the path after the app’s projects folder, …/<tag>, since every project’s begins the same), the open one ticked. Choosing a remembered folder that no longer exists removes it from the list with a snackbar, and the project on screen stays. Each remembered folder carries, at its right (item 14), an × that takes it off the list and leaves the folder alone, and — where folders can be made — a red trash can that opens Delete this project: a sentence that the checked folders and everything in them go for good, then two checkboxes — Data files: <the project folder> and Media files: <the media folder the manifest names> (mediaFolderOf: a local locator resolved as the loader resolves it, a URI never; disabled, saying the project names none, when there is none here, and disabled with the reason when it may not be deleted from here) — and Yes, disabled until one is checked, and No, which does nothing (the owner’s answer to milestone 4.6’s question 1). Yes checks every chosen folder first (isDeletableProjectFolder; isDeletableMediaFolder, which refuses a link — checked without a trailing separator — and a drive root, and anything that is or holds the project folder, the user’s home folder or another remembered project, so a manifest naming ., .. or a folder far above takes nothing), closes the project if its data goes and it is the open one (its drafts written), deletes the media folder (deleteMediaFolder) before the data folder (deleteProjectFolder), takes the row off the list only when the data went, re-reads an open project whose media alone went, with a message either way; a refusal is decided before anything is closed (isDeletableProjectFolder), the open project is recognised by sameFolderPath (case and a trailing separator aside on Windows), and a delete that fails after closing reopens what is left. The project overview no longer shows where the media root is (its chip went with item 5); the loader still reports a missing one as a warning. deleteProjectFolder refuses (NotAProjectFolderException), touching nothing, a path that is not a real folder — a link included —, the root of a drive or file system, or a folder without filmopen-project.json or a pj_*.json at its top; it deletes only that folder, never a media root the manifest names beside it. Logged as project.removedFromMenu, project.deleted, project.mediaDeleted, project.deleteRefused, project.mediaDeleteRefused, project.deleteFailed, project.mediaDeleteFailed. Below the header, the filter field and the tree.
New project dialog (filmopen_tree/new_project_dialog.dart): parent folder (desktop only; typed or browsed; pre-filled with the folder of the last opened project — on a phone the row is absent, the parent is the app’s projects folder of §6.4, and a sentence under the first says so: Projects live in FilmOpen’s own folder on this device, and their media beside them; while the folder is being found Create waits and its tooltip says so, and a folder that cannot be made is a line under the form, project.folderUnavailable in the log, and the same tooltip), the short name in a ShortNameField, an optional title, the username (a ShortNameField accepting owner[.workspace], pre-filled from Settings, mandatory: “Lowercase letters, digits and dashes. All your files will contain this username”), the kind (a pull-down of §8.1), and the media folder — where the film’s rendered and uploaded media will live (§4.4, milestone 5 D8), drawn by filmopen_media_ui’s MediaRootPicker: with the local folder alone it is one row and, where a folder dialog exists, a Browse button, pre-filled with <parent>/<tag>-data and following the tag until the person types a path of their own, under the sentence Rendered and uploaded media goes here; the project folder keeps only text and thumbnails. An empty row means the project folder is its own media root, which is the specification’s default and writes no locator — an answer, not a silence: a source’s panel that has settled on nothing writes the same MediaRootChoice.none() and means the opposite, so a panel says which it means (MediaRootController.settle(choice, waiting:, reason:)) and Create waits while a source has no place yet, with the reason in its tooltip — the source’s own where it has one (a build with no Google client says so) and Choose where this film’s media will live first otherwise. Without that, choosing a source and pressing Create made a local project in silence; a folder under or beside the project folder is recorded relative to it and anything else absolutely — and the picker says so while the person can still change it (mediaFolderElsewhere, D34), rather than leaving it to the loader’s warning, which arrives after the project is made. A registered media source adds its kind to a pull-down (hidden while there is nothing to choose between) and shows a panel of its own; New project names no source. Drive’s panel (§6.10) is FilmOpen’s sentence about the scope, Sign in with Google (new-project.google-drive.sign-in), then Sign out of Google (new-project.google-drive.sign-out) with the sentence that what is in the Drive stays there, the parent among FilmOpen’s own folders (new-project.google-drive.parent) and the folder name (new-project.google-drive.name), which follows the tag like every other; a build with no Google client of the owner’s, and the browser build, say so in one sentence instead. It says which project file it will write and that the creator is its director, refuses a non-empty folder with a message, saves the username to Settings when it differs from the handle there, creates and opens the project, and the caller takes the user to the new project file’s page, whose Edit tab is first. On the web the dialog says the project lives in the browser session only.
ShortNameField (filmopen_ui/short_name_field.dart) is the one input box for anything that must be a segment (§5.2): it filters as the user types (lower case, spaces and underscores to dashes, other characters dropped, twenty at most), explains the remaining problem under the box, and warns above ten characters without refusing. With allowWorkspace it accepts one dot and validates both sides, for an author handle (§5.6). Any place that asks for a tag, an epoch token or a handle reuses it.
9.3 Detail pane
Section titled “9.3 Detail pane”Switches on the selection. When the project needs a choice, the Project chooser replaces everything: a card per pj candidate (name, stem, author, version, language, forked-from) that selects it.
- Project overview — name, logline (no chips under them since milestone 4.6: the stem, official, source, manifest and media chips are gone; a missing manifest is still a warning); synopsis; cards for Details (kind and genres in words), Contributors, Epochs (one chip per epoch in order — token, label, year — the first marked as default), Tracks; Contents of the folder as count chips that open the category (entities the library contributed are not counted here); a Shared library card with where it was indexed from and its own counts; Warnings rendered in the current language.
- Category view — a list of every entity of one type with tag, epoch and version counts and an “official set” chip. The character and location pages carry Add character / Add location (milestone 4.7;
category.add) beside the title and the count, and under the empty state when there are none; disabled with This project is read-only here on a project that cannot be written. It opens the new character / new location dialog (filmopen_browser/new_entity_dialog.dart), laid out as the New project dialog is: a sentence saying the short name is permanent because every file name of the entity contains it; the short name in aShortNameField, refused inline when the type already has that tag here or in the library; the name (optional — an empty box writes the short name as the name, since §7 requires one); the username as New project asks for it (pre-filled from Settings, saved there when it differs); the kind (a pull-down of A.1’s or §9.3’s vocabulary, nothing written when none is chosen); the epoch, only where the project declares several, the default first; and the file it will write. Create is disabled until the short name and the username are valid and the tag is free; a refused write is shown inside the dialog — a file of that name written since the index was read as the same already taken sentence — and a username the dialog put into Settings for the write is put back when nothing was written. Once written the page moves to the new version, whose first tab is Edit. - Epoch overview (
detail/epoch_overview.dart) — what a library entity chosen as a whole shows (no epoch, no version: the group is not a file, §8.2): one card per epoch in the project’s order, with the picture (no type or tag chip under the heading since milestone 4.6) (the version in use’spreview, else itspicks.sheet, else the first image take at that epoch), the epoch’s label, token and year, the age for a character (filmopen_format/character_age.dart:birthdateand the epoch’s year, overridden byappearance.age), the version in use and the version count. A card opens the epoch; the epoch row has an All epochs chip back. - Entity detail — header (the icon and the name, nothing else: milestone 4.6 item 5 took the type, stem, library, forked-from, updated and kind chips away); an Epoch row when the entity has several, in the project’s order; and the version row (
detail/version_row.dart), with no Version label beside it or inside its pull-down: the author of this version as text (other authors’ versions are one click away in the tree), a pull-down of that author’s versions (v2, orv2 · gothwhen labelled; star and/or check as trailing icons), the label button (item 3: right of the pull-down, left of the star; the author only; a small dialog writesversionLabel, §7, removing an oldlabel), the official star (a toggle: grey, yellow when set; enabled for directors of a writable project; setting writes the pointer, clearing deletes it), the check — the version in use for the viewer (§6.1 step 2): lit green on the pick, where pressing it only says why it is in use (your pick, you follow official, official, your latest, the only author); grey and clickable on every other version, where a click moves the pick here (on the official version a click removes your pick, since no pick means official — and where official is already in use the check is not lit and says so instead, unless your own file still holds a pick naming no version here, a missing file, which the check on the official version then removes); enabled for any viewer of a writable project, library versions included, since the pick is the project’s own commentary — and Fork. Nothing follows Fork (item 7: the No version in use, In use: and Official is chips are gone; the tree still flags an epoch where nothing is in use). Tooltips say why a control is disabled (no username, read-only, directors only). Then tabs:- The reference images (
filmopen_forms/media_section.dart, milestone 5 D17–D19) are the first card within View/Edit on each entity type that has been given it — the character and the location today — part of the view, not above it (the owner’s answer 9). A type gains it with one line in its tab (characterFormSections,locationFormSections), and a type that has not been given it, the project file included, simply does not have it; which is the measure of D17: what an image reference is for that type is the format’s to say (uploadPrefixFor: a picture of a place is a concept sheet like a picture of a person,cs, and a sound of a place is ambience,am, where a character’s is a voice sample,vo— the prefix names the purpose, not the origin, §5.5), so a tab knows nothing of media beyond the line. The card ownsrefsandpreview, which therefore leave Other attributes, and shows what the file names as a wrap of 120 px tiles (filmopen_media_ui/media_tile.dart): the thumbnail the project folder holds, or the kind’s stock picture where there is none — never nothing, and never an error (§6.3) — with a badge for a clip or a sound and the name the file had as its tooltip. A tap opens the viewer (media_viewer.dart): the picture at full resolution in anInteractiveViewer, the file’s name, kind and size, Show in folder where the file is on this machine (player.reveal), and Close (player.close). A clip or a sound opens in the platform’s own player where there is one (player/media_player*.dart: Windows plays a file on disk through Media Foundation, and only the decoder is Windows’s: the controls row is a widget of its own (player/player_controls.dart, over aPlayerNowof position, length and whether it is playing), so what a person presses is pumped at 700 px rather than stood in for.player.play/player.pauseare one control named for what pressing it does next;player.seekfollows the finger and asks the player for one seek when it lifts, holding the thumb where it was let go until the player arrives there or two seconds pass, and is disabled with its reason for a file that does not say how long it is — whose clock still counts up. The player is made when a tile is opened and never at start, so nothing constructs one in a widget test.) A file the system cannot read says so — the walk proves it with bytes that are not a clip under an.mp4name; the sample’s own placeholders are a clip with no tracks and a silent sound with no frames, and what a platform makes of each is the platform’s to say; a platform with no player says that instead, and offers Show in folder where the file is on disk. An address a file names is never fetched: the viewer shows it and says why (D37), since a reference may be a URL (§6.3) and a file is somebody else’s work. In Edit the last tile is +, which opens the upload sheet (media_upload_sheet.dart): a file on this machine, an address, and the doors of any registered source — it names none itself. Its primary button stays enabled and says what is missing under the box (There is no address here yet. for an empty box, An address starting with http:// or https:// for anything that is nothttporhttps), rather than being disabled until the box parses: a control whose enabled state follows the last keystroke cannot be pressed in the same breath as the typing, which is how both an agent and a person pasting with the keyboard work (D39). What comes back goes throughMediaImporterand then into the form’s draft, so the autosave writes the reference, the identity lock holds and a changed file is still refused; the index is re-read afterwards, since the take and its thumbnail are files of the project. + is drawn only where the form accepts input — on somebody else’s version, and where there is no handle, the form is not editable at all and the fork banner or Settings is the answer — and is disabled with its reason where the form accepts input but the media root cannot be reached or is of a kind no source serves (PageAccess, D18). A tile that cannot be opened says why in its tooltip, and a reference the file’s own shape will not take is said under the strip and stays said. Compare pairs the card by its id and a<adds the references the left version lacks and removes none (CopyReferencesinfilmopen_format/copy_left.dart), copyingpreviewas any other word and no bytes at all. - View / Edit (first, for every entity type;
tabs/entity_form.dart) — one presentation of the file’s data in two modes: Edit on a version the viewer may write, View on any other, the same fields in the same order either way. The form renders sections → fields, a field being the JSON keys it owns, a preferred width and a builder taking aFormScope(the file, the draft, whether input is accepted, whether the column is narrow, a text controller by key, a redraw, andlock); nothing in the layout names an entity type, so a type is added by supplying a sections function. The project, the character and the location have forms (tabs/project_edit_tab.dart,tabs/character_edit_tab.dart,tabs/location_edit_tab.dart); every other type shows the generic section oftabs/generic_view_tab.dart— one row per dotted leaf path of the file (filmopen_format/flat_json.dart, the rows Compare uses; a scene’s or an episode’s storyepochamong them, since the filename carries none —nonValueKeysFor(type)), label above value with the raw key on hover, values rendered as the attribute view renders them, plus one row per block id for a scene; read-only until milestone 5 gives each type a form. Whatever no field names is shown read-only in a trailing Other attributes card, which is absent when nothing is left. No sentence stands above the cards in either mode (item 4 and the owner’s answer to question 2): the tab’s title says View or Edit. Then the cards about the file rather than in it: Notes when the file has one, File (path,filmopen, the header dates, andby/source/license/xwhen present), and a red card listing header/filename disagreements. A locked field is read-only rather than disabled, so its text stays at full strength: it wearsAppColors.locked, floats its label, shows an em dash where it is empty, and drops the author’s validation errors, which only the author can answer; a locked choice is drawn as the box a pull-down leaves behind, and a chip inside a locked box carries an outline. On the first card’s title row, at its right — no row of its own, and a 28 × 20 px button that leaves the title row as tall as its title (item 1) — an overflow menu (detail/entity_file_menu.dart) holds Edit JSON… on the viewer’s own file (tabs/json_editor_dialog.dart: it opens on a tree of the file,voo_json_treewithJsonTreeConfig.full(), where a long value keeps to its row, cut short with an ellipsis, and a tapped value is asked for, whole, and written into the document — a number,true,falseornulltyped where one stood stays one, anything else is text, and a path two keys would spell alike changes nothing — and a Tree / Text switch shows the whole file as text for adding or removing keys, back to the tree only when the text parses (item 2); both edit the one text an explicit Save writes, a duplicate key refused before anything is written, the identity fields refused by the writer, a changed-on-disk file refused, a confirm before discarding changes — the form’s pending draft is written first, so the editor never opens on text the form has not saved), Show in folder (disabled with an explanation when the file is not on disk; absent on the web) and Copy JSON, which copies exactly what the writer would write. Fork is in the version row above the tabs, not in the tab. Every form autosaves throughtabs/draft_saver.dart: a working copy of the file’s JSON — a deep copy, as is every snapshot written, so nothing done to a nested object reaches the index — written 700 ms after the last change, one write at a time; the file the re-read hands back is adopted when it holds what this draft wrote (updatedaside), so the next write is checked against the text this draft wrote and an outside change landing in between is still refused; a saver is for one file: when the form moves to another file it gets a saver of its own and the old one is disposed, and a change typed while a write is in flight is written once, when that write returns, against the text it handed back — however many files the form passed through meanwhile, each keeps its own saver and its own typing (a write that fails after the form has gone is tried once more against the text the draft was read with, and a refusal then is logged, having no form to show it; §14); a re-read holding the write in flight is adopted as the draft’s own;settlewaits for a write in flight and writes what is pending, and every fork settles every open draft first (DraftSaver.settleOpen, fromWriteActions.fork, which also waits for the drafts forms left behind when they moved on or went away), so a fork carries what was typed a moment ago, and a draft that cannot be written stops it with a message saying so; flushed when the page is left, when the form moves to another file and when the app loses focus; a keystroke during a write keeps the draft dirty (a change counter); a re-read of the same file is adopted when the draft is clean or when it is the draft’s own write coming back, and kept out while dirty so an outside change is refused as stale rather than overwritten; a status on the first card’s title row that shows only a failed write — the error with Retry, or Discard my changes and re-read when the file changed on disk — and nothing while the draft is saved, unsaved or saving (item 4). Closing the window writes every open draft first (lib/app.dart’sDraftsBeforeExit: anAppLifecycleListenerwhoseonExitRequestedwaits forOpenDrafts.settleOpen, at most 10 seconds, then lets the app exit, loggingapp.exitRequested). A change that leaves the draft as it was — an empty box cleared, the same kind chosen again — is not written. The writer’s own checks apply to every form: a name is required (an empty box is an error, not a removal), a version label is at most 32 characters, a project keeps at least one epoch. The character form (tabs/character_edit_tab.dart, milestone 4.7) owns every key of A.1 that is typed rather than generated, in seven sections, each with a small icon badge on its title row (FormSectionSpec.icon, drawn bySectionCard.icon): Details — name, kind (A.1),birthdate(a year orYYYY-MM-DD, an impossible date refused), the age at this epoch (proposed from the birthdate and the epoch’s year, an integer typed there written toappearance.age, emptied to return to the proposal), summary, aliases (comma-separated),arc,defaultOutfit; Features (a face) —appearance.face, the four keys ofhair, the three ofeyes,skin,facialHair,teeth,glasses; Body —gender,species,ethnicity,heightCmandweightKg(numbers),build,posture,gait,hands,jewellery,marks(one per line),appearance.notes; Personality —summary,traitsandmannerisms(one per line),wants,needs,fears,speech; Voice —description,pitchandpaceas chips of A.1’s vocabulary,timbre,accent,lang; Relationships — one row per{character, relation}with a remove button and Add relationship (one empty row at a time; a row keeps any other key its object holds; a row emptied while it is retyped stays on screen, with its focus, until its × removes it, and the file leaves it out); Prompt —prompt.positive,prompt.negative,creatorNotes. What is generated or chosen from media —refs,picks,preview,voice.refs,voice.providerBindings— stays in Other attributes. The location form (tabs/location_edit_tab.dart) does the same for A.3 in seven sections: Details — name, kind (a pull-down of interior / exterior / both), Located within (within, Project Specification 1.9 §9.3: a pull-down of the project’s other locations by name, None removing the key; a tag no other location has — its own included — is kept, offered as an entry and said under the box; the names are the viewer’s version in use, as in the tree; an agent types a tag, and one that is not another location’s writes nothing; purely informational — the tree, the category page and resolution ignore it), aliases,description,era,architecture,areas(one per line); Geography —city,region,country,coordinates,access; Time and weather —timeOfDayas chips of A.5’s vocabulary,weather; Dressing and palette —dressing,palette(one per line); Lighting —default,practicals(one per line),windows; Sound —roomTone,ambience; Prompt — as the character’s. A box inside an open object is labelled without the object its section names (Hair · Colour, throughflatPathLabel). The boxes on dotted paths aretabs/form_fields.dart’s: a type’s form names each path once with what it holds (PathBoxes: text, number, lines), which gives the seed and the box its kind, and each box’sonChangedand itsFormFieldSpec.setare one function, so an agent’ssetFieldonappearance.hair.coloror on a chip’s key (typing the token) writes as a person does. The rules are pure Dart infilmopen_format/field_paths.dart: a box starts with a string as it is, a number as its digits, a list one item per line; typing writes the trimmed text, a number or nothing (text that is not a number writes nothing), lines without the blank ones, creating the objects on the way and copying every container it descends; an emptied box removes its key and every object the removal leaves empty (and clearing what was not there changes nothing); and a box never writes over a value of another shape (pathFits: an object on the way that is not one, an object or a list where a word goes, a list holding an object) — such a field is drawn locked in Edit too (FormScope.lock(locked:)), with The file holds this value in another shape; change it with Edit JSON — the value itself, where the path reaches one (an object where a word goes), rendered in the field’s place (ShapeLocked), and an object on the way that is not one (appearancewritten as a sentence) shown in Other attributes. The age box follows the same rule: it never puts an age into anappearancethat is not an object. The boxes carry no prefix icon (an icon moves the label off the column the others share and makes the box taller), and their widths are shares of a four-column grid (FormFieldSpec.widthat 1 and below:0.25a column,0.5two,0.75three; two columns where one would be under 150 px), so a section’s boxes line up and end at the row’s right edge. A chip row shows a token the vocabulary does not know as a chosen chip of its own; tapping the chosen chip removes the key; locked, it is the chosen word in aLockedValue. Compare pairs the new sections by id (features,body,personality,voice,relationships,prompt;geography,time,dressing,lighting,sound), draws them without the icon badges, and copies each box’s path as it copies any row. The project form (tabs/project_edit_tab.dart): title, kind (pull-down; changing it moves an empty story-root list to the new kind’s key), year, rating, genre (filmopen_ui/genre_picker.dart: a pull-down of check boxes over the recommended list plus whatever the file names, shown as chips), logline, synopsis, and the epochs (tabs/epochs_editor.dart: a reorderable list with a drag handle, label and year boxes — in a card narrower than a form’s single column, the label beside the handle and the remove button and the year under it — no explanation above them and no token chip or default badge, item 12 — a remove button disabled for an epoch that files use — by filename, by a scene’s story epoch orlocationEpoch, or by a cast or props entry (FilmProject.epochUsage) — or the last one, and Add epoch with a small dialog — token, label, year). Every change re-encodes the draft and saves it 700 ms after the last keystroke; a failed write shows its error on the first card’s title row; a pending change is written when the page is left. An official version shows a banner offering Fork. On somebody else’s file, or a read-only project, the tab says so and offers Fork. - Script (scenes only) — the blocks as a screenplay page: block ids in the gutter, action, centred character cue with
(O.S.)/(V.O.)/^, parenthetical direction, transitions right-aligned, titles, notes boxed. - Compare (
tabs/compare_tab.dart,tabs/compare_form.dart) — exactly two versions: the viewer’s own latest version at this epoch on the left (EntityVersions.highestByunder their exact handle — a workspace does not inherit its owner’s versions for this), and the version they selected on the right, whatever it is. The others are reached by selecting them. Both columns are the View/Edit layout oftabs/entity_form.dartin single-column mode, so a field looks the same here as on its own page; the right column is always locked, the left is editable when the project can be written and the file is not the library’s, and only the left carries a save status, which shows only a failed write (item 6). It is one scroll view of paired rows, not two forms side by side: sections pair by id, fields pair by id within them (filmopen_format/flat_json.dart’smergeOrderkeeps a key only one version has beside its siblings rather than below everything), and the taller cell sets the row’s height so the next pair starts level on both sides. A key the other version does not have draws Not in this version rather than a gap — an em dash would mean the key is there and empty. Down the middle runs the 28 px gutter of copy-left. The column headings open that version; the right one carries the check and the star, the left — the viewer’s own — no pencil and no check, only the star when it is official (item 6). The intro line, the file menu and the official-edit banner are the page’s, not a column’s, and are not repeated here. - Copy-left (
tabs/compare_copy.dart,filmopen_format/copy_left.dart) — a<in the gutter of every row that has something to copy, a<<on each section card’s title row over the same gutter (item 11:SectionCard.titleRowlays the title out on the rows’ grid, so the<<sits on the split and not at the card’s right edge) and a<<<over the split in the header, drawn as one, two or three overlapping chevrons so the three read as one gesture at three scales. A row offers a<when the right version has a value at one of the row’s paths that the left does not match, compared by content rather than by key order. A copy never removes a key from the left, and four things would: a key only the left has; an empty value of any kind —null, an object or a list with nothing in it, a string with nothing but space — which everywhere else in the app is how a key is deleted; a path that runs through a value the two versions disagree about the shape of, where writing the leaf would replace the left’s list or sentence with a fresh object; and an object whose visible remainder is empty, which is on screen only so that nothing of the file disappears. None of them offers a<at all — the JSON editor is the way through a disagreement that deep. The keys it will not move depend on the file (uncopyableKeys(entity)): the identity the filename owns,created,updated,forkedFrom,versionLabel(withlabel, its 1.5 spelling), andbyandsource, this version’s own provenance, in every file;epochonly where the filename carries one, since on a scene or an episode it is the story’s epoch (§10.2), a row that copies like any other; andcontributorson a project file, the roster and its director roles, which change through the JSON editor and never through a copy; everything else a row shows may travel, and<<<is exactly the rows’ own copies applied one after another, so nothing the screen did not offer is ever written — the Notes and File cards are about the file, not fields of it, and carry no control. A nested object copies path by path, which is a subtree without ever dropping the sibling a field of the form owns (appearance.age), and a list copies whole. A scene whoseblocksholds the ids a cue writes (§10.3) has no script to compare: both columns then showblocksas an ordinary value row and no block moves either way, because the two columns must answer that question together — one drawing a Blocks section while the other draws a value row would leave both rows without a twin, and the empty-handed side would say the key is not in the other version while it plainly has it. A scene’s script is copied by block id (§13.3’s table):<replaces, inserts — where the comparison drew the row, onmergeOrder’s own rule — or deletes that block, the one row whose right side is absent that still carries a control;<<and<<<replace and insert but never delete, so one press cannot take away every line only the viewer has written, and a block with no id, or an id that names two blocks in one file, offers nothing. Copies are not confirmed (§13.3): they are written through the left column’s own autosave, so the identity lock, the content check, the stale refusal and the 700 ms debounce apply unchanged, a whole card or a whole file is one write, and copying an equal value writes nothing. A copy that would break the file is refused before the draft is touched, because a draft cannot be unwritten: a project copy that would drop an epoch some file is at is refused naming the epoch and the way round it — copy the other rows one at a time — (filmopen_format/epochs.dart’sepochsDroppedInUseoverFilmProject.epochUsage, the same question the epochs editor asks of one row; a file is at an epoch its filename carries, the story epoch its content names, itslocationEpoch, and every epoch a cast or props entry of it names, each counted once per file), and so is anythingvalidateContentwould reject, in the writer’s own words. Each row’s answer is kept until the left draft (its identity and revision) or the right one changes, so a rebuild that changes neither — a theme, a resize — asks no row again;FormPaneindexes its sections and fields andmergeOrderis linear, since nothing bounds the blocks of a scene. One message names what moved and acompare.copiedlog line lists the keys, never the values. Where the project cannot be written, or the file is the library’s, every control is still drawn over what differs and every one of them is disabled with the reason. Where the left column is the version official names, the §13.2 banner sits above the rows as it does on the page — a copy is an edit, and it changes what everyone sees — and its Fork forks the left version, after writing what a copy left pending; the version row above the tabs forks the page’s version, which is the right column. A column that takes no typing shows its save status from the moment a copy gives it something to say until the write is done, so Unsaved changes, Saving… and Saved all reach a scene or a location. - Other attributes in Compare — the trailing card is a section like any other here, one paired row per key, because the keys no field names — on a project the contributors, the tracks and the story roots — are shown here and a
<needs a row to sit on; the tracks and the story roots copy, and the contributors never do (the roster is the project file’s own, and its row offers no control). The rows carry the paths the card actually shows, so copying a character’sappearancemoves what is in that card and not the age the form holds. - When Compare cannot open — (enforcing; in development mode the tab opens on the same reason as a red line, §9.5) the tab is greyed with a tooltip saying why: the selected version is the viewer’s latest, so there is nothing to compare it with; there is no username in Settings; or the project is read-only (or the file is the library’s) and they have no version of their own. A greyed tab stays hoverable so its tooltip shows, and a tap on it puts the page back where it was. When the only thing missing is a version of their own, the tab looks open and asks instead (
tabs/compare_fork_dialog.dart): You don’t have a working version to compare against. Would you like to fork one? Yes pins the selection to the version on screen — without that the new fork becomes the version in use and the page would re-resolve onto it, disabling the tab it was asked to open — then forks that version without navigating, and opens Compare once the re-read makes it openable. No goes back to the file’s own data, wherever Compare was tapped from. A refusal is shown inside the dialog, and the buttons are dead while the write is in flight, because a fork is not idempotent. The tabs have an explicit controller (detail/entity_tabs.dart) rather than aDefaultTabController: it also carries the selected tab across a change of the tab set, falls back to the first tab in the same frame when the current one stops being openable, and refuses a swipe, which would change tabs without ever asking. - Takes — every take discovered for any version of the entity, as cards (image preview for image deliverables, placeholder icon otherwise), with deliverable, take number, source version, batch chip (opens the batch), renderer, path; “picked” marks takes the chosen version’s
picksname. - Commentary — all authors’ commentary consolidated: a Picks card, then entries newest first with rating icon, author, date,
onchip, block/field chips, text.
- The reference images (
- Batch detail — header chips (stem, author, kind, tier, status, created, item count, total cost) and a table of items; the source column links to the entity.
- Other files — two lists: named by the grammar but not JSON; not named by the grammar.
A pick the project overtook (§13.2, §13.4): when Pick.staleSince is set for the viewer at the epoch on screen, a StalePickBanner sits above the header on every tab of that entity — the error-container card with the owner’s sentence, Yes, abandon this (the pick and its date are removed, the viewer follows official, and — as the question says, go to the official — the page moves to the official version and the message names it; where there was no pick of the viewer’s own to abandon, nothing is written and the message says so) and No, keep this as my pick (the pick is re-dated, so the pointer no longer overtakes it). Both go through WriteActions, so a refusal is a message and changes nothing, and both are followed by the re-read; the buttons wrap at a narrow width.
9.4 Settings
Section titled “9.4 Settings”Appearance (System / Light / Dark), Language (System / English / Español; language names in their own language), Identity (the author handle in a ShortNameField that takes a workspace and allows an empty box, with helper text saying whether a handle is in effect and, for one that is not a handle, why — official is reserved, one dot at most), FilmOpen account (§6.6: one Log in to filmopen.ai button with the logo that opens the sign-in dialog — Google, Discord, GitHub or an e-mailed code; signed in, the display name, sign out and links to the website — the handle beside it is what the app writes files as, the account is who you are on filmopen.ai; nothing local needs it), Shared library (what it is; the folder in use with a show-in-file-browser button; Choose folder…; Use default when a folder was chosen), Logs (what is logged; the Verbose logs checkbox; the log file’s path and a View log button that shows it in the file browser; on the web, a line saying entries go to the console), Development (only in a build that has development mode: what it is, and the Warn instead of refuse checkbox with a line naming the three rules it governs), About (version, specification version, URL, the preferences folder with a show button, Open-source notices → Flutter’s licence page, which includes the vendored API Dash licence).
Tabs (milestone 4.9; Plugins milestone 5.1): under the title, a tab bar with General, these cards, Provider keys, Usage and Plugins. Settings opens on General each time the rail shows it, since leaving Settings puts the tab back (Dashboard). The bar is a TabBar with no TabBarView, the body chosen by its index, for three reasons: the first scrollable under Settings stays General’s list, which tests scroll; the agent finds tabs by the bar’s structure (set_tab general, set_tab keys, set_tab usage, set_tab plugins); and a test can pump Settings on its own, since the controller lives inside it. settingsTabProvider holds the tab, so a link elsewhere can open Settings on the tab it needs, and the bar jumps to it. A label too long for its tab, as Spanish’s is on a phone, wraps onto a second line, and is drawn smaller where its lines would outgrow the tab.
Provider keys (§6.7): a card on how keys are kept; then a section per owner of keys (milestone 5.1 plan §3.5, PluginRuntime.keyOwners) — FilmOpen, the app’s own (OpenAI, for dictation), then each enabled plug-in’s under its name (Default AI assistant: OpenRouter, fal) — with a card per key slot whose platform the catalogue lists, titled with the slot’s label or else the platform’s name, the slot’s help under the title; a plug-in turned off takes its section away and leaves its keys in the store; then a line saying the other platforms are pending and the website lists what stands in their way, with See the platforms. A catalogue that yields no platform says so, and that installing FilmOpen again restores its list. Each slot’s card holds:
- its state: No key, Stored, Verified on <date>, Rejected by <platform> or Could not check, with ends in <last four> where known — and since a save stores only a key the platform accepted (step 2c), the last two reach a card only from a state an earlier build wrote, which the next save replaces; under such a state, a line saying why and what to do (a 403 has its own); while a check runs, a line saying the key is being checked with the platform;
- a box for each credential the platform file lists, labelled with FilmOpen’s word for the value where it knows the file’s (API key, Secret key, Key ID, Key secret), else the file’s own, obscured — with an eye at its end (milestone 5.01) that shows what was pasted until the eye or a save hides it, since a phone’s clipboard is not always what it seems; a refused key stays in its box as it was, shown or not; the box’s identifier stays outside
valueIdentifiers(§4.3), so what the eye shows never travels in the data an agent or the tape reads; a picture shows what the screen shows, so while the issue recorder runs (recordingProvider, §7.1) every box is hidden and the eye says so — with no suggestions or autocorrection, and replaced by an empty one as Save reads it. A helper line in its own colour warns when the value looks like another listed platform’s key or, once what was typed can no longer become the platform’sprefix, when it does not start with it; Save still works (warn, don’t enforce); - the actions:
- Save, the only door a key goes through (the owner, 16 September), checking with the platform file’s
verify, else with the owning plug-in’sverifyKey(§6.11), else storing unchecked: disabled while a box is empty, saying Paste the key first, and pressed by Enter in a filled box. One press checks the key with the platform and stores it where the platform accepts it; a key it refuses, and one whose check has no answer, are not stored, and the box keeps the paste so that it can be put right. Its answer is a line under the boxes — accepted in the positive colour, invalid in the error colour, a check that could not be made in the warning colour, each saying what became of the key — and the line goes as soon as a box changes. Save asks first, withkeys.replace.cancelandkeys.replace.confirm(a 44 px target), when it would replace a stored key, naming its last four, or when the value looks like another listed platform’s key or does not start as this platform’s keys do; cancelled, the boxes keep what was pasted; - Remove, disabled while no key is stored, saying so. Remove asks first, in a dialog whose Remove is a 44 px target;
- while an action runs, the boxes and these three are disabled, each saying why;
- How to get a key (the platform’s guide on the website) and Open the keys page (the platform’s own), each saying so when the page cannot be opened;
- Save, the only door a key goes through (the owner, 16 September), checking with the platform file’s
- messages: a transient message after a save that stored the key, and after a removal; the answer to its check is the line under the boxes, not a message that goes by. Inline until the next action: a store that cannot be used, a value no key could hold, or an action refused because another was running.
- Usage (
filmopen_keys/usage_tab.dart; §6.7; milestone 4.9 plan, D9): the totals of this device’s usage log by month and platform — spent, and pending or possibly charged where anything is — and each use: what it was and the box it was for, when, the minutes, its state (a failed dictation with its code’s sentence) and what it counts as. Each use is named by the box a person saw (labels.dart’susageBoxLabel: a form field by its label, a project’s name as its Title, another box by its own) and the file it belonged to, and a failed dictation’s sentence there offers no Resume. Under the totals, a line says what pending and possibly charged mean wherever either is shown. The log is read again each time the tab opens, since another copy of the app may have written, and after each append of this app’s own, keeping the lines on screen meanwhile; a month filter applies on the device, and a call of a version this app does not know is left out and said. The web build says it keeps no log. Identifiers:usage.month,usage.totals,usage.uses; the tab’s id isusage. Money and decimals are written from their exact decimal, with each language’s mark (labels.dart’smoneyText,decimalText).
There is no way to show a saved key. On the web the tab is one sentence saying that keys are kept by the desktop app, and that FilmOpen on a computer adds them. Identifiers: keys.<owner>.<slot>, keys.<owner>.<slot>.<credential> (keys.default-ai-assist.openrouter.key, keys.app.openai.key), .key.show (the eye, milestone 5.01), .save, .remove, .guide, .keys-page and .answer, the line a save’s check leaves; keys.remove.cancel and keys.remove.confirm in Remove’s dialog, keys.replace.cancel and keys.replace.confirm in Save’s; and keys.platforms.
Plugins (filmopen_plugins_ui, milestone 5.1 plan §3.9–§3.11, step 4). The list: a line on what plug-ins are and that one not from FilmOpen stays off until allowed; Install from file… (plugins.install: a zip unpacked into the library, then the plug-in’s page and its consent); Preferred providers, Text and Images and video (plugins.prefer.text, plugins.prefer.media), each a pull-down of the enabled plug-ins providing complete or render, by name and platform, or a line saying none does; then a card per plug-in (plugin:<tag>) — its name, author, version and where it came from (Comes with FilmOpen, In your library, Came with the film …), its description, why it is off or cannot run, how many problems its files have, its switch (plugin:<tag>.enabled, disabled with the reason where it cannot run) and Open (plugin:<tag>.open). Turning on a plug-in that needs consent opens the consent screen instead — what it asks for: its own keys, which spend money, the keys it wants to use, the text model when it declares capabilities.ai, the hosts it reaches without a key, the types it reads and writes, and the film it came with — with Cancel and Allow (plugins.consent.cancel, plugins.consent.allow, a 44 px target); only Allow turns it on. A plug-in’s page: All plug-ins (plugins.back), its name and switch, Settings on this computer — each machine-scoped setting drawn by the app’s own widget for its type (plugin:<tag>.<key>: a box saved 700 ms after the last change and when the page closes, a number box saying inline when its text is not a number, a switch, a pull-down; a model’s choices are the catalogue’s entries of its category on its platform), registered in FieldRegistry under plugin:<tag> so that set_field plugin:<tag> <key> <text> types as a person does — Actions (plugin:<tag>.action.<id>, one run at a time, with progress and Stop, plugin:<tag>.action.cancel, and the answer as a line, plugin:<tag>.answer: the plug-in’s message, or why it did not finish — a key not entered, a key not allowed, a limit, stopped, a code), Keys (its own slots, entered on Provider keys, and a checkbox per key it asks to use, plugin:<tag>.grant.<owner>:<slot>), and About (its stem, its files’ problems, its README). The tab redraws on the runtime’s changes. On the web it is one sentence: plug-ins run in the desktop app. A machine setting on a plug-in’s page is drawn by the same plug-in field widgets as a plug-in’s card on an entity’s page (filmopen_forms/plugin_field_widgets.dart): a switch, a pull-down of the field’s choices and a box for text or a number, its help up to eight lines, and an agent’s typing parsed the same way in both places — true or false for a switch, one of the choices or nothing for a pull-down.
A plug-in’s rewrite (filmopen_forms/plugin_hook_dialog.dart, milestone 5.1 step 6). The file menu (file.menu) offers Plugins… (page.plugins) where PageAccess.canRunPlugins holds — a handle, a project that can be written, a file not from the library; from anybody’s version, since the run writes beside it, never over it — and an enabled plug-in’s transform applies to the page’s type. A Stop pressed after the plug-in answered still stops: nothing is written. It writes the form’s draft first, then opens a dialog titled with the version’s name: a line saying the result is a new version under the viewer’s and the plug-in’s names and that this version is not changed, a button per such plug-in (page.plugins.<tag>.transform: <plug-in>: rewrite), and while one runs a progress bar the plug-in’s ctx.progress fills, Running… and Stop (page.plugins.cancel). The answer is a line (page.plugins.answer): Written: <stem>. with Open it (page.plugins.open, a 44 px target, which shows that version and closes the dialog), or why nothing was written — stopped, the file changed meanwhile, no handle, a limit, a key not entered or not allowed, the plug-in’s own words, a code. Closing the dialog while a run goes on stops it. This progress and cancellation are the ones Generate will use.
A plug-in’s card on a page (filmopen_forms/plugin_sections.dart). The characters’, locations’ and project’s forms end with a card per enabled plug-in whose entitySettings name the type — for the project file, its project-scoped settings — drawn by the form’s own fields and stored in the version being edited under plugins.<tag>.<key> (field:<stem>:plugins.<tag>.<key>): a text box, a number box, a switch or a pull-down, autosaved by DraftSaver, locked on a version the viewer may not edit, and paired row by row in Compare, where copy-left moves it. An emptied box removes its key and an object left empty; a pull-down takes only its own tokens and a switch true or false. A plug-in turned off, or missing, leaves its values under Other attributes. Scenes and the other types without a form show their plugins object read-only (the note’s known gaps). The cards are the ones the runtime answers when the page is built.
9.5 Behavioural rules
Section titled “9.5 Behavioural rules”- The viewer handle is empty by default, so a first-run user sees the format’s rule that official wins. Setting a handle is visible in the tree header. A handle that is not one (§5.6,
ShortName.validateHandle:owner[.workspace], neverofficial) is no viewer at all —Settings.vieweris null — so the app browses as nobody and writes nothing under it. - Development mode: warnings, not locks (the owner’s rule of 13 September 2026,
docs\milestone-4-refactor.md§4.5). While the application is being built, a rule about who may do what warns and lets the person continue: another author’s version, official without being a director, the shared library. One switch governs it — Settings → Development → Warn instead of refuse, on by default in debug and profile builds, absent from a release build — and one place applies it,Guardsin the writer. The rules that protect files are not governed and refuse in every build: a file changed on disk, duplicate keys, the identity fields, the content the format requires, the epoch guard of copy-left, a missing handle, a folder the system will not write. The bundled sample opens as a session copy on every platform, in either mode (milestone 4.6), editable, with Copy to a folder… in the project menu to keep it. The pages follow the same rule as the writer:PageAccess(filmopen_state/page_access.dart) answers what a page may do — whether the form writes, whether the star and the label can be pressed — and which red lines it shows, and the page, the version row, the file menu and Compare read nothing else. A red line (filmopen_forms/guard_line.dart,GuardLine) sits above the page’s header in the scheme’s error colour, one per notice, with a tooltip naming Settings → Development: Editing maria’s version as john123 on another author’s version, the project’s directors beside a star someone who is not one may press, Writing to the shared library on a library file the library lets be written, No handle with a link to Settings, A session copy with Copy to a folder… where folders can be made, a library file the library cannot take (it stays View), the bundled sample opened read in place with Open the sample again, and a source that says beforehand it cannot be written (a folder on disk says so only when a write fails, in that write’s message). A star pressed, a label written or the JSON editor’s save past a governed rule says the same sentence as a message; the autosave does not repeat it, since the line is on the page. Compare’s left column followsPageAccesstoo. Compare’s tab is never greyed in development mode: where there is nothing to compare it opens on its reason as a red line, and the question to fork a version of your own stays. - Refresh never blanks the screen.
- Every empty state says what would fill it (no takes yet, only one version, no blocks).
- Every write goes through
ProjectWriterand is followed by a re-read; the UI never edits the index. Writes happen only under the viewer’s handle; the star is for directors; the library is never written. - On the web nothing reaches a disk: the sample and new projects are session copies, and the tree header says so.
- Every transient message goes through
filmopen_ui/messages.dart(showMessage), so they all look alike and can carry one action (for instance Settings when a username is missing). - A copy-left gesture (§9.3) is checked before it is applied and never after: what it would make of the draft is built to one side, and a refusal is a message with nothing written. What it does write goes through the same autosave typing does, and is logged as
compare.copiedwith the two stems, whether it was a row, a section or the file, and the keys — never the values, which are the author’s writing. epochis a header field only where the filename carries one (§7): on a scene or an episode it is content, the epoch the scene is set in (§10.2), and the writer lets it be written.Entity.headerMismatchesandProjectWriterapply the same rule.
9.6 Dictation — filmopen_dictation
Section titled “9.6 Dictation — filmopen_dictation”Built in milestone 4.9, step 3, to the owner’s design of 15 September 2026 and the plan’s D8–D10; its turns decided on the device since step 3b, when OpenAI turned down D8’s voice activity detection.
- Where the microphone is. A small microphone at the right of every box a person types into — a form’s prose, names and numbers alike (
FormScope.dictate), everyShortNameField(through its suffix builder), the tree’s filter, the new-project and new-entity dialogs, the version label, the epochs editor, a relationship’s two boxes, the JSON editor’s value dialog and the account’s display name — with the menu of microphones beside it where the box has room; the popup’s header has it too, except over a held microphone, where the popup lies above every route and a menu would open beneath it. It is left off secrets (the Provider keys boxes and the e-mailed sign-in code), boxes speech cannot fill (the sign-in e-mail, folder paths and the JSON editor’s text mode), a box that is read-only or disabled, and the web build. The microphone is 32 px in a compact suffix slot, so a box keeps the height of the boxes and pull-downs beside it. - The menu lists the microphones the system offers, asked when it opens, and holds Hold to record. Both are preferences (
dictationDevice,dictationHoldToRecord), which the agent’squitputs back; the log says only whether the microphone is the system’s default. A microphone no longer offered falls back to the default, which the menu then checks; a second press while the menu is opening opens no second one. A microphone chosen while a dictation listens takes over at once, in the same session and chain (through Stop and Resume, whose rules then hold); one chosen while it is stopped, or while its session opens, records from the next start. - A tap opens the popup. A blank box starts listening at once, and Insert applies the text. A box with text first asks Replace, Insert at cursor (at the caret, or at the end if it had none) or AI rewrite, greyed with its reason until language models come; the choice starts listening, and the apply button carries its name.
- Hold to record, with the switch on: holding the microphone shows the popup over the page, listening at once, at the caret on a box with text; letting go stops the audio, ends the turn in progress and waits for the final text (Turns, below), applies the text and closes, and Cancel during the wait applies nothing; let go with nothing said, it applies nothing and closes, unless a failure is on screen, which stays with its way out. Let go before the session opened, nothing has been sent: the popup applies what it holds — nothing, in a dictation just begun — and closes, and the chain ends
canceled; should the popup stay, because the box took none of the words, the session stops as soon as it opens, so nothing heard after the release is sent. A hold taken away rather than let go — a pointer the system cancels, the switch turned off, or the box gone — stops the audio and applies nothing, and the popup waits for its buttons. Here the popup is an overlay rather than a route, because a navigator cancels the pointers that are down when it pushes one, and the release is what applies the text; the microphone’s tooltip then shows on hover only, since on touch a tooltip answers the long press itself. - The popup shows the state — connecting, listening, stopped, ended, or a code’s sentence — the audio sent and its cost so far at the catalogue’s price once a session could have sent any, the warning before the platform’s 60 minutes, a large editable text with its label at the top, and one line saying where speech goes. Its doors are Stop, Resume, Cancel — which Escape presses too — and the apply button, which reads Apply until a box with text has its choice; a disabled door says why, Waiting for the last words… while a release or the apply button waits for the final text and Ending the session… while the popup closes, and the status line says the same, as the step under way. Without an OpenAI key, or with one OpenAI refuses, it says so, sending nothing and offering no Resume — the same key would only be refused again, with a record each time — and Open Provider keys closes the popup and every dialog under it, each by its own pop, before Settings → Provider keys opens; a dialog that guards its changes (the JSON editor’s) still asks, and while it stays the page stays where it is.
- Text the person edits is theirs. From the edit on, the turns that existed are left as the person wrote them, and only turns that start afterwards are added after the text.
- Applying goes through the box’s own formatters, length limit and
onChanged, as typing does (applyDictation). At the caret the words are spaced from the text around them and shortened to the room a length limit leaves, so the limit never cuts what follows the caret; otherwise a length limit keeps the start of the words, whatever the box held before (Flutter’s own limiter keeps the old text of a box already at its limit). Words the box’s rules empty leave the box as it was, and the popup stays open, saying so; a box already at its length limit says that instead, since no words fit at its caret (DictationResult). A box that went while the popup was open — its microphone gone, disabled, or moved to another text, as a layout change does — takes nothing: the popup says so, and keeps the text to copy. - The session (
DictationController). The key is read on the gesture, and a stored value under twelve characters counts as no key. The microphone starts before anything is written or sent, so one the system refuses costs nothing. The chain’sstartedrecord, with the first minute as its estimate, is written before the session opens, and a log that cannot take it stops the dictation.runningfollows each full minute of audio sent, andsucceeded, with the minutes sent to six places, whenever an accepted session closes — by the apply button, Cancel, the silence limit, the platform’s limit or a dropped connection —canceled, with no cost, when the popup closed before the platform accepted the session, since nothing was sent; orfailed, with its code and no cost, when the session was never accepted otherwise (connectionwhere the end gave no reason). Closing stops whatever is still starting where it is: before the key or the microphone, without a word or a log line; once the first record is written, with that chain endedcanceledand logged. A microphone started for a session not yet opened goes off at once, even while the first record waits for another copy’s lock. Nothing starts after a close. While a session is ending — its end come, its last record on its way — Stop and Resume wait, saying Ending the session…, and Resume would do nothing then, since nothing would stop its microphone. Stop takes effect at once, so no frame after the press is sent, and a session that ends meanwhile keeps its end; a second Resume while the first still starts the microphone starts no second one. Stop turns the microphone off and keeps the session; Resume starts it again, or, after an end, opens a new session with a chain of its own while the text stays. Five minutes without speech end a session, and so do the platform’s 60 minutes, warned of five minutes before. A microphone that fails while recording ends the session with its code (mic_ratewhere the system changed the format asked for). Closing waits at most three seconds for the chain’s last record, which is retried until it is written (§16). Closing the window, or the agent’squit, ends every open dictation too (ExitTasks, infilmopen_state), within the drafts’ ten seconds and waiting for each chain’s last record, so a normal exit leaves no chain pending; a crash still does (§16). - Turns (
SpeechGate, step 3b).gpt-live-transcribetakes no voice activity detection, only commits, so the device decides where a turn of speech ends. Each 100 ms frame sent is measured by its level, the root of its samples’ mean square, against the room’s, which the gate follows as it goes: a quieter frame brings the room down to it at once, and a louder one lifts it by at most 3 % a frame, so that a steady noise becomes the room within seconds. While the last two seconds are as uneven as a voice — their loudest frame at least twice their quietest — the room rises no higher than their loudest frame’s level divided by four and a half, so that a voice which talks on without a pause keeps being heard, even one whose syllables vary by less than three times; a steady noise that has sounded two seconds on its own is learned. The room starts at 50 on the 16-bit scale and never goes below it, so that digital silence does not make every sound speech and a voice already under way when the audio starts — a held microphone’s, as a rule — is heard as speech. It is kept across Stop, Resume, a new session and a change of microphone, whose own room it learns as it learns any other. A frame three times louder than the room is speech, and speech keeps the session from its five-minute limit, as the platform’sspeech_starteddid. A turn ends after 0.8 s of quiet that follows its speech, or after 30 s without such a pause. It is committed (input_audio_buffer.commit) where it held at least 0.2 s of speech — a short name — and cleared (input_audio_buffer.clear) where it held less, as a click does, or where it held a second or more of speech and was still even when it ended, its loudest frame less than twice its quietest: a noise that went on sounding while the room learned it, or an even sound cut by Stop. The quiet that ends a turn counts, so an even sound that stops — a beep — is committed, as a cough is, and so is a flat voice followed by a pause. Quiet with no speech is cleared every ten seconds, so the platform never holds long silence. Stop ends the turn in progress the same way, or leaves it alone where it held no speech, and nothing is committed or cleared before a session is accepted or after it ends. A release, and the apply button pressed while listening or while a committed turn’s text is on its way, first wait for the final text of every turn committed (settle: at most three seconds, then the text as it stands; what got no answer in that time is not waited for again, though a late answer is taken). A commit refused for want of audio (input_audio_buffer_commit_empty) and a turn the platform could not transcribe wait for nothing more, the latter keeping what arrived of it, and a session’s end ends the wait. The defaults are untried on real microphones (§16). - The connection (
OpenAiTranscriber,dictationSetupFromout ofcatalogueProvider). The URL, the model and the price per minute come from the bundled entrystt-gpt-live-transcribe, and the setup refuses an endpoint that is notwsson one of OpenAI’s hosts with nothing before the host (PlatformFile.mayReceiveCredentials), since the key goes wherever the endpoint points, and a platform file that takes its key other than as OpenAI’s bearer token (Authorization: Bearer <key>), which is how the session sends it. The handshake follows no redirect: dart:io would drop the key’s header on the way to another host, but the audio would follow. A close under way is what ends a session, even when its connection then fails or its time for accepting runs out. The session message is the plan’s D8 with turn detection off ("turn_detection": null, step 3b), and the audio frames are D8’s. A session turned down before it was accepted endsrefused, and the log keeps the platform’s error type and code and the field it named (dictation.refused), each only where it is a lower-case snake_case code or a dotted field path, so that neither the platform’s message nor a key can pass. - The microphone (
RecordAudioSource,filmopen_platform): 24 kHz mono 16-bit PCM throughrecord, cut into 100 ms frames byPcmFramer; its codes aremic_unavailableandmic_rate. - Seams (D10).
AudioSource,TranscriberandUsageLogare unavailable until an entrypoint installs one:maininstalls the device’s, and the agent build and the test harnesses installFakeAudioSource,ScriptedTranscriberandMemoryUsageLog. The scripted session answers every ten frames with a turn, in two halves and then its final text, and a commit with the turn in progress at once — or, with nothing since the last turn, with a refusal, as OpenAI answers an empty commit; a clear is only counted.FakeAudioSourcerecords silence unless it is given asound, each frame’s loudness by its place in the recording; the agent build’s talks in phrases, 2.5 s of syllables and then 1.5 s of quiet. - Words. A code’s sentence is
labels.dart’sdictationCodeText. The log carriesdictation.ended {field, minutes, code, turns, cleared, quietClears, speechFrames, room, loudest}— the last six how the device heard that session: its turns committed and cleared, its clears of quiet, its frames of speech, and the room’s and the loudest frame’s levels — and for what went wrongdictation.refused {type, code, param},dictation.notStarted,dictation.recordLost,dictation.setupUnreadable,dictation.setupUnusableanddictation.devicesUnreadable, each with codes, types and numbers only: never the text or the key. - Identifiers:
<box>.dictateand, beside it,<box>.dictate-menu; in the menudictation.device:default,dictation.device:<id>anddictation.hold; in the popupdictation.menu,dictation.status,dictation.text,dictation.replace,dictation.insert-at-cursor,dictation.rewrite,dictation.stop,dictation.resume,dictation.cancel,dictation.applyanddictation.open-keys. The version label’s box islabel.textand the account’s display nameaccount.name.
10. Localisation
Section titled “10. Localisation”Three layers, all in filmopen_l10n (packages/filmopen_l10n/lib/l10n/ for the ARB files and the generated code, lib/src/ for the two hand-written files):
- Messages —
app_en.arb(template) andapp_es.arb, compiled bygen-l10n(l10n.yaml:nullable-getter: false) intoapp_localizations.dartand one file per language. Widgets usecontext.l10n; providers uselocalizationsProvider. Counts use ICU plurals (countScene(n)→ “1 scene” / “3 scenes”). - The format’s vocabulary — the JSON keys are normative and never translated.
fieldLabel(l10n, key)infield_labels.dartmaps 310 keys from Appendix A to messages (fieldName,fieldForkedFrom, …) and returns null for unknown keys, in which case the raw key is shown. Entity types (typeScene,typeScenePlural,countScene) and deliverables (deliverableClip) have their own messages, reached through theLabelsextension inlabels.dart(typeLabel,typePlural,typeCount,deliverableLabel). - Diagnostics —
warningText(ProjectWarning)andproblemText(ResolutionProblem)inlabels.dartturn model codes into sentences.
The generated files are committed so editors and the analyzer see them; flutter gen-l10n inside the package regenerates them. English is the source of the keys, and filmopen_l10n/test/arb_keys_test.dart fails when another language lacks one. To add a language: copy app_en.arb, translate, add the language name to settings_page.dart — one file per language, so a translator and a colleague adding a language never touch the same file. To add a field label: add field<Key> to every ARB and one case to field_labels.dart. British spelling is used in English messages (colour, licence).
11. The sample project
Section titled “11. The sample project”assets/sample/ holds The Cartographer from Appendix B of the Project Specification, extended so the tree has depth, laid out the way a real project is meant to be (§4.4, §14.5):
the-cartographer/— the project:filmopen-project.json(tagcartographer,data→../the-cartographer-data) and 38 JSON files, flat (folders carry no meaning in the format): two project versions (John’s, and Suda’s Thai remake forked from it) with an official pointer; the charactermain-heroat two epochs with three versions at30yoand an official pointer to Maria’s fork; a second character; two outfits; two locations, one with an area; a prop, a style with an official pointer, a misc entry, a document; a season with two episodes, one using a sequence; two scenes, one of which has two versions and an official pointer; shots and cues including a J-cut and an episode-level music cue; two commentary files; two render batches; and one unknown text file so the Other files row appears.the-cartographer-data/— the media root: the nine placeholder takes (four small PNGs, and.mp4and.wavfiles named as takes, each the smallest honest file of its kind — an MP4 with a header and no tracks, a 44-byte silent WAVE with no frames — so a player says it cannot read them rather than the file failing to load;flutter run -d web-serveranswers 500 for a zero-length asset, which the older empty placeholders were), andreferences/kira/voice-calm.wav, the one hand-supplied reference a character names (§9.0 of the Project Specification).library/— the seed of the shared library: a model, a platform and a plugin manifest.
Being under assets/ and in Git is a development convenience (the build copies them to build/flutter_assets/assets/sample/…); a real project’s media root is a synced folder, and the real library is the folder the app installs beside its preferences.
The sample is what the app opens at start — as a session copy on the web, and in development mode on every platform (§9.5), so it can be edited and then kept with Copy to a folder… — and what most tests read. In-memory fixtures (filmopen_test_support’s fixtures.dart) cover what the sample cannot: ambiguity, missing epochs, multi-author project folders, bad field types, cycles.
12. Testing
Section titled “12. Testing”| File | Covers |
|---|---|
packages/filmopen_format/test/stems_test.dart |
the filename grammar: every name kind, takes back to their source and batch, rejections (a version or take number too long for an int among them), naming warnings, the reserved owner |
packages/filmopen_store/test/pick_test.dart |
the pick model on tiny fixtures (§6.1 step 2, §13.4): pick and its 1.5 spelling, pickedAt in the shape of pick; the warnings for a pick naming no file, another epoch or another entity, an unreadable time and a pointer naming another key (dropped, never official); a duplicate commentary file; the director of record — a viewer’s fork of the project file selecting it but appointing nobody, in the model and in the writer — and a folder with no file of record, where a fork (once or twice, the viewer’s or a colleague’s) is followed back to its origin, an original decides, a loop or a missing source decides nothing, and a save that removes, changes or adds a project file’s forkedFrom (a null included) is refused while a scene’s is not; a misnamed project pointer leaving a library pointer standing; an offset without its colon; handles, not owners, for the project file; pick, official, own; a workspace’s chain; resolution through official for official files, the project root included; and the stale predicate: a later setAt, equal seconds with a fraction, the next second, a lower-case z, offsets compared as instants, no zone, no time, the official version and the tracking value never stale, an inherited pick never stale, a pointer to a missing file superseding nothing |
packages/filmopen_state/test/stale_pick_tree_test.dart |
the red chain in the tree for every kind of row (milestone 3.5, D8): the Project row, a story unit with its Versions row and a shot row, a library entity with its epoch, a spec entity; the author’s latest row above an older stale pick; a unit pinned by a full reference left unpainted; and nothing painted for a newer pick, equal seconds, no pickedAt, another viewer, nobody or a workspace inheriting the pick |
packages/filmopen_tree/test/tree_view_test.dart |
how a row is drawn: an overtaken pick’s label and check in the error colour with the reason on hover, an ordinary pick untouched, a parent row red without a check |
packages/filmopen_state/test/project_test.dart |
loading the sample: counts, warnings, resolution rules, the tree’s shape, and a pick an official version overtook painting every row up to its entity (§13.2) |
packages/filmopen_state/test/fixture_test.dart |
spec behaviours on tiny in-memory projects: a file official names reading the official world while the same reference read from its author’s own file reads theirs (§6.1 step 2), ambiguous resolve, default-only fallback, project selection (ask, invalid pointer, sole author handle — a workspace’s project fork counts as another author), tolerance of wrong types, leaf scenes and unresolved rows in the tree, cycle detection |
packages/filmopen_store/test/directory_source_test.dart |
on a real temp folder: the path sandbox, skipped folders and .tmp siblings, and the write path — the sandbox again, a changed file and an existing file refused, no temporary left behind, a delete that honours what the caller read; and the binary half (§6): a picture written and read back byte for byte with its length, the same sandbox for bytes, an existing media file refused under create with no temporary left behind, and a 500 MiB stream copied through without the process growing by a tenth of it |
packages/filmopen_format/test/project_source_test.dart |
binary I/O in the sources that need no disk: a memory source writing, reading, listing and locating bytes as BytesMedia, text read as bytes, create refused whichever kind is already there, a delete taking the bytes; and OverlayProjectSource — reading through, keeping every write and delete to itself, refusing create for what the folder behind it holds, refusing a stale write against what it shows, and wrapping a related folder in an overlay of its own |
packages/filmopen_format/test/media_names_test.dart |
what an upload is called and where its thumbnail lives: the kind from the extension (upper case, a dotfile, a name without one), the prefix naming the purpose on every library type (a character’s audio vo, an outfit’s and a location’s am), the names of one action numbered per purpose and parsing back through the grammar as takes of that source with no naming warning, the eight-hex id of the second, a second later, the next free one when it is taken, a local instant and its UTC twin; the thumbnail’s path for a stem, a file and a nested path, and none for a URL; the reserved folders, _inbox/ excepted |
packages/filmopen_format/test/media_refs_test.dart |
§9.0’s rule for a hand-supplied reference: a picture starting or joining the plain array, joining or making images when refs is grouped, and becoming preview only when the file has none; a clip turning a plain array into groups and joining motion; a sound in a character’s voice.refs and in sound on a location, an outfit and a prop; nothing repeated and nothing removed; the document handed in never edited; a value of another shape left alone; and what a file names, in order without repeats |
packages/filmopen_media/test/thumbnailer_test.dart |
the thumbnail (§6.3): a 4000 × 3000 photograph fitted to 300 × 225 inside the budget, a busy one inside it at the lower quality, a tall one fitted on its height, a small one never enlarged, a transparent PNG flattened on the grey rather than on black, an EXIF orientation baked in, a GIF’s first frame, bytes that are not a picture making none without an error; the stock pictures at 300 × 300, the same bytes every time and different per kind; a picture’s size measured without keeping it; and a 24-megapixel photograph thumbnailed well inside four seconds (D5’s threshold) |
packages/filmopen_format/test/media_paths_test.dart |
where a media folder sits (§5.10, D34): a path split and joined on either platform’s separators, a folder resolved against the project folder, the locator written relative where it may be and absolute where it may not, and what makes a folder portable — all of it asked with windows: rather than read from the machine the check runs on, so either platform’s paths can be checked from either |
packages/filmopen_media/test/media_importer_test.dart |
the one way media enters a project (§12.3): a picture becoming a take in the media root, a thumbnail in the project folder and a batch record with originalName, sha256, bytes and the picture’s size; two pictures and a clip in one batch numbered per purpose, with the clip wearing the stock picture; a second upload a batch of its own and a second in the same second taking the next free id; a sound taking its purpose from the entity it is dropped on; the refusals — not media (before anything is written), empty, too large, no viewer, a file that is gone by the time the import begins, a name the store already holds making the import take the next id instead (D32), and a store that refuses a name half-way leaving nothing of the batch behind; the URL door against a MockClient — fetched and named like any other file, refused for a content type that is not media, refused for a server’s answer without its words or the address reaching the exception, and refused for a scheme that is not http before anything is asked; an upload found as a take when the project is read again, its thumbnail not an unknown file and its display name the original; and the importer leaving the entity alone, the reference going through withReferenceAdded |
packages/filmopen_media/test/media_cache_test.dart |
the cache a fetching store puts its bytes in (D14): a file kept under a name made from the whole media path with its extension — so stills/front.png and posters/front.png are two files and not one — none answered for a file it has not, a file already there kept rather than fetched again with the answer drained so no connection is left open, the budget held by dropping the oldest first with what was just asked for kept even when it fills the budget alone, and a folder from an earlier session evicted rather than growing for ever |
packages/filmopen_media_ui/test/media_root_picker_test.dart (the source rows) |
a source’s panel replacing the local row and its sentence, which describes a box a source does not have, and the kind pull-down drawing the source’s own icon |
packages/filmopen_media_ui/test/media_root_picker_test.dart |
where a new project’s media will live: the path proposed as <parent>/<tag>-data and following the tag until the person types one of their own, an empty row meaning no locator, Browse putting the chosen folder in the row and a cancelled dialog changing nothing, the identifiers once each with no kind pull-down while there is nothing to choose between, a registered source adding its kind and its own panel, the Spanish sentence, and no overflow at 380 px in Spanish and dark |
packages/filmopen_state/test/media_provider_test.dart |
mediaStoreProvider: a local root answering a store of the folder the manifest names, a project that names none being its own, a locator type no source serves reported as unsupported with the project still open, a folder the loader could not open reported as unavailable, a registered source answering for its own type and for no other, and no store while no project is open |
test/media_section_test.dart |
the reference section on the whole app: a picture fetched through the URL door against a MockClient becoming a tile, a reference in the file, a thumbnail in the project folder and a batch record; a second upload appending with a batch of its own and the first still there; a clip joining motion and wearing the stock picture with no preview proposed; no + on somebody else’s version and none without a handle; a tile opening the picture at full resolution and closing; the media keys gone from Other attributes; the Takes tab listing the upload; Compare pairing the card and a < adding a reference without removing one; Browse adding two real files at once through the sheet’s own door with the picker injected, both reaching the file in one batch; a media root of a kind no source serves leaving + there but disabled with its reason in its tooltip; a refusal staying inline under the strip in the person’s words with nothing written; and no overflow at 700 and 532 px in Spanish and dark |
packages/filmopen_media_ui/test/player_controls_test.dart |
the row a person presses, pumped without a decoder: one control named for what pressing it does next and calling back once; the time as where the file is over how long it is; a drag asking for no seek until the finger lifts and exactly one then; the thumb staying where it was let go until the player arrives, and given back after two seconds when it never does; a file of unknown length with the bar disabled, its reason in the tooltip and its clock still counting; and the row at 700 px in Spanish |
packages/filmopen_media_ui/test/player_rules_test.dart |
what the player draws, as arithmetic a check can reach without a decoder (player_rules.dart): the ratio a file that reports none is drawn at, whether there is anywhere to seek to, whether the file has run out — the last fifth of a second counting as the end — and the clock, m:ss under an hour and h:mm:ss from exactly one |
test/media_section_test.dart (the location’s cases) |
the same card on a location from one line (milestone 5, step 4): a picture of the café named cs_lo_cafe_… and becoming the preview, with its take in the media root and its thumbnail in the project folder; a sound named am_… and joining the sound group, with no voice on a place; Compare pairing the card and < adding without removing; and 700 px in Spanish and dark |
test/media_section_test.dart (the player’s own cases) |
the viewer asking the platform for a player and drawing what it answers, with player.play beside player.close and the file’s kind above them; and a platform that has none saying so, with no player.reveal for media that is on nobody’s disk; the dialog and its controls row fitting at 700 px, the seek bar taking what is left and the time beside it. The player itself is the platform’s, so a check stands in for it (debugMediaPlayer); the real one is exercised by scripts/agent_walk.py on Windows, which also opens a file whose bytes are not a clip and expects the error state and player.unplayable in the log |
packages/filmopen_drive/test/drive_android_flow_test.dart |
the whole Android consent flow composed, driven without a browser, a device or a socket (§6.10, milestone 5.01 step 3, round 1’s M2): the consent page opened with the Android client and its redirect answered and exchanged; the verifier sent with the code being the one the challenge was made from; a browser that will not open being unavailable with nothing exchanged; a build given no link door saying so without sending the person to Google; a redirect for somebody else’s request refused with its code never exchanged; a refusal from Google kept as a code with none of its words; and a state and a verifier of their own for every sign-in |
packages/filmopen_drive/test/drive_android_client_test.dart |
Google Drive on Android (§6.10, milestone 5.01 step 3), read without a browser, a device or an account: which client each platform signs in with and that a build carrying one is unconfigured on the other platform; that an Android client sends no client_secret rather than an empty one; the redirect scheme as Google’s reversed client id, computed the same way android/app/build.gradle.kts computes the manifest’s placeholder, and an id of an unexpected shape yielding a scheme rather than a crash; the Android consent address carrying everything the desktop one does — the scope, access_type=offline, prompt=consent, the S256 challenge, the state — with the custom scheme as its redirect and no secret anywhere; and the refresh request’s own body, where the wrong secret would not show until the access token ran out an hour later: the Android client and no client_secret key at all, the desktop client with its secret unchanged, an expiry kept, a refresh token not invented, and a refusal that is a code and never the server’s words; the code exchange’s own body, which is FilmOpen’s request and not the library’s, carrying no client_secret key on Android and the secret on a desktop, answering tokens from a stand-in, and turning a captive portal’s page into unavailable; the redirect waiter driven from a stream — our own scheme only, a wrong state refused before the code is used, an error carrying no code, a bounded wait, and the subscription given back however it ends; and the Kotlin read from android/app/build.gradle.kts, so the two computations of the scheme cannot drift apart unnoticed |
packages/filmopen_drive/test/drive_consent_test.dart |
what FilmOpen asks Google for, read without a browser or an account (§6.10): Google’s own endpoint and the loopback redirect; access_type=offline and prompt=consent, without which no refresh token is ever issued and the application stops working an hour after every sign-in; the one scope; PKCE’s S256 challenge against RFC 7636’s own test vector; a verifier and a state that are long, unreserved and never the same twice; and no client secret anywhere in the address |
packages/filmopen_drive/test/drive_media_store_test.dart |
Drive against a stand-in in memory (fake_drive.dart) — no account, no token, no request that leaves the machine: a build with no client of the owner’s refusing as unconfigured and the project opening all the same; nobody signed in as refused; signing out removing the keys; an access token that has run out refreshed and the refresh token kept; a root made under a parent and the locator answered as a path; the folder made by the first upload, not the dialog; the id remembered so the path is walked once; the parents offered being FilmOpen’s own folders and nothing else of the person’s; a locator naming no folder refused rather than read as the whole Drive, and a remembered id walked again when its folder has gone; an upload named and placed, a second of the same name refused with nothing replaced, one whose size nobody knows refused before anything is written; a fetch into the cache once and a file on this machine the second time; and every refusal a code, with Google’s own words never coming back with it |
packages/filmopen_drive/test/drive_source_ui_test.dart (milestone 5.01: a build with only the desktop client says no Google client on a phone, and one with the Android client offers to sign in there) |
the panel: a build with no client saying so and offering nothing; the scope sentence before any browser opens; signing in showing the parent and the name and settling a locator; a name of the person’s own; signing out forgetting the keys, saying what it does not do and leaving no place settled; a refusal said in FilmOpen’s words, with no address in it, and the button still there to try again; and 700 px in Spanish |
test/drive_source_test.dart |
the application with a second source registered, which is the whole of what registering one does: New project growing a kind pull-down and showing Drive’s panel in place of the local row; Create waiting while that panel has settled on no place — the form filled first, so that nothing else holds it back — and saying why, and going live again when the local folder is chosen; a project whose locator is google-drive with nobody signed in refusing at the moment of upload, in FilmOpen’s words, with nothing fetched; the upload sheet offering no Drive door; and the registry answering the local source first |
test/sample_media_test.dart |
the bundled sample as a session copy: its media root is an overlay that reads the bundle and writes to memory, a second copy starts from the bundle again, the bundled folder itself refuses a write and answers a length, and a take written into a session copy’s media root is loaded as a take of the project |
packages/filmopen_state/test/localization_test.dart |
Spanish messages, field labels, diagnostics and tree text, and a dotted leaf path read as words in both languages |
packages/filmopen_format/test/flat_json_test.dart |
the flattening both Compare and the generic View read (§13.3): dotted paths, a list kept whole, a top-level-only skip, an empty object contributing no path, and that what a value row never shows is the header keys less name plus blocks |
test/responsive_test.dart (milestone 5.01: a 360 dp phone and the Galaxy’s 411 × 891 among the drawer sizes and the overflow widths; on a phone the system’s back closes the tree and the page stays; Settings’ segmented buttons keep every label on one line at 360, 375, 411, 532 and 1200 px in Spanish, with the icons gone at the three phone widths and kept at 532 and 1200, D8) |
the two layouts (§9.1, D11): the tree away and behind an icon below 700 px of window with the rail still beside the drawer, choosing a row navigating and closing it while opening a branch does neither, the tree back on the page at 700 px and above, the remembered sidebar width seeding a fresh split after a trip to the drawer, one field to a row below 560 px of pane — checked at a width where the fields would otherwise have shared one, since below about 344 px they would not have fitted anyway — a comparison that keeps two columns, a 28 px gutter and rows with height at 375, 532, 700 and 1200 px without ever scrolling sideways, nothing overflowing at any of twelve widths from 180 to 1200 px in Spanish and dark (the language whose sentences are longer, which is where two of this step’s failures showed), the tree’s header keeping a readable project name at the sidebar’s own minimum with the handle moved under it, a label going above its chips where beside would crush them, a comparison’s headings travelling with its rows where the page is short, and the header above the tabs never taking the height the tabs keep, with the bar that says it was cut |
packages/filmopen_format/test/field_paths_test.dart |
a form’s box on a nested key: the text it starts with (a string, a number’s digits, a list one per line), creating the objects on the way and leaving siblings alone, an emptied box removing its key and every object left empty, clearing what is not there changing nothing, lines and numbers (text that is not one writing nothing), never writing into the document it was copied from, and a value of another shape — an object on the way that is not one, an object where a word goes, a list holding an object — left alone |
test/character_location_form_test.dart |
milestone 4.7’s forms: every key of A.1 and A.3 owned by a field (collected over the whole scrolled page), each section with its icon, a box named without its object, the media keys left in Other attributes; typing a nested key writes it beside its siblings and emptying the last key of an object removes the object; a draft observer (the recorder’s seam) hearing a chip’s token, a box’s trimmed text and a relationship row’s list; the voice chips choosing, a second tap removing, an agent typing a token through FieldRegistry; relationships added (one untyped row at a time) and removed with the row below moving up; a value of another shape locked with the reason, written over by nothing and shown in Other attributes; the location’s kind, time of day and lines written; Located within listing the other locations by name and not itself, writing the tag, an agent’s tag that is no other location writing nothing, None removing it, and a tag no location has kept and said; another author’s version with every field and no chips or row controls; Compare pairing the new sections and copying a nested key left without removing a sibling; nothing overflowing at 532 and 700 px in Spanish and dark, scrolled to the foot of both forms |
test/plugin_sections_test.dart |
a plug-in’s card on a character (milestone 5.1 step 4, §9.4): a text box, a pull-down and a switch typed through FieldRegistry and autosaved under plugins.<tag>.<key> of the version being edited, another plug-in’s data kept, a token the pull-down does not offer and a switch that is not true or false writing nothing, an emptied box removing its key; the card locked and legible on another author’s version; Compare pairing the card and copying a value left; 700 px |
test/plugin_hook_test.dart |
Plugins… on a page (milestone 5.1 step 6, §9.3): a scene’s file menu running the template’s rewrite on the version shown, its progress and Stop, Written: … and a 44 px Open it showing the new version; Stop, and closing the dialog, cancelling the run; the item absent on a type no plug-in rewrites; a refusal said in words at 700 px |
test/generic_view_test.dart |
View for a type with no form (a style since milestone 4.7, when a location got a form): a scene’s story epoch as a value row and a location’s filename epoch not, every leaf path the Overview tab reached is still reachable over the whole sample (walked independently of the code under test), a scene’s blocks as rows against a cue’s blocks as a value, values rendered rather than printed as JSON, the File and header-disagreement cards, an empty object surviving in Other attributes, the file menu’s editor and clipboard, the pending draft written before the editor opens, and no overflow at 532 and 700 px in both languages (a width pumpApp now honours — it used to fix 1600×1000 over whatever the caller had set, so both of this table’s width claims were made at 1600 until milestone 4 step 5) |
test/compare_test.dart |
two-column Compare (§13.3): the viewer’s latest on the left and the selected version on the right with their rows level, a key only one version has saying so rather than leaving a gap, the three reasons the tab refuses to open, the question a reader with no version of their own is asked and both its answers — No writing nothing and Yes forking the version on screen without moving the page — and that nothing compares across epochs |
packages/filmopen_forms/test/draft_saver_test.dart |
the autosave on its own (§9.3): nothing written until the draft has been still for the 700 ms the form uses, one write at a time with a keystroke during a write keeping the draft dirty and Saving… observable while a deliberately slow write is in flight, a change typed during a write whose form is then disposed written once against the text the writer handed back, and tried once more against the text it was read with when that write fails, settle waiting for the write in flight and writing what came after, settleOpen answering false while one draft is refused, forgetting a disposed saver and waiting for the write it left behind, adopt taking back only the draft’s own write, a re-read holding the write in flight adopted, a nested change never reaching the file’s JSON, a change that changes nothing writing nothing, an outside change refused while the draft is dirty and the same re-read adopted when it is clean, and a pending draft written when the form moves to another file or is disposed; the draft observer (the issue recorder’s seam) hearing the trimmed text, a chip’s token, a relationship list, a removed key as null, a copy-left’s several keys with no value, and nothing for a change that changes nothing |
packages/filmopen_state/test/project_provider_test.dart |
the controller’s re-read: of two overlapping re-reads the one started last wins whatever order they finish in, the overtaken one answers false and leaves the base index alone, and opening another folder drops a re-read of the previous one still in flight; the sample opening as a session copy everywhere, in both modes (milestone 4.6); openSample in both modes; closing writing the open drafts and the next open ending the closed state; Copy to a folder on a temp folder — the files written into a folder named after the project and the project opened there — a folder that already holds files refused, with nothing written and the session copy still open, a copy failing part-way taking its folder back with nothing escaping it, a target that is a link refused, with a sentence of its own and nothing written through it, and a project named after a Windows device name copied into project |
packages/filmopen_store/test/copy_left_test.dart |
— and the reference section’s own copy (milestone 5 D17): what the left lacks added and nothing removed, the right version’s grouping kept, a character’s voice.refs copied without the rest of the voice, preview copied as any other word and only where it differs, nothing offered when the left already names everything, the keys named for the message and the log, a value of another shape left alone, and a reference the right names in another group not added twice |
test/compare_copy_test.dart |
copy-left on screen: a row copy writing one key and leaving identity, the dates and unknown keys alone with the version copied from untouched, a section and copy-all as one write each (counted in the log) that never carry a key copy-left will not move, the rows that offer no < proved beside one that does, an epoch in use refusing both a row copy and copy-all with the file unchanged and a sentence naming the way round it, a copy the writer would refuse refused first in its words with an empty name never offered, a scene’s story epoch copying, a project’s contributors shown and copied neither by row nor by copy-all, a rebuild in the other theme — its controls taking the new colours — asking no row again while a copy does, a scene’s blocks replaced, inserted and deleted by id with copy-all deleting none, a scene whose blocks are ids compared as a value on both sides, the §13.2 banner over an official left column and its Fork, which forks the left version after writing a copy still pending, the message naming the row it copied, the icons on the split at the size the gutter allows, every control disabled with its reason on a read-only project, and the sample’s own scene and location — in a writable copy, so assets/sample/ is never edited |
test/widget_test.dart |
the app: overview and tree, the tab set (no Overview, no JSON) and every entity tab laying out without overflow, the commentary feed ordered by instant across offsets (§1.4), tree highlight when choosing a version, navigation from the detail pane reveals the row, the project chooser |
packages/filmopen_store/test/project_writer_test.dart (a plug-in run) |
milestone 5.1: a returned entity written as <viewer>.<tag>’s v1 with by, forkedFrom, no label and its plugin batch, the person’s version untouched, a second run v2; an entity matching no source, content the format refuses and no viewer writing nothing; a run whose second write, or whose batch, is refused leaving nothing of it; a plugin batch under the viewer’s derived handle for that plug-in accepted, an import under it, a plugin batch under another person’s and one under a workspace that is not the plug-in’s refused |
packages/filmopen_store/test/manifest_test.dart |
a project’s plug-in folder (§4.2, milestone 5.1): its manifest indexed and its code a companion file by their names, its locale file, README, platform file and asset not reported, _inbox/ still reported; the manifest and media root (§4.4): locators, takes found in the media root and beside the JSON, no manifest, unsupported type, missing folder, absolute path, malformed data, tag mismatch, a manifest missing filmopen or tag |
packages/filmopen_store/test/library_test.dart |
the shared library (§14.5): entries marked by origin, separate counts, project stem shadows the library’s with a warning and none of the replaced file’s warnings survive, a fork beside library versions, a project official pointer overriding silently, an unreadable library folder as a warning rather than a failure |
packages/filmopen_format/test/short_name_test.dart |
the segment rule in one place: validation, a handle’s two segments and the reserved owner, Windows device names, the ten-character advice, normalisation, and that the filename grammar warns by the same rule |
packages/filmopen_store/test/app_log_test.dart |
JSON-line shape, thresholds, verbose and the environment pin, a failing sink, unencodable data, timed, the file sink and its rotation, an error quoting a text box’s value keeping no typed text |
packages/filmopen_forms/test/page_access_test.dart |
what a page may do and which red lines it shows (§9.5), without a widget: enforcing — the viewer’s own file, the star for directors, the library locked, no line; development mode — another author’s version editable with its line, the star for everyone with the directors’ line, a library file editable where the library can be written and, where it cannot, not editable with its own line, no handle writing nothing and saying so, a folder the system refuses; directorHandles, and the directors’ sentence naming them or saying the project names none |
test/development_mode_test.dart |
development mode on screen (§9.5): another author’s version opening in Edit with its red line and what is typed written under its author; the star pressed by someone who is not a director, writing the pointer and saying the directors’ sentence again; a label written on another author’s version under its author, the message repeating the line; Compare opening on the viewer’s own latest with its reason as a red line; no handle leading to Settings; a session copy’s line offering Copy to a folder…; and, enforcing, no red line, View, and the star disabled |
packages/filmopen_store/test/guards_test.dart |
development mode on an in-memory project (§9.5), one group per row of the plan’s table, each enforced and in development mode: another author’s version (refused; written under its own author with the override logged at warning level, with its data), official without being a director (refused; set and cleared, two overrides), no handle (refused in both), a read-only folder (refused in both), the shared library (refused; written into the library and not into the project, and both overrides logged for another author’s library file; a read-only library and a library file without a handle refusing with no override logged) — and the rules that protect files refusing in development mode too, with no override logged: a changed identity, missing content, a project file’s forkedFrom, a stale file |
packages/filmopen_state/test/settings_test.dart |
recent projects (tolerant parsing, order, no duplicates, cap), library path and verbose defaults, and the viewer being the handle only when it is one — anything else, official included, browses as nobody; development mode’s default taken from the build and its setter deciding Settings.guards, which a build without the mode ignores; dictation’s microphone and Hold to record, their defaults, a device cleared back to the default, and a log that never names a device |
packages/filmopen_store/test/project_creator_test.dart |
creating a project on a real temp folder: manifest and first version (director, one epoch, root list for the kind), the kind’s root list, refusing a non-empty folder and a bad tag; deleting a project folder (milestone 4.6) with everything in it, a folder with a project file and no manifest counting as one, and a folder without a project at its top (one deeper down does not count), a missing folder, a file and a link refused with nothing touched; and the media folder of a new project (§4.4, D8): made, named relative to the project folder when it sits beside or under it and absolutely otherwise (which the loader then reports), an empty row writing no locator and making nothing, a locator a source made for itself written as it is, and the path arithmetic on Windows |
packages/filmopen_store/test/project_writer_test.dart |
the writer on an in-memory project (§13, §17): save keeps unknown fields and stamps updated, refuses other authors, no viewer and read-only sources; fork numbering for own and others’ versions, the fork identical apart from the header and without a version label under either spelling (milestone 4.6), a project fork adding its author to contributors, the fork becoming the pick; the identity lock and a repair towards the filename, a stale file refused, a duplicate key refused at read, content validation, only a director writing the pointer; a new character or location written as the viewer’s v1 with its name and kind, beside a project file kept in a subfolder, recorded as the creator’s pick with nothing official, and refused for a tag the type or the library has, a bad tag or epoch, an undeclared epoch, a type without epochs, no handle, no name and a read-only folder, with nothing written; official set (rewriting the pointer, dropping its note), new pointer, clear; the pick order (pick, official, own, sole owner), the date a pick carries and abandoning one, pinning versus tracking official, a missing or other-epoch pick with its warning, per-epoch picks, the folding of an older stem and of the plain date beside it, workspace handles inheriting the owner’s pick, the project file following §4.1 unless a pick says otherwise; epoch order, re-encoding, default-epoch fallback, the no-epochs warning; kind vocabulary |
packages/filmopen_format/test/character_age_test.dart |
birthdate as a year, text or calendar date (impossible dates refused); the proposed age, the override, its removal |
test/editing_test.dart |
editing through the screen on an in-memory project: Settings’ handle box explaining a reserved or malformed handle and taking an empty one, Settings → Development switching the provider’s writer from refusing another author’s version to saving it with the override logged, a pick naming a missing file removed with the check on the official version, Fork carrying what was typed a moment ago and stopped, with a message, by a draft refused as stale, moving away twice and back while a save is held in flight with each file keeping what was typed into it, Compare’s fork dialog waiting for a draft another page left behind before it copies, an abandon with nothing to abandon writing nothing and answering false, the project form saves after the delay and the page survives the re-read, somebody else’s file offers Fork, Fork makes and shows the next version with the check on it, the tree shows one row per author with official and pick beneath and the label as detail, two genres in one save window, the version label, the check marking a pick and moving on a click (official in use wears the star alone; the check on the version in use only says why; the director’s star removes their own pick and leaves nothing stale; the star is disabled for a non-director), abandoning a pick and keeping it, a workspace whose owner has a pick writing the tracking value into its own file when it abandons and when the star moves (and writing nothing when the owner’s pick already names the new official), the star re-reading the folder even when the pick removal is refused, and the banner a pick the project overtook raises: its two answers, a refusal that writes nothing, nobody else being asked, and the project file asking on its own page; the §13.2 banner on a type with no form of its own and the version it forks, the file menu’s editor, folder and clipboard items, the viewer’s pick choosing the project version with a project fork picked at once, a rename in the version in use naming the epoch overview, an invalid birthdate never written, and the JSON editor’s inline refusals; the epochs row in a provider scope, as in the app, since its boxes’ microphones read the dictation settings, and its label keeping more than 150 px of a 239 px card |
test/project_menu_test.dart |
the ⋮ menu’s items (Copy to a folder… offered for a project in memory and not for a folder on disk), the New project dialog’s filtering, the mandatory username and the file it announces, the View toolbar’s folder and clipboard items on somebody else’s file, the library chip |
test/phone_folders_test.dart |
a phone’s folders through the providers (milestone 5.01, D4–D5): the ⋮ menu without Open project… and Copy to a folder…; New project without the parent row and the two Browse buttons, saying where the project goes, the target and the media proposal under the app’s folder, Create enabled; Create waiting with its reason while the folder is found and refusing with the sentence when it cannot be made; a remembered project under the folder listed by its tail; Settings → About naming the folder and the library card without Choose folder… |
packages/filmopen_account/test/account_auth_test.dart |
the callback predicate (exact scheme, host, path; code or error required; fragments refused) and the error mapping to problem codes that never carry the server’s text |
packages/filmopen_account/test/account_card_model_test.dart |
the sign-in result through the link door (milestone 5.01): a callback opens the client and is exchanged through it whatever started the app, its refusal a notice and not pending; the same link handed twice is acted on once; another link with a client there is exchanged too; and a second model acts on no link the first already spent, a ledger that forgot being a fresh process (milestone 5.01, step 2, round 2) |
packages/filmopen_account/test/account_registration_test.dart |
who registers the sign-in’s scheme at run time (milestone 5.01): the desktops, not a phone, and this machine as its platform |
test/account_card_test.dart |
the account card renders signed out without any plugin and without a progress bar as one login button with the logo and no provider name; the button opens the sign-in dialog with the logo, one marked button per provider, the or rule and the e-mail box; no microphone on the e-mail box; an invalid e-mail is refused inside the dialog before connecting and its text does not outlive the dialog; Spanish; and every problem code has a distinct sentence in both languages; the card asks the link door as it is built and ignores a link that is no callback; a callback through the door is exchanged by the card, once per process and not once per card — a second card replays no spent code, waits for no browser and carries no failure of the first — the Windows argument and the door carrying one link exchanged once, and a door that throws logged rather than thrown (milestone 5.01, step 2, round 2; the stand-in client turns the token ticker off, that periodic timer having been the reason the wiring went untested) |
packages/filmopen_account/test/account_profile_test.dart |
the profile service against a loopback PostgREST stand-in with the deployed permissions: load and save under update-only grants, a NULL name reads as empty, a missing row is a PostgrestException, a denied write propagates |
packages/filmopen_state/test/seams_test.dart |
the exit tasks run together and all waited for, one that fails, before its first await or after, keeping none waiting and one taken back not running; the seams of §7.1: the no-op host installed by default and answering “not available” to everything, install replacing it and leaving windowKey and rootKey two distinct keys, the field registry routing to the form that registered and forgetting one that left, the draft observers hearing each change with its key and value until removed and unable to write into the value they hear or into what another observer hears; the four doors step 2 added (openProject, setViewer, setLocale, setTheme) answering nothing and changing no setting on the no-op host; the route seam silent with no listener, naming a dialog’s push and pop by its name and a page’s push, and a failing listener never stopping a dialog from opening |
packages/filmopen_plugins/test/plugin_manifest_test.dart |
the manifest (§6.11): the sample library’s 1.10 manifest and the template read with no problem; an unknown field, entity hook, platform hook, setting type and action reported with the rest kept; a mistyped field inside a setting, a key slot and an action reported with the entry kept, a field the setting’s type does not use (values on a text box) reported, and bounds no number meets left out; a wrong type in every field reported, never thrown; an issue’s values part of its equality; api 2 refused naming both versions; another type, a bad tag, the tag app and a missing or mistyped header field refused; the code file named after the stem, and one elsewhere refused; key slots named <tag>:<slot> with their store and preference names, one naming a platform without a file reported and kept, a bad slot left out; uses with the app’s key, malformed ids reported and repeats dropped; KeyId reading only two segments; a default of the wrong type dropped, an enum without values and a model without a category left out; scope on settings and none on entity settings; entity settings for a type not applied; network hosts |
packages/filmopen_plugins/test/plugin_strings_test.dart |
a plug-in’s strings (§6.11): metadata skipped, a key with a colon and a value that is not text refused, @@locale checked against the file’s name, a file that is not an object or repeats a key; pt_BR reaching pt-BR; the template’s two languages holding the same keys; the fall-back from locale to language to English to the key, reported once; the plug-in’s own namespace, app:, and another plug-in’s; an app string never picked up by a missing key; placeholders; a manifest’s name as a key or as written |
packages/filmopen_plugins/test/plugin_package_test.dart |
a plug-in folder and its zip (§6.11): the template in a library read with both locales; a stock manifest and a fork in one folder; a folder without a manifest, a manifest of another tag or stem, and one that does not parse, reported; a platform file for a new id making a slot’s platform known and one for a catalogue id refused; a file that is not text reported by its path with the rest read, a manifest’s among them; a missing code file and a folder without English reported; locale files under the folder a manifest names; the template zipped and read back, nested or flat; an entry escaping the folder — .., absolute, a backslash, a drive letter, ., an empty segment — or a link refusing the whole archive; macOS’s __MACOSX/ and .DS_Store left out; an entry inflating past the limit refused, honest or lying about its size; a damaged entry refused as unreadable; a folder or manifests naming another tag, or no manifest; bytes that are not a zip; the default AI assistant read with no problem, its two key slots and capabilities.ai, in both locales with the same keys |
packages/filmopen_js/test/engine_test.dart |
the engine (§6.11), each call in its own isolate: a function called with JSON and its value back; a promise awaited and two Dart doors awaited from JavaScript; a door’s refusal reaching the script with its code and reason, and another error with no text; an action called with ctx alone, with its settings, tag and strings; eval, Function, Math.random, Date, the host’s own functions, require and fetch absent and every function’s constructor refused; an infinite loop inside try ending at the time limit while this isolate’s timer kept ticking, a loop at the top of the file too, waits on a door and on sleep not counted, a growing array ending at the memory limit though caught, deep recursion at the stack limit with the default 1 MB, a result over its limit, the wall clock; a script’s thrown message, a syntax error, a missing function, a value JSON cannot hold, a promise nothing settles; a looping, a waiting and a sleeping script cancelled at once; flooding the doors ending at the memory limit without the process growing by gigabytes, and so does a slow door holding what was drained to it; a prototype’s toJSON and a thrown cancelled, hung, limit, engine or stalled code faking nothing, a door’s refused kept; a stack trace’s call sites not handing the script the host; replacing JSON, Reflect.apply changing neither the doors reached nor the outcome; a cancel before the isolate started; a last log and a missing string still reaching their doors, and requests queued before a time limit other than a log not started; a cut message, a lone surrogate and a function as a request’s arguments read back; the result limit in bytes; a throwing microtask not failing the call; and a thousand calls growing the process by less than 100 MB |
packages/filmopen_plugins/test/stock_install_test.dart |
the stock install (§6.11): an empty library getting every file and a record, a second start changing nothing; an update replacing its own file while a person’s fork, their locale file and their own plug-in stay; a file of the same name the app never wrote left alone; a file the bundle dropped removed unless the person changed it; a deleted stock folder back at the next start, and gone at every start while the person keeps that plug-in disabled |
packages/filmopen_plugin_host/test/host_runtime_test.dart |
the host against stand-ins (§6.11): ctx.http sending the platform file’s header filled from the store, with nothing the plug-in reads holding the key though the platform echoes it and sets a cookie; a host off the platform’s, a redirect elsewhere, http, a key neither owned nor granted, a key with no platform file and an unlisted free host refused with their reasons; a granted key sent and, its grant taken back, refused; a key echoed JSON-escaped still replaced, and in a text answer JSON-escaped, percent-encoded or as character references; six asks at once reaching the provider one at a time; a nested call refused another; a provider of complete refused another provider’s key without capabilities.ai; a caller cancelled or answering without waiting ending its nested call, with no request after it and every usage chain ended; a hung plug-in off until the next start; ctx.ai.complete from the text provider itself running, recorded as the action the person ran with the entry and model id, and refused to a plug-in without capabilities.ai; a key sent from an action recorded; the default AI assistant against stand-in OpenRouter and fal — Ask with the film’s text model and the cost its nested complete reported, Ask without a key refused, render through fal’s queue to a take with its thumbnail, estimateCost, its folder deleted and put back at a start unless turned off; a disabled owner’s key refused and a key checked by nobody but its owner; a transform cancelled midway leaving no version and no batch; a transform refused on a type it does not apply to and its batch without the machine setting; a film’s folder under a stock tag not taking its place; verifyKey reaching the candidate for one call with nothing stored; ctx.project answering the viewer’s versions with their plugins object; the template’s transform written as <viewer>.template‘s v1 with its batch and the person’s version untouched; a render against a stand-in fal’s queue downloaded without credentials as a take by <viewer>.<tag> with its thumbnail and a plugin batch, and its usage chain started then succeeded with the stem, the entry, the platform, the key’s last four and the reported cost; a loop at the time limit and a throw with its message, the next call running; a third party’s plug-in off until allowed and off again when its code changes; a stock plug-in trusted and granted with the index read again after the install, and a fork beside it asking; Provider keys’ sections |
packages/filmopen_state/test/legacy_keys_test.dart |
4.9’s key names moved once to filmopen/default-ai-assist/… and filmopen/app/openai, a key already under a new name kept, the account’s session untouched, a second call opening nothing; a store that cannot be used leaving the move for next time; the preferences’ states moved without opening the store (§6.7) |
packages/filmopen_l10n/test/app_strings_by_key_test.dart |
the generated appStringsByKey equal to the ARB files as they are (scripts/gen_app_strings.py) |
packages/filmopen_plugins/test/plugin_archive_memory_test.dart |
alone in its file, so that the process’s high-water mark is its own: a gigabyte of zeros behind a zip whose directory claims one byte refused as too large with the peak memory growing by less than 400 MB (§6.11) |
packages/filmopen_l10n/test/arb_keys_test.dart |
every key of app_en.arb present in every other language file, and no key in another language that English lacks |
packages/filmopen_automation/test/protocol_test.dart |
the agent data channel (§4.3) on an injected host: ping as in step 1 — literal, empty, missing or JSON — whatever the host; every op reaching the host with its arguments and answering what the host answers, one case per op; a host that cannot act, the product’s no-op host among them, answering not_available to every op and hearing nothing, and a host without Recording answering not_available to record_start, record_stop and record_state alone; a message that is not an op refused as bad_args whatever the host, and every malformed argument refused as bad_args naming the part before the host hears of it, a language the app lacks and an empty label among them — on a host that can act, since one that cannot answers not_available first; an unknown op refused, and named only when it is a name; not_found naming the identifier, the field or the kind of target, never the typed text or the label; a host without the semantics taps answering not_available to a tap by label or position, and one that cannot exit to quit; tap_at marked a diagnostic; the log travelling without an error’s message or stack, nor the data of the account’s events, and a line that is not an event staying behind; a host’s refusal travelling as its code, and an unexpected failure as its type, logged; ops one at a time in arrival order, with neither a ping nor an unknown op waiting; an op past its time answered timeout / running and still ending before the next starts, one whose time ran out in the queue answered queued and never run, and a late success, refusal and failure logged; a selection of every kind in the plan’s shape and back; find over a semantics tree; set_tab naming Settings’ general and keys; the usage tab |
packages/filmopen_state/test/semantics_walk_test.dart |
the walk over the semantics tree (§4.3, §7.1, §6.8), on a pumped tree: |
- a node by its identifier, and by its label (a tab by its label’s first line);
- the tabs of a tab bar;
- a tap going through a wrapper to its button, and from an identifier inside a tappable item to the item, but never through a disabled button, nor up past a disabled control, a button without a tap or a route;
- a pull-down (
DropdownMenu), whose field offersexpandrather thantap, opened through its identifier and an item chosen by its label, and a disabled one refused although it still wiresexpand(milestone 4.7:activationOf); - a rect in logical pixels at a pixel ratio of 2;
- the tree as data: a project field’s value travels, an unidentified text field’s value is withheld, an e-mail address is masked in a label, and a provider key’s obscured box travels neither its value nor its bullets, while a key box shown by its eye (milestone 5.01) is withheld by its identifier, not by obscurity;
- the entity page’s tabs by their ids and the selected one, in English and Spanish;
- the control at a point (
semanticsNodeAt,describeControl, the issue recorder’s rule, the note’s D6): a button by the identifier around it, and the same by its node id; a chip by its label within the row that carries the identifier; a pull-down by its identifier opened withexpand, and its item by its label once the pull-down is open; a dialog’s button by its label, borrowing no identifier from beneath the navigator; a tab as a tab; a point on nothing that takes a click not resolved; a control only a tooltip names, and a second control of the same label, not resolved; a control keeping its own identifier though it holds another control (a row around a star button, a text field around a clear button), one drawn outside its parent still found, and a masked label not resolved; textValuesandfocusedTextIdentifier: a value read undernew-entity.tagand withheld underaccount.login, and the focused box named only under a project identifier | |packages/filmopen_automation/test/ring_log_sink_test.dart| the host’s copy of the log (§4.3): an event’s line with its time, level, name and data, and an error by its type only, never by its message or stack; the ring keeping the last lines, oldest first; a value JSON cannot hold, written as its text; the log’s threshold still applying | |packages/filmopen_recorder/test/tape_test.dart| the tape on its own (§6.8): one JSON object per line, LF,seqandtrising,atin UTC milliseconds; every text masked, in keys, lists and maps, with a typed value outside a project identifier refused; a report path refused when it could leave the folder; the log’s tail keeping this session only, from the app’s last start; a value JSON cannot hold written as its text, masked | |packages/filmopen_recorder/test/snapshot_test.dart| the project snapshot (§6.8): every text file copied and media left out, the copy taken after a write differing from the one before by that write; the folder on disk appending the tape line by line and writing files beneath it; a recording’s folder named after its second, and a second one in the same second getting-2| |test/recorder_test.dart| the issue recorder on the whole app (§6.8): a dialog is inwindowKey’s picture and not inrootKey‘s, and the route seam hears it open and close; a scripted sequence — a character’s box, the Low chip, Add character with a tag typed and cancelled, Compare, Settings and the handle — on the tape in order, with a picture and the state after each action, no account value, the project’s files before and after, a tape whole line by line beforestop, and every door given back; a door that fails mid-recording leaves a readable tape and the recording goes on; a start that fails gives back every door, logs its type and names no folder; the rail’s ? opens Get Support and About (the specification’s version, the open-source notices), its Record an issue closes the dialog and starts a recording, and the ? becomes the stop, which opens no dialog; the dialog fits at 375 and 1200 px in Spanish and dark, on both tabs | |test/agent_host_test.dart| the agent’s host on the whole app (§4.3), on a fixture. Setup and navigation: installed and attached, it names the project; it navigates to a version and refuses a selection that names nothing. Compare and typing: it opens Compare, copies a row by its identifier and types through a form’s own setter; the autosave writes both, and the log namescompare.copiedandentity.saved; a pull-down takes no text; an unknown identifier and a tab the page lacks are not found; a blocked Compare isrefusedwith its reason’s code. Fork and dialogs:findnames the version row’s four controls; a fork by its button lands as a click’s does, the state naming the new stem, the tree its row and the logentity.forked; Compare’s question isinterceptedand answered by a dialog button’s label. Page and settings: the page is painted as a PNG; Spanish and dark go through Settings’ doors, a language the app lacks is refused, and in Spanish the tabs still answer to their ids; an e-mail address typed as the handle browses as nobody and travels in the state only masked; a tap at a position opens Settings. Recording: the recorder’s ops (record_start,record_stop,record_state) starting, naming and stopping a recording, and its tape hearing the agent’s taps markedvia: semantics. Quit: the open drafts written before the answer, the exit asked for only after it, and a running recording stopped first, its tape ending with the exit; a draft that cannot be written refusing the quit, and the application staying; the settings the session started with put back through Settings’ doors. Unattached: nothing is answered beforeattach, and the host is not available afterdetach; Settings’ tabs by their ids;quitputting dictation’s microphone and switch back too | |packages/filmopen_mcp/test/server_test.dart(milestone 5.01:launchon android requires a device and refuses a size, a home or a device on a desktop, the device reaching the launcher; and a client that sends"size": nullis refused by the tool’s own schema, which names the property, before any argument handling is reached) | |packages/filmopen_mcp/test/server_test.dart| the MCP server (§4.3) through an MCP client on an in-memory channel, against a fake link:- Tools: a tool for every op of the app’s channel,
quitamong them (checked againstfilmopen_automation’sagentOps), beside the window, the wait,enter_text,launchandstop;enter_texthanding its text to the link’s text entry and never to the channel, answeringtyped, and refusing a missing or non-string text asbad_args. - Answers: each op tool sending its op with its arguments and never the URL, and answering the result as JSON text;
page_shotandscreenshotanswering image content; an app’s refusal returned as an error result with its code. - The link: a failure on the link, or a request past its time, dropping it, answered by its type without the URL, and reconnected on the next call; a
vmServiceUrlthat is not a string, or not on this machine, refused asbad_argsforstateand forstop, the link kept; the announced agent build found by itself, looked for again only once its link fails, andno_appwithout one; a service that is not an agent build named so, and nothing kept; a window shot written only as a new.pnginside its folder, or over one the server wrote, and any other path — outside it, not a PNG, another’s file, one the file system refuses — answeredbad_args, the link kept;wait_idle; calls sent at once opening one link between them; calls still queued when the client leaves answered without running, no launch starting, and a link opened meanwhile closed; a client leaving asking the app it launched toquitbeforeq. - Launch and stop:
launchstarting, linking, waiting, applying its options and answering the state, and refusing a second launch and a platform it cannot run; the launched app outranking the URL named at start; a launch whose app does not let the driver in answeringlaunched: true, and still stopped; a launch that fails before its app answers giving its reason, its exit code and its last lines; a launched app that ends by itself forgotten with its link, its launcher asked once to clear what it left; a client leaving mid-launch stopping what was started;stopending a launched app with itsquitandq, over a new link when none is open, or saying it had to kill it; a draft it cannot write keeping it running, a quit still writing waited for (timeoutwhen it stays), and an app withoutquitended withq(by: q); astopthat names another app ending that one, the launched one staying; any other app ended through its ownquitand waited for (timeoutwhen it stays), a refusal to quit passed on, andno_appwith nothing to stop. | |packages/filmopen_mcp/test/discovery_test.dart| finding an agent build (§4.3): the log folder on each system, as the app computes it; an announcement of a service on this machine read, and anything else — another machine’s included — refused; a build found while its service answers, and not once it has gone, nor from a half-written file or no file; a stand-in service answering, and a closed port not | |packages/filmopen_mcp/test/launcher_test.dart(milestone 5.01: an android command line takes the device’s serial where a platform’s name would stand and neverxvfb-run; a launch with no device is anArgumentErrorrather than a guess at-d android; a launch on a Linux host gets a process group of its own whatever the target; the keys reach an android build; and android’s launch environment is empty, the screen being the device’s) | |packages/filmopen_mcp/test/launcher_test.dart| starting an agent build (§4.3): the service lineflutter runprints matched, and the DevTools line not; the command for Windows, and for Linux undersetsid xvfb-runwith the Xvfb screen ofsize;parseWindowSize; the launch environment (FILMOPEN_WINDOW, the XDG folders on Linux);prepareAgentHome; on Linux a process group ended as a group, and a launch that makes a temp XDG home whichstopremoves, a named home left; the repository an executable belongs to; against stand-ins forflutter, a launch answering the printed URL, a second launch refused,qreaching the process and ending it, a process that ignoresqended once its time runs out, a stop asked for whileflutter runis still being started ending it once it has, and aflutter runthat ends first failing with its exit code and its last lines, the e-mail address and the token cut out | |packages/filmopen_mcp/test/cli_parse_test.dart(milestone 5.01:launch android --device <serial>parsed, a size or a home there a usage error, a device on a desktop a usage error, and the command line built from the command as parsed — the join where--devicewas once parsed and then dropped with every suite still green) | |packages/filmopen_mcp/test/cli_parse_test.dart| the executable’s command line (§4.3), read as the executable reads it: no words, andserve, serving MCP; every op command’s message,quit‘s included, and words starting with a dash (tap --label,open-project --sample, a negative number); the executable’s own options anywhere before a--, the words after it the command’s, and--helpwherever it stands; pictures written to a named or default file;launchon this machine’s platform unless it names one, with--sizeand--home;enter-textwith its one word;record-start,record-stopandrecord-state;stopandwait-idle; a command line it does not understand, an option without its value, a--sizeoutside<width>x<height>320–4096, an empty--homeor anenter-textwithout its text, refused with a reason | |packages/filmopen_mcp/test/app_link_test.dart| the link to an app (§4.3), against stand-ins on the loopback: the token cut from every service URL in a text, with or without the slash after it, and percent-encoded; a service URL on this machine beinghttpon the loopback; the WebSocket address the driver derives, from the URL’s first path segment; a service that answers probed and let go; a closed port refused at once, and a listener that never answers refused once its time has passed, asServiceUnreachablenaming no address, to the probe and to a connection; the wait for a service to end; a VM service whose isolate carries no driver extension refused asNotAnAgentBuild, its connection closed; the time running out in a requestNotAnAgentBuildwhere the service had answered andServiceUnreachablewhere it never did, never a bare timeout; one whose isolate carries it sent the driver’s own commands, a request past its time failing; and one whose isolate is paused at its start resumed, then connected | |test/ui_cleanup_test.dart| milestone 4.6 on screen: the header with the name alone, no Version word and nothing after Fork, the label button between the pull-down and the star; the file menu on the first card’s title row without making it taller, and no save status while saving; closing the window writing a draft typed inside the save delay, and ending an open dictation, before answering exit; Compare’s left heading without pencil, check or status, and<<and<<<centred on the rows’ gutter; the epochs without chips or explanation; Close project and its blank page, the menu then offering neither Close project nor Re-read files; × taking a remembered folder off the list with the folder kept, the trash’s No doing nothing and Yes deleting a real temp folder and its row; Edit JSON… above Copy JSON, opening on the tree, a tapped value changed, applied and saved; a long value kept to its row at 700 and 1200 px; the microphone on the value dialog and never on the tree or the text mode | |packages/filmopen_forms/test/json_tree_edit_test.dart| the JSON editor’s tree without a widget: every path the tree spells reaching its value through objects and lists, a value written at exactly that path, a path two keys would spell alike left out, and a typed value keeping its kind | |test/new_entity_test.dart| milestone 4.7’s Add character and Add location: the dialog’s identifiers, a taken tag refused inline with Create disabled, the short name filtered and the file it will write named, the name, kind and author written and the page moved to the new version on Edit; the epoch pull-down only where the project has several, an empty name written as the short name and no kind written when none is chosen, at 700 px without an overflow; a file of that name appearing before Create refused as taken with nothing replaced and the handle put back; the username typed in the dialog written as and saved to Settings; the button disabled with the reason on a read-only project | |test/identifiers_test.dart| the semantics identifiers an agent acts on: unique on the overview, a version page, the open project menu, the New project dialog, a category page and the New location dialog, the label dialog, Compare and Settings; the rail (nav.helpamong them), the tree pane and its rows, the project menu, the version row, the file menu, the fields (file and key), the dialogs and the copy controls named; a form registering its setter, typing through the registry reaching the box by the field’s own setter, a pull-down and a key no field owns answering false, the locked column having no setter; Settings’ Provider keys tab; the microphones on the tree’s filter, a form field, both dialogs and Settings’ handle, the label dialog’s box, the popup’s doors opened from a filled box and from a blank one, and the Usage tab’s; none on the New project dialog’s folder box or the Provider keys boxes | |packages/filmopen_format/test/platform_file_test.dart| the catalogue’s platform files (Project Specification §14.6, §14.9): the bundled index’s fifteen platforms, the first version’s three first and every other pending with its obstacles; every listed file parsing with no issue under the id it is listed with, and the first version’s carrying what Provider keys needs; a file that cannot be used reported by code and never thrown (not JSON, a missing or mistyped field, an unknown kind, a key platform without credentials or with a placeholder no credential fills, a credential id that is not a plain token or repeats one, a header that is notName: value, a keychain other thanfilmopen/<id>, a base URL over http or off its hosts, another id), a bad page URL leaving the file usable, and a check that cannot be used (a method that is not a token, an http or unlisted URL) leaving the check out and the file usable; credentials only over https or wss to a listed host; the header filled from both fields of a two-field platform; an index status other thanpendingread as pending, and a listing without its file, name or kind, or with one of the wrong type, left out | |packages/filmopen_platform/test/credential_store_test.dart| the credential store’s seam (§6.7): nothing installed failing closed; the memory store round-tripping a value, counting every call and failing when told; the device store running one call at a time, in order, and turning an operating-system failure intoCredentialStoreUnavailablewith its type only, the next call still running; a call the operating system never answers failing closed astimeout, the queue moving on | |packages/filmopen_platform/test/key_verifier_test.dart| the key verifier’s seam (§6.7): nothing installed checking nothing and sending nothing; the stub answering by how a fake value ends | |packages/filmopen_keys/test/http_key_verifier_test.dart| the check’s request againstMockClient(§6.7): a 2xx verified, with one request to exactly the check URL carrying the key in the platform’s header and its other headers; 401 and 403 rejected, and any other status not checked; a 302 not followed; a timeout and a failed connection; the body never read; the method sent as written, and a value dart:io refuses in a header answered asbad_value; a check on an unlisted host kept out of its file, awsscheck refused, and nothing sent without a value | |packages/filmopen_keys/test/provider_keys_test.dart| the keys’ controller on the memory store and the stub (§6.7): Save checking a value with the platform and storing one trimmed JSON object under its slot’s store name,filmopen/<owner>/<slot>where the platform accepts it, and replacing a stored key; a refused key and an unanswered check, neither of which reaches the store, both keeping their code for the line under the box; a platform with no check, stored unchecked; Remove deleting that name alone; a two-field platform stored as one entry; an unusable store changing nothing, without throwing; the states in memory for an ephemeral store and in the preferences otherwise, read back without opening the store; log events carrying the platform and a code only; no last four for a short value; one action per platform at a time, recorded for the cards, another platform not waiting; a verifier that throws, which could not check; a value cleaned of invisible characters, and one no key could hold refused; a refusal keeping its code; a preference that is not text, and a state the preferences refuse to keep; a state read from JSON; a platform with noverifychecked by the owning plug-in’sverifyKey— accepted and stored, refused and not stored, a failed check not stored with its code | |packages/filmopen_plugins_ui/test/plugins_tab_test.dart| Settings → Plugins over a runtime the test answers (§9.4): the web build’s one sentence; a plug-in that needs consent turned on only through Allow, Cancel leaving it off, the consent naming its keys and hosts, a 44 px Allow, turning off asking nothing; a plug-in’s page — a machine setting saved 700 ms after typing and throughFieldRegistry, a number box saying inline that its text is not a number and saving nothing, an action running once at a time with its answer, a key not entered said as such, a grant; the preferred providers’ pull-down and its line when none provides; the list, the consent and the page at 700 and 375 px in English light and Spanish dark | |packages/filmopen_keys/test/provider_catalogue_test.dart| the tab’s platforms out of the one reader’s catalogue (§6.7): a platform whose card would repeat the tab’s identifiers left out and logged, pending ones counted; a catalogue that could not be read gives the tab nothing, and the tab logs nothing of it | |packages/filmopen_l10n/test/labels_keys_test.dart| a key check’s codes to their sentences, a refusal’s sentence for a 403 and for any other, and the key boxes’ labels translated where FilmOpen knows the word, the platform file’s own otherwise | |packages/filmopen_l10n/test/labels_dictation_test.dart| the Usage tab’s names for the boxes a use was for, a project’s name as its Title, and its failure sentences without the popup’s Resume; a sentence of its own for every code a dictation gives,canceledincluded, in both languages, the two refused-key statuses sharing one, and an unknown code naming itself in the general sentence | |packages/filmopen_keys/test/provider_keys_tab_test.dart| Settings → Provider keys on its own, over the bundled catalogue (§9.4): the explanation, then OpenRouter, fal and OpenAI in the index’s order, then the pending line, every identifier once and no store opened; obscured boxes without suggestions, every disabled action’s reason, and Save ready as soon as every box is filled; a mismatched prefix warned about only once what was typed can no longer become it, and saved after Save asks, the box emptied, and the key on no widget and in no log line; a refusal and an unanswered check, each with what to do, nothing stored and the box keeping the paste, and then the same button storing it once the platform answers; a platform FilmOpen cannot check, stored unchecked; Remove asking first, with a 44 px confirm; an unusable store said inline, the box keeping the key; the web build’s sentence; an unreadable catalogue; the links’ addresses, and a link that cannot be opened; throughHttpKeyVerifieronMockClient, a 200, a 401 and a timeout, with the checking line and every action’s reason while it waits, and each request to its own platform’s check address; no way to show a key; a card built again while its key is on its way to the store, Remove‘s question outliving its card, no undo bringing a key back, Save asking first and its cancel keeping the box, Enter saving, a value no key could hold, and a 403’s line; the longest lines, none cut, at 635 and 295 px, light and dark, English and Spanish; the eye on a secret box (milestone 5.01): shows the key, hides it, a refused key stays shown, a stored one is not left on screen, and a box that is not secret has no eye; while a recording runs every box is hidden and the eye says so, the choice standing once it ends; the busy eye says why | |packages/filmopen_keys/test/usage_tab_test.dart| Settings → Usage on its own (§6.7, §9.4): the web build’s sentence; an empty log; the totals by month and platform, newest first, a live session counted as spent and a waiting estimate as pending, a refused call counting nothing; each use with what and which box, when, the minutes, its state, a failed dictation’s sentence and its cost at the line’s end, newest first; the month filter; Spanish decimal marks and currency; a call of a version this app does not know left out and said; an append showing at once, and another copy’s record on reopening; the file each box belonged to; the words pending and possibly charged explained where they are shown, and only there; nothing cut or overflowing at 635 and 295 px, light and dark, English and Spanish | |packages/filmopen_keys/test/usage_providers_test.dart| what the Usage tab reads (§6.7): the log read, and read again after this app appends a record; calls newest first, by the month they count in, and the totals as §6.7 says; the month filter starting on every month and keeping what it is given; no log installed reading nothing, without throwing | |test/settings_tabs_test.dart| Settings’ three tabs on the whole app (§9.4): General first, Provider keys over the bundled catalogue without opening the store, and back; a tab asked for from elsewhere; Settings opening on General again after it was left on Provider keys, even when a tapped tab was still moving; the first scrollable under Settings still General’s list; both tabs at 700 px, light and dark, English and Spanish, and the tab labels at 375 px; Usage reading this device’s log, opened by tap, and fitting at 700 px | |packages/filmopen_format/test/money_test.dart| money as exact decimals (§6.7): a JSON number read exactly from its text, exponents included, and nothing else taken for one; amounts rounded half away from zero to twelve places; a cost at the catalogue’s price times its units; a reported cost below zero or with more than twelve places being no provider cost; a money object with a sign, an exponent, other than twelve places, another scale or no basis being none | |packages/filmopen_format/test/catalogue_entry_test.dart| the catalogue entry reader (Project Specification §14.7): an entry’s id, kind and access rows with their prices; two prices for one unit, or two rows on one platform, read as no answer rather than a guess; an entry that is not JSON, not an object, names a field twice or lacks its id, kind or access, not read; the bundled live-transcription entry read at OpenAI | |packages/filmopen_format/test/catalogue_test.dart| the one catalogue reader (§6.7, §6.11): the listings, every platform file pending or not, and each entry once in the index’s order; what cannot be read or used a problem with a code and never a message; an unreadable index an empty catalogue with the one problem; an index that is not an object said so | |packages/filmopen_format/test/usage_record_test.dart| the usage record of §6.7, version 1: a started record; a later record naming the first inrefand repeating its fields, whilev,id,at,status,ref,appanderrorare its own; a record version 1 does not allow refused as the app builds it; a record’s time kept after the chain’s latest, even under a clock that does not move; ids as random version 4 UUIDs in lower case; a line that is not a complete record skipped with its reason, and fields a reader does not know kept; a record of a version this reader does not know naming its chain, which is skipped whole; a record’s JSON read back as written | |packages/filmopen_format/test/usage_totals_test.dart| the chains and totals of §6.7: each chain counted once, by its counted record, as its status says; the counted record the one with the latestat, and on a tie the greaterid; a correction carrying the counted record’s status never reopening a call; records whose first record is missing counted as one chain, in the month of the earliest; a chain counted in the month of its first record; a chain holding a record of an unknown version left out whole; months newest first, then platforms and currencies in order | |packages/filmopen_store/test/usage_log_test.dart| the usage log on disk (§6.7): two processes appending under the lock, leaving 400 distinct records, no broken line and one device id; a write waiting for a lock another process holds, and giving up after its time withlock_timeout; a last line cut short getting a line feed before the next record, and skipped and reported once; a record of an unknown version leaving its whole chain out, reported once;device.jsonmade once, its id kept by every log of the folder, and made again, through a rename, only when it holds no valid one; a lock file that cannot be opened failing at once asio; a folder with no usage yet reading as none; a later record retried until it is written, and a log unavailable altogether giving up; no log in use until an entrypoint installs one | |packages/filmopen_dictation/test/transcriber_io_test.dart| the device’s WebSocket against servers of the test’s own on the loopback (§9.6): a handshake answered with a redirect not followed, ending as its status, the key reaching only the server asked; an upgraded handshake carrying messages both ways; a server that is not there being a connection that did not open | |packages/filmopen_dictation/test/openai_transcriber_test.dart| a close under way ending the session without a code, even when the connection then fails or the time for accepting runs out; OpenAI’s live transcription over aStreamChannelController(§9.6): the session message exactly D8’s with turn detection off, pinned whole; a commit and a clear sent only while the session is accepted and open; a turn taken, a turn that could not be transcribed and an empty commit refused as the session’s events, and other errors after acceptance ignored; a session turned down carrying the platform’s type, code and field, each only where it is a code or a field path; text by turn, a completion replacing its own turn and no other; a refused handshake, a connection that never opens and a session turned down, each ending before it opened; a connection that drops after it opened ending the session with its minutes counted; a session never accepted giving up after its time; no transcriber until an entrypoint installs one; the scripted transcriber answering frames with turns, and a commit with the turn in progress, or with a refusal where there is none | |packages/filmopen_dictation/test/speech_gate_test.dart| turns decided on the device (§9.6): a frame’s level as the root of its samples’ mean square; quiet committing nothing and cleared every ten seconds; speech, then 0.8 s of quiet, committed once; a click cleared and a two-frame name kept; a pause under 0.8 s keeping the turn; talk without a pause committed at 30 s and going on in a new turn; speech already under way when the gate starts heard as speech and committed, held or paused, with the room kept under its softest syllable; a voice with a narrow range heard through a minute without a pause; an even sound that stops committed, as a cough is; a steady hum learned within seconds, the turn it made meanwhile cleared as noise, and a voice above it committed, while in a quiet room the same level is speech; a microphone that hisses after a quiet one committing nothing; digital silence not making every sound speech; the turn in progress where the audio stops committed, cleared — too short, or as even as a tone — or nothing, and a reset forgetting it | |packages/filmopen_dictation/test/dictation_controller_test.dart| the session controller on the fakes (§9.6): the chain —startedwith a one-minute estimate and the key’s last four,runningafter each full minute,succeededwith six-place minutes, every later record naming the first; no key, a stored value too short or not JSON, and an unusable store, starting nothing; a refused microphone costing nothing; a refused session endingfailedwith its code and no cost; a dropped connection; a microphone failing while recording; Stop and Resume in one chain; Stop answering at once, and a session ending while the microphone stops keeping its end; a second Resume starting no second microphone; a session closed before it was accepted endingcanceledwith no cost, and one lost unaccepted with no reason endingfailedconnection; a close while the key, the microphone or the first record is on its way stopping it there, logging nothing, and nothing starting after it; a microphone chosen while listening taking over at once, and one chosen while stopped or while the session opens recording from the next start; Resume doing nothing while a session ends; a close while the log answers turning the microphone off at once; the app’s exit ending a dictation still listening and waiting for its last record, even from a platform that never answers the close; a platform file that takes its key other than as a bearer token refused; five minutes without speech, then a new chain with the text kept; the limit’s warning and end; an unwritable log stopping everything before anything is sent; the device deciding the turns: speech then quiet committed once and the quiet after it cleared, and nothing committed before the session opens; nothing committed or cleared once a session has ended, even with speech in progress; Stop committing the turn in progress, andsettlewaiting for its final text, at most three seconds, and not waiting again for what got no answer; a refused commit and a turn that could not be transcribed waiting for nothing, and no turn waiting past its session’s end; speech the device hears keeping a session open past five minutes, with a turn committed every 30 s of talk, and quiet ending it; a session turned down logging the platform’s codes and field, never its message; the key in no record and no log line, and the log saying, in numbers, how the device heard each session, each line counting only its own; the minutes’ arithmetic; the setup read from the bundled catalogue, refusing an endpoint off OpenAI’s hosts, one that is notwss, one with anything before the host, and an entry with no price for a minute | |packages/filmopen_dictation/test/dictation_popup_test.dart| the microphone and the popup on a box of their own (§9.6): the microphone and its menu on a box that can be typed into and none on one that cannot or on the web; a blank box listening at once, its text’s label at the top, and Insert going through the box’sonChanged, the chain endingsucceededand naming the box; a box with text asking first, AI rewrite greyed with its reason, the apply button reading Apply and no count until a choice, Insert at cursor at the caret, and Replace; a box whose rules keep nothing left as it was, saying so, and a box at its limit saying that instead; Cancel before and after listening; Stop and Resume, and Resume waiting, with its reason, while a session’s last record is on its way; Escape as Cancel, over a held popup too; no key, with no count, opening Provider keys, and from a box in a dialog closing the dialog too, while a dialog guarding its changes stays and the page with it; a refused microphone; a refused key offering no Resume; the menu listing the microphones when it opens and keeping the choice and the switch, and a microphone chosen in the popup recording from then on in the same session and chain; the menu checking the system’s microphone when the one chosen is gone, and opening once for two quick presses; Hold to record from press to release, the turn in progress committed and its final text applied, with no menu over the held popup and the doors and the status line saying why they wait while it applies, and the status line saying the session is ending while the popup closes; Insert while listening committing the turn in progress and putting in its final text rather than the half shown; a hold the system cancels, or the switch turned off, stopping the audio and applying nothing until a button says so; a box that goes, tapped or held, taking nothing while the popup says so and keeps the text; a release with nothing said closing, and one on a failure staying; a release before the session opened, with nothing sent or said, closing the popup and ending the chaincanceled; edited text staying the person’s while later turns follow it; the popup at 700 and 375 px, light and dark, English and Spanish;applyDictation’s caret, length-limit and formatter rules, Replace on a box already at its limit included | |packages/filmopen_platform/test/audio_source_test.dart| the microphone seam (§9.6): a frame is 100 ms of 24 kHz mono PCM16, 4,800 bytes; no microphone until an entrypoint installs one; the fake source’s two devices, the one a recording asked for kept and a device no longer offered falling back to the default; a fake recording giving a frame per interval until it is stopped, stopping twice harmless, silent unless told how loud each frame is, and then a square wave of that loudness; a microphone that cannot start saying why, once; a recording failing as a microphone taken away does; the framer cutting chunks of any size into 100 ms frames, keeping its own copies and passing errors on | |packages/filmopen_platform/test/app_paths_test.dart| the folder doors on a desktop (milestone 5.01, D4–D5): the person names the folder, a file can be shown, and the two answers are one decision | |packages/filmopen_platform/test/incoming_links_test.dart| the link door (milestone 5.01): nothing installed answers nothing; the memory door answers its initial link to every caller and delivers what it is handed |
Where a test lives: with the code it proves — dart test in the pure packages, flutter test inside a Flutter package — unless it pumps the whole application, in which case it stays in the root test/; scripts/test_all.sh (or test_all.ps1) runs every suite. Widget tests load projects in setUpAll because file IO does not complete inside a widget test’s fake-async zone. Tree rows draw label and detail as one rich text; match them with find.text('label detail', findRichText: true).
flutter analyze and flutter test must both be clean before a commit. Tests that need dart:io import the _io file directly (log_file_io.dart), because the analyzer resolves a conditional export to its stub.
13. Conventions for contributors
Section titled “13. Conventions for contributors”- No user-facing text outside
filmopen_l10n. The format and the store emit codes. Widgets take strings fromcontext.l10n; providers fromlocalizationsProvider. - Every write goes through
ProjectWriter(orwriteNewProjectfor a new project), through the source’swriteText/deleteFile, followed byrefresh(). No widget writes a file, and nothing edits the index in memory. - Write only as the viewer. A write without a handle throws
NoViewerException, another author’s fileNotYourFileException; the UI turns these into messages and offers Fork or Settings. - No Flutter and no plugin in
filmopen_format,filmopen_storeorfilmopen_test_support; no widget package infilmopen_state. - A package imports another only through its barrel (
package:filmopen_x/filmopen_x.dart), neverlib/src/; a new dependency is a line in that package’s pubspec and a row in §3; a boundary that needs a cycle is redrawn, never merged. - Every control an agent drives carries a
Semantics(identifier:)—nav.*,tree:<node id>,tree.*,project.*,new-project.*,category.add,new-entity.*,version.*,label.*,file.*,field:<stem>:<key>(the key a field pairs on),epochs.*,banner.fork,compare.copy-*,compare.fork-*,draft.*,guard.*,pick.*,json.*,settings.*,account.*,keys.*,usage.*,<box>.dictateanddictation.*(§9.4, §9.6) — unique on every pagetest/identifiers_test.dartwalks (the overview, a version page, the project menu, a category page, three dialogs, the dictation popup, Compare, Settings and its Provider keys and Usage tabs), and by construction elsewhere (path-like row ids, the file’s stem in a field’s, an enum’s name in a notice’s). Tabs carry none: Material’s tab bar merges each tab’s semantics into one node and an identifier inside breaks that merge; a tab is opened by name (AutomationHost.setTab). - No product widget knows it is recorded; a control an agent drives carries an identifier, and renaming one breaks every tape that names it (§6.8: the issue recorder hears a person through Flutter’s own bindings and the state’s seams, never through a line that says “if recording”).
- Platform code comes in threes:
x.dart(conditional export),x_io.dart,x_stub.dart; the stub answers “not available” (canX = false, null, orUnsupportedError) and the UI hides the affordance. Never importdart:iofrom a file the web build reaches. - Log events, not sentences:
AppLog.instance.info('project.opened', data: {...}); dotted identifiers, structured data, no user-facing text. Log every open, write, failure and setting change; log timings at debug. - One rule for short names: anything that must be a segment goes through
ShortNameand, in the UI,ShortNameField. - Never throw on a bad file. Add a
WarningKindand report. - Never choose for the user where the Project Specification says report. Return an unresolved
Resolutionor a null selection. - One way to navigate:
browserProvider.navigate(...). - Comments explain why, and cite the Project Specification section (
§6.1) when a rule comes from it. - Filenames and identifiers follow the format’s vocabulary:
stem,tag,epoch,author,official,take,batch,cue,block. - Tests for every spec rule on an in-memory fixture, not only on the sample.
- Keep
kAppVersioninlib/consts.dartequal toversion:inpubspec.yaml. The constants the packages read (the name, the development-mode switch, the bundled sample) arefilmopen_state/consts.dart. - Commit
pubspec.lock(it is an application) and the generated localisation files.
A media source is a package. It implements MediaStore (and, above, its own MediaSourceUi) and is registered by its locator type in one list; no other file names a source. A page that shows media asks ProjectMedia and the store it was given, and never where the bytes are.
No media file is ever overwritten. A store writes with create, and a name comes from the take grammar with a batch id made unique against the author’s own batches (Project Specification §5.4, §12.3, §18.3). A thumbnail is the exception that proves it: a derivative a reader may re-make, written without create.
14. Known limitations
Section titled “14. Known limitations”- Forms exist for the project file, characters and locations. Every other type is edited through the JSON editor until it has a form of its own. On a character or a location
refsandpreviewbelong to the reference card (§9.3) and have left Other attributes; the media keys it does not own (picks,voice.providerBindings) are read-only there, and a value of another shape than its box’s is changed through the JSON editor. - §13.3’s block table is only half drawn. The format asks a reader to collapse unchanged blocks (6 unchanged), to mark the changed words of a block both versions have, and to leave a gap where the other version has a line this one does not. A comparison here draws every block as its own row, marks nothing inside the text, and says Not in this version rather than leaving a gap. What the table says copy-left does in each of the four cases is implemented exactly; only the rendering is outstanding, and it is milestone 6’s, with the script editor.
- No per-attribute compare of two epochs of one entity (§13.3) yet: Compare’s left column is the viewer’s latest at the epoch on screen, so nothing compares across epochs. Copy-left itself is built (§9.3).
- Copy-left does not move what two versions disagree about the shape of — a key one holds as an object and the other as a list or a sentence — nor an empty value of any kind, since an empty value is how this application removes a key. The JSON editor is the way through either.
- A save re-reads the whole folder. Fine for hundreds of files; a project of thousands would want an incremental index.
- Concurrent edits are refused, not merged. A file changed since it was read is never overwritten (
StaleFileException); the user re-reads and repeats the change. Two people editing the same file at once still need Git. - Toggling the star off deletes the pointer. The previous official choice is not remembered; setting it again is one click on that version. A pick that tracked official then falls back (own version, else the sole author), as §6.1 says. Dragging an epoch to the top makes it the default without confirmation.
- Except for a file an official pointer names, which resolves through official whoever is reading (§5.4), the viewer is the referencing author. §6.1 resolves from the point of view of the file that holds the reference; the application resolves everything from the viewer’s point of view, picks included. The two agree whenever the viewer browses their own work.
- The account does nothing yet beyond signing in and a display name: no project is synced, no key is fetched. Browser sign-in works on Windows (the scheme is registered on first use) and on the web; on macOS and Linux only the e-mailed code works until those builds exist. The OAuth round trip has been verified by reading the plugins’ sources, not by a live sign-in from this checkout.
- Google Drive is resolved (§6.10), and a build with the owner’s OAuth client can keep a film’s media there. Every other locator (Dropbox, OneDrive, iCloud, URL) is recognised and reported, not resolved: the project opens and its media is shown as unavailable, with the locator’s type named.
- Mobile “show in folder” (iOS
shareddocuments://, Androidcontent://) has not run for a shared-storage path: Android is configured since milestone 5.01, but the app’s own files are app-private and the Android branch answers false for them; iOS is not configured. - The shared library is not updated by the app: it is seeded once and then left alone.
- The model catalogue is read only in part.
pubspec.yamlbundlesassets/models/models.json, the platform files and the dictation entry (stt-gpt-live-transcribe.json; the Project Specification §14.6–§14.9): Provider keys reads the platforms (§6.7), and dictation its entry and OpenAI’s file (§9.6). The other entries are not bundled, and a model (mo) or platform (pl) library entry is still written by hand. - Warnings are not grouped; the manifest warnings join the same list.
- Resolution through official for official files (§6.1 second paragraph) is implemented in the model and applied where a version’s own references are rendered — today only the Script tab’s speakers. Everywhere else, including the tree and the pages, the reader keeps browsing in their own world.
- Fountain, SRT, OTIO, timeline export and every §15–16 concern are out of scope so far.
- Media: a picture is shown, and a clip or a sound plays on Windows (§9.3, through the operating system’s own Media Foundation). The web and Linux have no player yet and say so, offering Show in folder where the file is on disk. A clip’s thumbnail is the kind’s stock picture, not its first frame.
- Narrow layouts are supported down to about 320 px of window (§9.1): below that a comparison scrolls sideways rather than fitting. The web build is a preview, not a target. Since milestone 5.01 the app runs on Android — the Galaxy S24 Ultra at 411 × 891 dp and the emulator at 448 × 997 — where the tree is a drawer and the system’s back — the browser’s Back in the web preview too — closes it (
BrowserPage); the widths are verified in tests (360, 375 and 411 among them), in the browser preview and on the emulator. - Spanish is a first translation, not yet reviewed by a native speaker.
- Windows path length limits are not checked before writing a new project folder.
- Warnings are per load and are not persisted or grouped.
- A development-mode edit of a library file is permanent. The shared library is read by every project on the machine and seeded only when empty; nothing restores the bundled copy.
- A re-read during a change of library folder may bring the old library back until the next open; two forks of one version at the same moment tell the second person the file changed underneath.
- A draft whose form has gone can still be refused, and only the log says so. A form left during a write writes what was typed meanwhile after that write; when the re-read of that write was overtaken by another, or when the write failed and its one retry is refused, the later typing is refused as stale with no save status left to show it.
- A session copy is lost when it is left. Choosing the sample again, or closing the app, drops a session copy’s edits; there is no exit guard (milestone 3.5’s disposition). The tree header says session copy — not saved to disk, and Copy to a folder… keeps it.
- Copy to a folder copies text, not media. A session copy keeps the JSON (and other text) of the project; the takes the sample reads from the bundle stay behind, and the copy’s manifest then names a media root that is not there, which the reader reports.
- The agent layer controls the app on Windows and on a headless Linux runner (§4.3, §7.1).
- An MCP client drives an agent build through the server’s tools; this is proved on Windows and on Linux under Xvfb. Linux is a runner, not a product platform promised to users.
launchtakessize(<width>x<height>, 1280×720 when omitted) and, on Linux,home(fresh XDG folders otherwise).- Every product build runs the no-op host.
- No op scrolls, so a node of a lazily built list that has never been in view cannot be found.
page_shotshows the whole view as the app paints it from its own render tree — every route, dialog and menu — andscreenshotdoes too, though neither shows the native window frame or a native dialog such as the folder picker. A picture shows what the screen shows, an e-mail address included. An agent build closed by hand or killed keeps the settings an agent gave it; onlyquitputs them back. The web build has no driver; agents use the desktop binary. Dialog text boxes (New project, the label, the JSON editor) take noset_field;enter_textfills the one that has the focus, and nothing tells whether one had it.sizeis the view’s size on both runners, to within a pixel on Windows.
- The issue recorder’s known limits (§6.8). Replay’s gaps — a key, a drag, a scroll, a long press, development mode — stay manual steps printed by
scripts/replay_tape.py, never replayed. A picture’s measure does not see a value a box shows but cannot render (a height typed asabc). Replay resolves against the machine’s own shared library, not a copy of the one recorded. The web build keeps its tape in memory and writes nothing. The Linux agent walk was not run for the issue recorder in phase 1. - Identifiers name the doors, not every control. Compare’s cells (built outside the form’s field layout), the Settings show folder buttons and the sign-in dialog’s secondary buttons carry none yet; tabs never will (§13).
- Development mode exists (§9.5) and is on by default outside a release build: another author’s version, official without being a director and the shared library warn instead of refusing. What stays enforced near production is the owner’s to decide; the rules that protect files are not governed and refuse in every build.
Takes on a cloud media root are not discovered by listing. The loader finds takes by listing a media root that is a ProjectSource (§5.9); a root on a service is a MediaStore and not a source, so takes there are reached through an entity’s refs and through the batch records, which are in the project folder. The reference section, the epoch overview’s preview and the Takes tab’s rows for batch-recorded uploads work; a take a colleague put there without a batch record does not appear (milestone 5, D23). Listing a cloud root through MediaStore.list is carried forward.
15. Third-party code
Section titled “15. Third-party code”packages/filmopen_ui/lib/src/split_view.dart is adapted from API Dash (lib/widgets/splitview_dashboard.dart, Apache License 2.0, revision 8044b218…). The licence is vendored at third_party/apidash/LICENSE, registered with Flutter’s LicenseRegistry in main.dart, and listed in THIRD_PARTY_NOTICES.md. The rail-beside-sidebar arrangement follows API Dash’s dashboard; the theme, model, tree, views and state are FilmOpen code. FilmOpen’s own licence is not yet decided.
16. Future points to address
Section titled “16. Future points to address”Recorded on 15 September 2026, so that the milestones that build keys, calls, the usage log and the portal meet each point on purpose. Each point says what could go wrong, and where it is handled or still open.
The plug-ins’ app: strings (§6.11, recorded on 17 September 2026). A plug-in borrows an app string by its ARB key through filmopen_l10n’s generated appStringsByKey — every string of the app a second time, kept in step by scripts/gen_app_strings.py and a test that fails when the map is stale. The owner’s decision (the milestone 5.1 note’s D38): the next milestone that touches localisation bundles the two ARB files as assets and reads them at run time through the plug-ins package’s own ARB reader, one read per language on the first app: lookup, removing the map, the generator and the test. Until then the generator runs after every ARB edit. No stock plug-in uses an app: string.
The usage log (§6.7).
- Live sessions. The catalogue prices a live transcription per minute of audio.
- Where the cost comes from. OpenAI’s SDK gives
conversation.item.input_audio_transcription.completedausagefield, in tokens or in seconds of audio. Whethergpt-live-transcribefills it, and in which unit, is checked at milestone 4.9’s hand-off C. Until then, the cost is the catalogue’s price times the minutes sent. - A crash loses what followed the last
runningrecord, and the chain stays open, showing that last cost. - Through filmopen.ai. How a brokered session is settled is open (Portal Specification Q11).
- Where the cost comes from. OpenAI’s SDK gives
- Double counting.
- On one machine. Two copies of the app can share the usage folder, and both ask a platform about the same queued job. Each may write the job’s final record, but only the chain’s latest record counts, so the job is counted once. The step that builds the log proves this with two writers.
- Across devices. When records reach filmopen.ai, a record keeps its
id, so a use is counted once however often it is sent (Portal Specification P10).
- Open chains. A call that timed out, a cancel the platform did not confirm, and a crash each leave a chain open. The Usage tab counts it by its counted record, as Totals says: a
startedorsubmittedchain’s estimate as pending or possibly charged, and arunningchain’s cost so far as spent.- A platform may still finish, and charge for, a call the app gave up on. OpenRouter stops generating on a cancelled stream only for providers that support cancelling; its streaming page names providers that do not, Google among them. fal may complete a job after a cancel is requested.
- A
startedchain whose platform never returned an id cannot be asked about later.
- A key replaced while jobs are open. Once the start-up check of §6.7 is built, the app asks only about chains whose key matches the one now stored, so a chain submitted with a key since replaced stays open until the user checks the platform.
- Corrections carry the status of the chain’s counted record. Whichever record is latest counts, so a correction carrying an earlier status would reopen a finished call.
- A record that cannot be written after a call has started. A
submittedorrunningrecord waiting for the lock is retried in memory. A crash before it is written loses the platform’s request id, so the job cannot be asked about. - A file cut short by a crash could join the next record to its broken line. The writer first ends such a file with a line feed.
- A lost first record. A chain whose first record is missing, from a line cut short or a file removed by hand, still counts once through the
refits records share, but in the month of its earliest remaining record. - Version skew. A build that shares the folder with a newer one skips every chain holding a record with a newer
v. It may leave out calls the newer build recorded, but it never counts part of one. Builds that share a folder should share a record version. - Clocks. A writer keeps a chain’s times in order, a millisecond past the chain’s latest record it has read when its clock has stopped or gone back. Two copies of the app writing to one chain at the same moment can still tie; then the greater
idcounts, and both records carry what the platform said. - Estimates. A call whose request does not bound its cost is estimated at the smallest amount it can cost, so its pending amount understates.
- The price row. A
price-basis cost cannot be recomputed later without the catalogue’s price row that applied, and a record does not yet name it. - Money against the website.
- The website takes provider costs to ten fractional digits and has not chosen a live currency.
- Device records use twelve digits and the platform’s currency, and they never settle (Portal Specification P11).
- A later format for brokered jobs must follow the website’s rule.
- The lock on Windows is mandatory, so it is taken only on
.lock, which no reader opens. A writer that cannot take it within ten seconds does not start a paid call. - A folder copied to another machine keeps its
device.json, so two machines share a device id until one of them deletes it. The file is made again only when it is read whole and holds no valid id, so a scanner that briefly holds it does not change the id. - Names in
purpose. File stems can hold people’s names. What a record may carry off the device is open (Portal Specification Q8).
Keys (§6.4, §6.7).
- One file holds every secret on Windows.
flutter_secure_storagekeeps the account’s session and every provider key in one DPAPI file, rewrites the whole file on each write, and deletes it when it cannot decrypt it.- Within one app, the plugin runs its calls one at a time, so a key save and the account’s refresh do not collide.
- Two copies of the app writing at once can still lose one write. A failed decryption loses every key and the session together.
- A key pasted into a chat, a shared file or a message is exposed, and should be replaced.
- A key’s expiry. A platform key can expire, as OpenAI’s keys can. Nothing re-checks a key once it is stored (step 2c), so an expired one keeps its Verified on state and fails where it is used; the user makes a new one and saves it over the old.
- The tab’s states can outlive the keys. A store emptied by a failed decryption, or changed by another program, leaves the preferences’ states claiming keys the store no longer holds, and nothing reads the store behind the card, so the card says Verified on until the key is saved again (step 2c; the owner’s question 12).
- Keys can outlive their states, too. A preferences file put back from before a key was saved, as
CLAUDE.md§11’s agent runs put theirs back, leaves a card saying No key while the store holds a key. The store is the source of truth — the step that builds dictation reads the store, not the card — but since step 2c nothing reads it behind the card, so the card says No key until the key is saved again. - A write the operating system never finishes. On Windows the plugin retries a failed file write without end, under its own lock. Each call has ten seconds before it fails closed and the queue moves on, but the plugin’s lock can still hold the calls behind it, the account session’s among them, until the app restarts.
- Keys handed to devices by the portal, later. Signing deters copying but cannot prevent it, so a handed-out credential needs a spending limit where the platform offers one (Portal Specification P17).
Platforms.
- ElevenLabs through fal, for now. fal’s voice design returns a voice id when it saves the voice, and fal’s dialogue endpoint takes a voice’s name or id. Open (Portal Specification Q4):
- whether fal’s single-voice speech endpoint takes a designed voice’s id;
- how long a designed voice lasts;
- what a design costs.
- OpenAI’s company account could not add a card on 15 September 2026, so development uses the owner’s personal account.
- Pending platforms keep their files and notes, with their obstacles in the index, for later work.
- Terms. Whether a portal that runs jobs, or hands out keys, for other people fits each platform’s terms is open for OpenRouter, fal, OpenAI and ElevenLabs (Portal Specification Q3).
- Results served only with the account key, such as OpenAI’s and Veo’s videos, would put large files through the portal (Portal Specification Q10).
Dictation (§9.6).
- Turns decided on the device. The gate’s defaults — three times the room, 0.8 s of quiet, 0.2 s of speech, a room of at least 50 that rises at most 3 % a frame and is held under a voice, and a turn cleared as noise where a second or more of it is still as even as a tone when it ends — are untried on real microphones. A voice quieter than three times the room is heard as no speech, and its audio is cleared with the quiet. A noise that is not steady, such as a television, is transcribed, and so is an even sound that stops, such as a beep. A voice whose frames vary by less than twice is taken for a steady noise: the room climbs to it, and a turn of it still sounding at Stop or a release is cleared. A steady noise’s first seconds are held, then cleared, before the room has learned it.
dictation.endedcarries the numbers that show these (§9.6), never the audio. Should OpenAI take voice activity detection for this model again, its catalogue entry says so first. - An answer that never comes. A commit the platform neither takes nor refuses holds a release or the apply button for its three seconds; the text then applied is what arrived.
The portal (Portal Specification §8).
- Where usage records live without a relational database, and the change that asks of the website’s contract (Q1).
- How provider keys are encrypted (Q2).
- FilmOpen’s fee on a director’s own keys (Q7), and in the portal’s Usage tab (Q12).
- Whether money stays in PostgreSQL (Q9).
- Estimates against limits (Q6), and how records reach the portal (Q5).
Appendix A — File map
Section titled “Appendix A — File map”filmopen/ (the repository root) README.md how to run, layout, languages THIRD_PARTY_NOTICES.md CLAUDE.md the routine every Claude Code session follows; the repository's only CLAUDE.md .claude/agents/ architect (the consultant), coder, critic, explorer (CLAUDE.md §13) .gitattributes every text file LF, in the repository and in every checkout pubspec.yaml, pubspec.lock analysis_options.yaml flutter_lints assets/sample/the-cartographer/ the sample project (manifest + 38 JSON files, and one text file) assets/sample/the-cartographer-data/ its media root (9 placeholder takes) assets/sample/library/ the seed of the shared library (model, platform, plugin) assets/branding/ the logo and its master under source/ assets/providers/ the sign-in brand marks assets/models/ the AI model catalogue (Project Specification §14.6–§14.9): 42 entries and models.json, which marks every platform outside the first version pending; models.json, platforms/ and stt-gpt-live-transcribe.json are bundled, for Provider keys and dictation plugins/ the stock plug-ins' source and the plug-in guide (§6.11): README.md, filmopen-plugin.d.ts, template/ (the plug-in to copy) assets/models/platforms/ one file per platform (Project Specification §14.9): where requests go, how a key is created, sent and checked third_party/apidash/LICENSE third_party/quickjs-ng/LICENSE QuickJS-NG's licence, shown on the licence page docs/ FilmOpen-Project-Specification v1.0.md the format (v1.11 draft inside) FilmOpen-Software-Specification v1.0.md this document FilmOpen-Milestone 1.md milestone 1: the reader FilmOpen-Milestone 2.md milestone 2: manifest, media root, library, log, projects FilmOpen-Milestone 3.md milestone 3: versions, picks, official, commentary FilmOpen-Milestone 3.5 Plan/Results.md milestone 3.5: the pick model in one word, and dated FilmOpen-Milestone 4 Plan/Results.md milestone 4: one presentation, copy-left, the two layouts milestone-4-refactor.md milestone 4.5's plan FilmOpen-Milestone 4.5 Results.md milestone 4.5: the pub workspace and its package boundaries FilmOpen-Milestone 4.6-ui-cleanup.md milestone 4.6: the owner's list for the UI cleanup FilmOpen-Milestone 4.6 Results.md milestone 4.6: the UI cleanup FilmOpen-Milestone 4.8 Plan/Results.md milestone 4.8: the platforms and their keys platforms/ public pages, ready for the website: index.md, and per platform <id>.md, <id>-notes.md and <id>/ for its screenshots FilmOpen-Managed-Usage-Notes.md private, never published: what each platform allows for managed usage FilmOpen-Portal-Specification v1.0.md filmopen.ai as the broker of generation: usage, keys, budgets, teams (0.1 draft, nothing built; §6.7) FilmOpen-Milestone 4.9 Plan/Results.md milestone 4.9: keys for the first version, Provider keys, dictation and the usage log FilmOpen-Issue Recorder.md the issue recorder's plan: the tape, the recorder's doors, the report folder, replay and comparison; phase 1 is the infrastructure FilmOpen-Milestone 5 Plan/Results.md milestone 5: media (closed 16 September 2026; Next steps.md holds its proposals): the media folder, one class for a piece of media, thumbnails, the reference image, the player, Google Drive FilmOpen-Milestone 5.01 Plan.md milestone 5.01: Android (16 September 2026): the app on the emulator and the Galaxy, projects in the app's own folder, Drive with an Android client, the agent layer over adb FilmOpen-Milestone 5.1 Plan/Results.md milestone 5.1: the plug-in architecture (planned 16 September 2026, from the owner's brief Plugin Architecture Security And UI.md; built from 17 September) automated-bug-tracking-plan.md the issue recorder's phase 2 (15 September 2026): sending with consent, the private store, the public tracker, the pipeline of agents from a recording to a fix (the website's own plans moved to filmopen-ai/filmopen-web on 17 September 2026: the website plan, milestone 5.02 and the setup record) FilmOpen-Dev-Keys.md the keys a build needs and where they live: one folder outside the checkout, the wrappers that pass it, the pre-commit check (§3) follow-up-milestone-5.md every open item after the merges of 4.7, 4.8, the recorder, 4.9 and 5 (16 September 2026): decisions, hand-offs, bugs and gaps, routine proposals, the proposals index FilmOpen-Milestone 6 Plan.md milestone 6: the script editor and §13.3's block rendering FilmOpen-MCP Specification v1.0.md the agent layer as an agent uses it (§4.3) mcp_plan.md, FilmOpen-MCP-Plan.md the agent layer's first outline, and the plan of step 2 FilmOpen-MCP-Plan-Step3.md MCP step 3: the headless Linux runner FilmOpen-MCP Results.md the agent layer's record Linux-VPS-flutter-dev-setup.md the owner's notes on setting up the Linux box Website-Request-*.md what the app asks of the website's repositories (§10 of CLAUDE.md) speech.md dictation, studied before milestone 4.9 (§9.6) FilmOpen-Development-Plan-*.md earlier planning notes AI Filmmaking Scripting Workflows.docx the owner's survey of the field lib/ the application shell main.dart log start-up, licence registration, preferences, the credential store and the key verifier, the microphone, the transcriber and the usage log, the ProviderContainer, the host's attachment app.dart MaterialApp: themes, locale, delegates, the root RepaintBoundary consts.dart the version and the specification URL screens/ dashboard, settings_page, support_dialog packages/ the workspace (§4.1); each package: pubspec.yaml, README.md, lib/<name>.dart (the barrel), lib/src/, and test/ where it has tests filmopen_format/ author, entity_type, stems, entity, project, diagnostics, manifest, short_name, project_kind, epochs, character_age, vocabulary, field_paths, account_problem, flat_json, copy_left, natural_compare, project_source (+memory), json_document, platform_file, catalogue (Catalogue, the one reader of the bundled catalogue), catalogue_entry, json_exact, money, usage_record, usage_totals (§6.7), media_names (MediaKind, uploadTakeFor, nextBatchId, thumbnailPathFor), media_refs (withReferenceAdded, groupFor, referencesOf), media_paths (splitPath, mediaFolderResolvedFor, mediaLocatorPathFor, isPortableMediaFolder) filmopen_store/ directory_source(_io/_stub), project_loader, project_template, project_writer, project_creator(_io/_stub), guards, app_log, log_file(_io/_stub), library_install(_io/_stub), usage_log(_io/_stub) (§6.7) filmopen_test_support/ fixtures, sample_on_disk filmopen_l10n/ l10n/{app_en.arb, app_es.arb, app_localizations*.dart}, src/{labels, field_labels}, l10n.yaml filmopen_drive/ drive_config (DriveClientConfig, kDriveScope), drive_tokens (DriveTokens, DriveTokenStore, SecureDriveTokenStore, refreshThroughGoogle), drive_auth (DriveAuth), drive_sign_in (+_io/_stub), drive_client (DriveClient, DriveError), drive_consent (driveConsentUri, codeChallengeFor, newCodeVerifier), drive_folders (folderNamed), drive_media_store (DriveMediaStore, DriveFolderMemory), drive_cache (+_io/_stub), drive_source_ui (DriveSourceUi) filmopen_media_ui/ player/media_player (+_io/_stub), player/player_controls (PlayerNow, PlayerControls), player/player_rules (aspectRatioOrSquare, canSeek, hasRunOut, clockText), media_source_ui (MediaSourceUi, MediaSourceUis, MediaRootChoice, MediaRootController, UploadDoor), media_root_picker, media_tile (MediaTile, MediaStrip), media_upload_sheet, media_viewer, folder_picker (+_io/_stub), file_picker (+_io/_stub) filmopen_media/ media_store (MediaStore, MediaSources, Playable, RootPlan), local_media_store, project_media (MediaRef, ProjectMedia, Entity.media), media_importer (MediaInput, MediaImporter, MediaImportProblem), thumbnailer, stock_thumbnails, media_cache, local_file (+_io/_stub), off_thread (+_io/_stub) filmopen_ui/ theme (buildTheme, AppColors, Breakpoints), section_card, info_chip, empty_state, messages, entity_icons, attribute_view, screenplay_view, split_view, short_name_field, filmopen_logo, genre_picker, media_image, file_image (+_io/_stub) filmopen_platform/ asset_source (AssetProjectSource, readBundledText), asset_disk(_io/_stub), app_paths(_io/_stub), reveal(_io/_stub), audio_source, credential_store, device_credential_store(_io/_stub), key_verifier, device_audio_source(_io/_stub), incoming_links (the door: unavailable, memory), device_incoming_links(_io/_stub: app_links) filmopen_js/ hook/build.dart (compiles the engine), src/ (QuickJS-NG v0.16.2's amalgamation and licence, filmopen_js.c and .h, the shim), lib/src/bindings.dart (@Native), lib/src/js_call.dart (runJs, JsCall, JsLimits, JsOutcome, JsDoor, JsCancel) (§6.11) filmopen_plugin_host/ create_runtime (+_io/_stub), host_runtime (HostPluginRuntime: loading, Settings' changes, install, each call's entry), host_environment, plugin_registry (the registry, trust, preferences), plugin_views (PluginViews: the seam's data as a page draws it), plugin_data_files (PluginDataFiles), plugin_call_runner (PluginCallRunner, CallScope: one call in the engine, the slots), ctx_doors (CtxDoors: the doors), http_door (PluginHttpDoor), plugin_outputs (PluginOutputs: a transform's versions, a render's takes), usage_chain, preferences (§6.11) filmopen_plugins_ui/ plugins_tab (PluginsTab), plugin_card (PluginCard, PluginEnabledSwitch), consent_dialog (askConsent), preferred_providers (PreferredProviders), plugin_page (PluginPage) (§9.4) filmopen_plugins/ plugin_manifest (PluginManifest, KeySlot), setting_spec (SettingSpec, ActionSpec), key_id, plugin_api (the hooks, the api versions), plugin_strings (PluginLocaleFile, PluginStrings), plugin_package (PluginPackage, SourcePluginPackage, PluginFolder), plugin_archive, plugin_issue (§6.11) filmopen_state/ settings_provider, settings_tab_provider, project_provider (+library), browser_provider, launch_arguments_provider, incoming_links_provider (the platform's link door, as the widgets reach it), localization_provider, catalogue_provider (catalogueProvider), app_paths_provider, consts, selection, tree_node (TreeNode, TreeIcon, TreeModel), tree_builder, page_access, drafts (OpenDrafts, DraftObserver), exit_tasks (ExitTasks), field_registry, automation_host, route_events, app_version_provider, semantics_walk (find, tap, the tree as data, the frames, the tabs, the control at a point) filmopen_recorder/ the issue recorder (§6.8): recorder, tape, report_folder(_io/_stub), snapshot, bin/compare_shots.dart; README.md; 8 tests filmopen_dictation/ dictation (§9.6): transcriber(_io/_stub), openai_transcriber, device_transcriber (createOpenAiTranscriber, io and stub), dictation_controller (one session, its usage chain and timers), dictation_setup (the entry, endpoint, price and keychain from the bundled catalogue), dictation_target (a box a microphone can fill, and applyDictation), dictate_button (the microphone, its menu, and dictateShortName), dictation_popup, speech_gate (where a turn of speech ends, decided on the device) filmopen_account/ account_auth, account_profile, account_registration(_io/_stub), account_card, account_card_model, auth_callbacks (which callbacks this process has acted on), provider_mark filmopen_keys/ provider_catalogue, provider_keys, provider_keys_tab, http_key_verifier (Settings → Provider keys, §6.7), usage_tab (Settings → Usage, §6.7) filmopen_tree/ tree_view, tree_pane, new_project_dialog, copy_to_folder filmopen_forms/ entity_form, entity_data_spec, media_section, project_edit_tab, character_edit_tab, location_edit_tab, plugin_sections, plugin_field_widgets (PluginSwitchField, PluginChoiceField, PluginTextBox, what an agent's typing parses to), form_fields, generic_view_tab, epochs_editor, draft_saver, json_editor_dialog (with the JSON tree), entity_file_menu, write_actions, guard_line filmopen_compare/ compare_tab, compare_form, compare_copy, compare_fork_dialog filmopen_browser/ browser_page, detail_pane, entity_detail, entity_tabs, version_row, epoch_overview, category_view, new_entity_dialog, project_overview, project_chooser, batch_detail, other_files_view, script_tab, takes_tab, commentary_tab (browser_page holds the two layouts of §9.1; filmopen_ui/theme.dart holds `Breakpoints`) filmopen_automation/ the agent layer's in-app half (§4.3): driver_extension (the install), protocol (the data channel's ops, codes and selection codec), driver_host (DriverAutomationHost, RingLogSink); README.md; 22 tests filmopen_mcp/ the MCP server (§4.3): bin/filmopen_mcp.dart (serve, and one-shot commands), src/server (the tools), src/app_link (the connection, the driver, the probe, the text entry), src/discovery (agent.json), src/launcher, src/cli; README.md; 59 tests test_driver/ agent_main.dart the agent entrypoint: the real host, provider keys in memory with the stub verifier, dictation's fakes (a microphone talking in phrases), the driver extension, the app, then agent.json (§4.3) scripts/ register-login.ps1 manual registration of the ai.filmopen.desktop:// scheme (§6.6) make_icons.py re-derives every icon from assets/branding/source (needs Pillow) test_all.sh, test_all.ps1 every package's tests and the app's dev-keys.cmd, .ps1, .sh this machine's keys folder (docs/FilmOpen-Dev-Keys.md): the folder, the --dart-define-from-file flag, or --check, which prints names and lengths and never a value run-windows.cmd, .ps1, .sh flutter run -d windows with the keys, where the machine has any build-windows.cmd, .ps1, .sh flutter build windows with the keys, where the machine has any run-android.cmd, .sh flutter run on the emulator, or on a device by its serial, with the keys (milestone 5.01) build-android.cmd, .sh flutter build apk --debug with the keys; adb install -r puts it on a device check-no-keys.sh refuses a staged change that carries the shape of a key; --all sweeps every tracked file install-hooks.sh installs check-no-keys.sh as the clone's pre-commit hook, once per clone agent-vps.sh the Linux box from a fresh Ubuntu: the apt packages, the SDK, the repository, every suite agent-run.sh the headless Linux agent build under Xvfb, by hand agent_walk.py the acceptance walk of the MCP Specification §7, through the server, on either platform replay_tape.py replays an issue recorder's report folder through the MCP control and compares it with what the person saw (§6.8) gen_app_strings.py filmopen_l10n's appStringsByKey from the ARB files, for a plug-in's app: strings (§6.11) check_catalogue.py the model catalogue's rules (Project Specification §14.6–§14.9) and the platform pages' format, links and images mcp_test_results/ ignored by git: the walk's and the plans' results, in <branch>/<walk or plans>-<platform>-<device or size>/ by default; the pictures committed before that rule (README.md names step 3's) stay tracked, since the notes cite them test_plans/ the numbered smoke tests every close runs (010 the project, 020 characters and locations, 030 the issue recorder, 040 media, 050 keys, dictation and usage), by hand and through run_mcp.py, which drives them over the MCP control; README.md test/ the tests that pump the whole app: widget, responsive, editing, compare, compare_copy, generic_view, development_mode, project_menu, account_card, identifiers, agent_host, ui_cleanup, recorder, settings_tabs; the packages' tests are in their own test/ windows/, web/, linux/ platform runners (window title "FilmOpen"; linux is the agent runner, FILMOPEN_WINDOW for the initial size) android/ the Android runner (milestone 5.01): app/build.gradle.kts (the application id, the dart-defines read for the Drive redirect scheme), app/src/main/AndroidManifest.xml (INTERNET, the two intent filters, the https query), the launcher icons make_icons.py derives (mipmap-*, the adaptive icon's XML and colour)Size at the end of milestone 1: about 5,500 lines of Dart outside generated code, 600 lines of tests, 549 localisation messages per language. At the end of milestone 2: about 7,300 lines, 1,100 lines of tests, 608 messages per language. At the end of milestone 3: about 8,900 lines, 1,650 lines of tests, 694 messages per language. At the end of milestone 3.5: about 12,500 lines, 3,600 lines of tests, 805 messages per language, 191 tests. At the end of milestone 4: about 15,200 lines, 6,000 lines of tests, 825 messages per language, 279 tests. At the end of milestone 4.5: about 16,879 lines outside generated code, 7,855 lines of tests, 825 messages per language, 384 tests across the packages and the app. At the end of milestone 5 (media): 805 tests in fourteen suites, and about 930 localisation messages per language.
Appendix B — Glossary of code names
Section titled “Appendix B — Glossary of code names”| Name | Meaning |
|---|---|
| stem | a filename without extension, e.g. sc_5_john123_v1 |
| versions key | the stem minus author and version, e.g. ch_main-hero_30yo; what an official pointer names |
| group key | type and tag only, e.g. ch_main-hero; every epoch and version of one entity |
EntityGroup |
one entity: all epochs |
EntityVersions |
one entity at one epoch: all authors’ versions |
| viewer | the author handle the user browses as; empty means none |
| resolution | the outcome of looking up a reference: an entity, or a ResolutionProblem |
| selection | what the detail pane shows; the tree indexes rows by selection keys |
| wrapper row | the Shots / Cues grouping under a story unit |
| companion file | a file the grammar names that is not JSON (plugin code) |
| other file | a file the grammar does not name |
| manifest | filmopen-project.json: the fixed-name file that identifies a project folder and names its media root |
| media root | the folder takes and referenced media are read from: the manifest’s data folder, else the project folder |
| locator | the manifest’s typed “where” ({type, path}) for the media root |
| library, shared library | the models, platforms and plugins installed beside projects, indexed with every project |
| origin | whether an entity file came from the project folder or the library |
| short name | a segment (§5.2) as a person types it: a folder name, a tag, an epoch |
| pick | the version of an entity in use for the viewer at one epoch (§6.1 step 2): their pick when it names one, else official, else their own highest, else the sole author’s; an explicit pick wears a green check, official in use wears the star alone |
| tracking value | pick = <versions key>_official: the pick follows the official pointer |
| version label | the header’s label: a short word shown beside the version number in lists |
| session copy | a project held in memory for one run of the web preview; nothing reaches a disk |
| default epoch | the first epoch the project declares; where implicit references fall back to |
| reveal | showing a file in the platform’s file browser with the file selected |