Services: files, the writer and the log
Where a project’s files come from, how they are written, and the application’s own files.
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 the whole project folder, including data/, media/ and thumbnails/; the shared library (§14.5) is a separate source:
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 diskbool 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 on two queues chosen by path — media/ and thumbnails/ on one, every other file (the documents, the manifest) on the other — so two operations on one target always share a queue and keep their order, while a clip’s copy never holds a save; each write, delete and byte write logs folder.write.start (its kind, path, queue, the text’s length or the declared bytes, the expectedText’s length where one was given, create), then folder.write.step as it enters each step — queued (its wait for the queue over), the expectedText comparison with the expected text, the text on disk and the text a save would write (none for a delete) and its result (matches, differs or missing), mkdir, write (with the temporary file), rename (with a byte write’s bytes written), delete, absent — so an operation that never finishes shows where it stopped, then folder.write.done with waitMs (the time it waited for its queue), ms, its last step and, for bytes, written, or folder.write.refused with the check that refused it (create or expectedText) and its reason (exists, existsAfterCopy, differs, missing), or folder.writeFailed at warning with the step, the exception’s type and message and the system’s code and message; nothing per chunk. 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 the media folder 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. writeBytes takes no expectedText — bytes are compared by name and hash (§18.3, §12.2) and never read back to be diffed — and create: false, its default, replaces the file, which only a derivative a reader can make again does (a thumbnail). Its length is what the caller knows of the size, for progress and for a store that must declare it, and is not checked: a stream that holds another number of bytes is the store’s own affair. A source that answers false to canWrite throws UnsupportedError from every write. Every document is read through filmopen_format/json_document.dart: decodeDocument decodes a document once and refuses a root that is not an object (NotJsonObjectException, which the loader reports as notJsonObject) and 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; other malformed JSON is invalidJson. copyDocument makes an independent JSON copy with a mutable, string-keyed root; clearing and refilling that root retains typed iteration on the web as well as on the VM, so attaching media does not invalidate the form’s draft.
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,node_modules. The scan runs synchronously on a background isolate and answers in one message: every asynchronousdart:iocall costs the app an engine task, at least a millisecond on the Linux desktop, so a folder’s walk done asynchronously costs seconds. Every whole file is read asynchronously, so that a load reading hundreds of files leaves the window’s event loop its turns; the loader asks for many at once (ProjectLoader.readsAtOnce), so that their engine tasks overlap instead of adding up. The walk and the reads are logged on the calling isolate — the walk at debug,folder.list.start,folder.listed(files,ms) orfolder.listFailed(the error’s type and message,ms, at warning); each read at trace,folder.read.start,folder.read(itskind, text or bytes, andms) orfolder.readFailed(kind, the error’s type and message,ms; at debug for a missing file alone, which is often the reader’s own question, and at warning for anything else — a path that would leave the folder, a file that does not decode, a permission refused) — with the folder and the path as they are; a refusal keeps the platform’s own exception. A write, of text or bytes, or a delete the file system refuses isfolder.writeFailed {kind: text | bytes | delete, step: check | mkdir | write | rename | delete, type, error, osError?, osMessage?, target?, temp?, reads, ms}at warning, before the exception goes on: where it failed, the system’s code and message, the absolute target and the temporary file, andreads, this source’s reads of that path in flight when the failure was caught — each itskind,started,ageMsandcaller(the first frames of the stack outside this file, taken as the read started) — sampled before the temporary file’s cleanup is awaited. On Windows a file open for reading refuses a rename over it (errno 5, Access is denied), so a refusal naming a read says a read of this source was in flight on the file — from before its open to its answer, not proof that it held the handle — and an emptyreadssays only that no tracked read of this source was in flight at the sampling point, not that the opener was outside it: a read can overlap the native refusal and have its completion delivered before the rename’s error reaches Dart. Reads through another source, directFilereads and other processes are never tracked. The sample is taken at a write’s first catch — a byte write’s before its sink is closed — and a later one never replaces it. A failure to remove the temporary file after a failed write, or to measure it, isfolder.write.cleanupFailed {kind, path, temp, step: delete | length, type, error, osError?, ms}at warning, logged apart; the write’s own failure is the one its warning names and its caller receives. The reads in flight are kept for that line alone and decide nothing. Every read and write goes throughfile(relativePath), which rejects schemes, absolute paths, backslashes, empty,.and..segments. It checks the lexical path and resolves the nearest existing ancestor of a missing destination before any directories or temporary files are created. That ancestor must resolve inside the resolved source root, or to the root itself when it is a directory containing the missing destination. Links that escape the source and dangling links are refused withProjectPathException; a linked source root and internal directory links remain usable when the destination stays inside that root. 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 whole sample project, or the seed copy of the library — enumerated from the asset manifest.diskPathfinds the file beside the executable (asset_disk_io.dart:<exe dir>/data/flutter_assets/…on Windows and Linux, theApp.frameworkbundle on macOS), so sample files can be shown in the file browser. It is read-only (canWritefalse). Where the app keeps files, the sample is opened on disk:extract(label:, keep:)copies every bundled file, one at a time, into a new folder of its own (newSampleFolder, §6.4) and answers aSampleFolderSourceover it, logged assample.extract.start {asset, kept},sample.extract.file {path, bytes}per file, thensample.extracted {folder, files, bytes, ms}, orsample.extract.failed {folder, path, files, type, error, ms}at warning before the failure goes on. On the web, which has no disk,snapshot(label:)returns a writableOverlayProjectSourceover the whole bundled folder instead. Either way edits, uploaded media and thumbnails last until the sample is opened again, and the next opening starts from the bundle.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. Writable.SampleFolderSource(asset_source.dart): the extracted sample, read and written through its folder’s ownDirectoryProjectSource, with norootPath, so that it is a session copy (FilmProject.isSessionCopy, §5.15) like the web’s: never put among the recent projects, offered Copy to a folder…, refused by sharing, and opened afresh only through the sample’s own entry.foldernames where it was extracted.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. Its writes and deletes run one at a time, each check with the change it allows, so that two writes against one expected text cannot both land: the second is refused as stale. It is how the web’s bundled sample takes an edit or upload: nothing written there outlives the session, and nothing reaches the bundle.
ProjectLoader.load(source, {library}) (filmopen_store/project_loader.dart) reads the project and the optional shared library. It reads the root manifest first (a missing one is noManifest; missing filmopen or tag is headerMissing) and ignores an old manifest’s data field. It indexes the library first with EntityOrigin.library using the library’s existing layout; an unlistable library is libraryUnavailable and the project opens without it. It routes the project’s paths through projectAreaOf: JSON documents directly in data/ enter the index; takes directly in media/ retain their exact file names and naming warnings. If locating a parsed take fails, the loader adds couldNotRead with the source-relative path and the literal file name as its argument, keeps the take with a null media location and its existing naming warnings, and continues loading the remaining files. The source exception text is not exposed in that warning. A literal backslash in a listed name is preserved in the index; it does not become a separator or a different file name. Other direct media files are hand-supplied reference material, and files in thumbnails/ populate FilmProject.thumbnailPaths. Files at the root other than the manifest, nested documents or media, and files in any other folder become Other files. Nothing is moved or repaired. Each entity’s own warnings come with it: its header’s (headerMismatches, fieldTypeWarnings) and its media’s (mediaWarnings, §5.10: a reference named twice, an entry or a group that cannot be read, a key of media that cannot name a group), reported against its file and never refused. The project’s copy of a library stem replaces it with libraryStemShadowed, while a project’s official pointer replaces a library’s silently (§14.5); the library’s unknown files do not become the project’s other files. Finally the loader checks the manifest tag against the pj files (manifestTagMismatch), selects the project and logs project.loaded with counts and timing. The documents are read ProjectLoader.readsAtOnce (64) at a time: one read after another costs a desktop window an engine task per file call, so a film of hundreds of documents takes seconds; the bound keeps a film of thousands within the files a process may hold open, and the index is built in the listing’s order, never the reads’. Both project.loaded and the facts a publication logs carry reads_at_once. A pointer naming a discarded version is officialDiscarded and a pick naming one pickDiscarded (Project Specification §6.1, §13.4), each also logged at info as project.discardedTarget {stem, target: pointer|pick, by, reason, path}, since a warning’s own line is trace; the project file is chosen among the retained versions, a pointer naming a discarded project file counting as none, and a folder whose every project file is discarded is noProjectFile.
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”ProjectWriter.updateManifest(change, viewer:) takes the manifest’s text as the index read it (ProjectManifest.rawText), passes a copy of its JSON to change, and writes with that exact expectedText; a change from outside since is refused as StaleFileException, never absorbed. A missing manifest is NoManifestException, a missing handle is NoViewerException, and a read-only source refuses the write even under warning guards. There is no director rule. Unknown fields survive unless the callback changes them; the controller’s gesture publishes the index after a successful write (§7.5). ProjectManifest.sync parses the optional sharing configuration; a present non-object adds the loader warning fieldShouldBeObject without preventing the film from opening.
writeNewProject(source, {tag, author, name, epochLabel, sceneName}) (pure Dart) writes into any writable ProjectSource: filmopen-project.json at the root from ProjectManifest.template, without a data field; the scene the script starts with, data/sc_1_<author>_v1.json (firstSceneTag), an empty scene — the header, the name and the timestamps, what Add scene… writes — named sceneName, which the caller says in the person’s language as it says the epoch’s label; and, last, so that a folder that holds a project file holds everything it names, data/pj_<tag>_<author>_v1.json with the header, an empty genre, the current year, one epoch — the creator’s first allocated id, <owner>-1 (default-1 for the unnamed user default), labelled in the user’s language, with the current year, §8.2 — and next_epoch 2, past it, the creator in contributors as director (§8.3), the script — "children": ["sc_1"] (the format’s 1.12 §8.5) — and timestamps. No kind is written (the format’s §8.1 makes it optional): it is a word for people, asked by nobody, which the project’s page sets when somebody wants it. No pick is written for the scene, as none is for the project file: a sole author’s version is in use without one. copyProjectTo(source: ...) (_io) copies every listed file from a session source into a new <parent>/<folder>/ through writeBytes — streamed from the file where the source has it on disk (diskPath: the extracted sample), so that a clip larger than memory copies through, else from readBytes whole (the web’s overlay holds it whole) — each logged as project.copy.file {path, from: disk | source, disk, bytes}, preserving the bytes regardless of the extension. This preserves a text file’s UTF-8 byte-order mark and arbitrary binary content stored under a name such as media/notes.txt; text edited in a memory source or overlay is encoded by that source’s readBytes. It creates data/, media/ and thumbnails/ even when the source has no files in them, so a manifest-only session has the complete folder layout after copying. It refuses a destination that is a link or already holds files, and takes back an incomplete copy under the same rules as creation. writeNewProject refuses a tag that is not a segment and an author that is not a handle (ShortName.validateHandle) before it writes anything, whoever calls it: two file names are built from the handle. createProject(...) (_io) accepts no media folder or locator and creates <parent>/<tag>/ with exactly filmopen-project.json, data/ (the project file and first scene), and empty media/ and thumbnails/, refusing a folder that already holds files (ProjectFolderNotEmptyException) and a tag that is not a segment; a project that could not be finished is taken back — three files are written one after another, and a folder left with a manifest and a scene but no project file is one no project menu lists and New project refuses for ever as not empty: a folder this call made is removed, and one the person had made is emptied, never deleted and made again — it may be a link, and that would put a real folder where their link was (_takeBack); what cannot be taken back is left, and the failure rethrown is the one that says what went wrong; 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).
deleteProjectFolder(path) deletes the project folder recursively, including media inside it. It never resolves the manifest’s media locator or deletes a separate media folder. Before deleting, isDeletableProjectFolder(path) requires a real directory with filmopen-project.json at its root. It resolves the path to refuse a filesystem root under any spelling, and checks the deletion endpoint for a link, including spellings with trailing separators or .. A link traversed by .. remains refused. A real project reached through a linked parent is eligible; deleting it leaves that link and the parent’s other files intact. A real project with a trailing separator remains eligible. A folder holding project documents in data/ but no manifest still opens with a warning; it cannot be deleted through this guard. A refusal throws NotAProjectFolderException without changing the folder. The project menu performs the same guard before closing the open project, asks one confirmation (§9.2), and removes the remembered row only after the deletion succeeds.
Bootstrapping a shared project — filmopen_store/project_bootstrap*.dart. bootstrapProjectFolder(parentPath:, tag:, manifestText:) (_io; the web’s stub refuses) makes <parent>/<tag>/ with empty media/ and thumbnails/ and writes only the manifest it is given — no project file and no scene, which would conflict with the copy the sync brings — so that the ordinary sync fills the folder (§6.14). It refuses a tag that is not a segment and a folder that holds files (ProjectFolderNotEmptyException), and a failure takes back what it made by the creator’s rule (takeBackProjectFolder, shared with createProject and copyProjectTo: a folder it made is removed, one the person had is emptied). Before any folder is made the fetched manifest is checked by verifiedManifest(bytes, length:, md5:): its length as the target said and at most 1 MiB, its MD5 where the target gave one (a null MD5 checks the length alone), and UTF-8 text, refused manifestLength, manifestMd5 or manifestNotText (ManifestRefused). It logs project.bootstrap.start, a .step {stage: check | folder | layout | manifest} as each stage holds, and one of .done, .refused {reason: badTag | notEmpty} or .failed {stage, type, error} — a failure while the destination is only looked at (check) or made (folder) takes nothing back; a later one logs .takeBack {outcome: start} then {outcome: removed | emptied | left} — each with ms and the tag and the path as they are; the check logs project.manifest.verify {bytes, length, md5}, then .verified {bytes, md5, text} or .refused {code, bytes, length, md5, actual} (the checksum the bytes have, where an MD5 was given), each with ms.
6.1a Writing — filmopen_store/project_writer.dart
Section titled “6.1a Writing — filmopen_store/project_writer.dart”ProjectWriter(project, record:) is pure Dart and does every write into an open project, through project.source.writeText / deleteFile, each entered in its WriteRecord where it has one — before the source is asked, landed when it answers, dropped on a refusal or failure, a stale refusal’s path kept (§7.5); saveEntity answers the file as written and writes nothing where the file already holds it (entity.save.unchanged). It logs each write (entity.saved — with changed, the top-level keys the save changed and their new values, logged here once, the source logging only the operation’s metadata — entity.forked, official.set, official.cleared, pick.set, pick.abandoned, visit.set, media.deleted, manifest.updated, batch.written, each with its ms, and *.unchanged with its reason where nothing was written) with every stem, path, tag, epoch, key and handle, codes, flags, counts, reasons and times as they are (§13), and a failure by the error’s type and message. saveEntity and the create paths (createEntity, createOutfit, createAtEpoch), setDiscarded, writeSet, setOfficial, clearOfficial, setPick, visit, updateManifest, deleteTake and writeImportBatch log their trail: writer.start {op, type, …values} before their checks; writer.refused {op, stage: check, code, reason, ms} where a check refuses, the exception unchanged — code the exception’s class, reason which check where the class alone does not say: a content rule’s name (nameRequired, versionLabelTooLong, …), the identity field a save changed (tag, …), or the argument and its check (tag.notShortName, epoch.notDeclared, epoch.notInFileName, …), beside the exception’s message; the success line (entity.saved, entity.created) with ms; and writer.failed {op, stage: read | write, code, type, error, ms} where a read or a write fails — code: writeFailed at the write, with the error’s type and message; at createAtEpoch’s reread sourceUnreadable, with the read’s type and message, or sourceChanged, with none. checkSave writes nothing and logs no completed override; a governed guard it trips logs its guard.refused, as the save’s would.
The writer uses the loaded project’s allocation inventory as a snapshot. An allocated id is distinct from every allocated id that inventory has read from the folder: every version of every owner, every pointer, every take, every declared epoch and the floors included. Declaration raises the owner’s floor without lowering it. Allocation proposes an identity rather than reserving it. Two writers of one owner acting at once from bases that do not see each other can receive one id, whether a person and their workspace or two machines on a sync folder. Both writes can succeed into their own files, each protected by expectedText or create: true; the next inventory read has its floor past both, so a third writer cannot reuse either. Files have no owner-wide lock, and a re-read or reservation file cannot close that window. One id declared with different labels in two versions of one owner calls for a loader warning; that diagnostic is not implemented yet.
-
saveEntity(entity, json, viewer:)— replaces the author’s own file withjson, settingupdatedonly where the content changed: where the document differs from the file it replaces outsidemedia,picks,preview,notes,creator_notes,render_stage— a stage chosen is no content —updatedand each prompt’screated(and, inside every block,media,output_duration_secandupdated) (Project Specification §7); and it stamps a block’supdatedwhere its text — its prompts among it, theircreatedaside — differs from the block of the sameidin the file replaced, or the block is new (§10.3). A prompt at apromptPathspath is stampedcreatedwith the time written to its holder’supdated— the block’s for a block’s — where it differs from the file’s in anything butcreated, where it is new, or where itscreatedis absent and the file’s present, which is how a writer asks for a stamp of the same words; the holder’supdatedmoves with it, and the file’s where the prompt is its own. A prompt differing only in itscreatedis no content, and keeps what the document gives.entity.savedsays how many it stamped (promptsStamped) and each stamp (prompts: itspath,kind,blockandreason—new,changedwith themembersthat differ,restampedwherecreatedwas removed with the same words, orcarried). A stamped prompt carries its holder’s other current prompts where the holder’s content apart from its prompts is the same (Project Specification §9), each holder’s decisions undercarryinentity.saved; a fork written with content does not carry. Each prompt’s block is found throughblocksById, the document’s blocks by id built once per save, the first of an id winning as the search’s did. 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’sforked_from, removed, redirected or added (IdentityChangedException('forked_from'), 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), counted over every version they have there, a discarded one included (highestOfAllBy), so a discarded number is never given again, a copy identical apart fromauthor,v,forked_fromand fresh timestamps, and withoutversion_labelor its 1.5 spellinglabel(a new version starts unlabelled, Project Specification 1.8 §13.1), nordiscarded(a fork of a discarded version is not discarded, §7); a forked project file lists the new author incontributorswith no role; written intodata/(refusing an existing file), then recorded as the viewer’s pick (setPick) so the fork is used at once. On a project fork across owners, the original owner’snext_epochis replaced by the forker’s highest floor across their project versions and one past their ids anywhere in the inventory or the copy’s declarations. This applies with or without supplied content, and a supplied floor never travels. A same-owner fork, including a workspace, keeps the owner’s floor, never lowered: the greater of the source’s, a valid whole-number supplied floor and the owner’s highest across their project versions, raised past every own id in the folder or the copy’s declarations — supplied content may raise it but never lowers, omits or spoils it; a copy that carried no floor and finds no own floor or id gets none. A non-project document’snext_epochis an ordinary field. A fork written with content stamps its prompts against its source as a save stamps them against the file, with the fork’s time; an ordinary fork keeps its source’s prompts as they are, a current one current (entity.forked’swithContent,promptsStamped,prompts). Returns the newVersionStem. -
checkSave(entity, json, viewer:)— every refusal ofsaveEntitythat can be known before anything is written — who may write the file, the identity its filename owns, a project file’s provenance, itsnext_epoch, the content rules — thrown as the save would throw it, with nothing written and no override reported; a governed guard’s refusal is logged (guard.refused) as the save’s is. It issaveEntity’s own first half (_checkSave), so the two cannot drift. An action of two writes whose second is a save tries it dry first: a new folder or scene, then its parent’schildren— the dry run made against what the second write will start from (§7) — so that only a file changed meanwhile, a keystroke landing while the first write is made or the disk itself can leave the first write without the second.ContentProblem.childrenNotAListis refused byvalidateContentfor non-null non-listchildrenon writes carrying new project or folder content — a save, a fork with content, a creation or a plug-in run’s outputs — and by the list’s own edits (§5.11). Null is read as absent. A plain fork with neither content nor a draft label copies stored content unchanged, including a badchildren; its next save with content refuses that value. -
The owner’s epoch floor only rises through a save: where a project file holds a whole
next_epoch(Project Specification §8.2: never lowered by its author’s writes — 1 included, the floor a cross-owner fork writes; a value that is not a whole number is no floor), a save whose content lowers it, leaves it out or makes it something other than a whole number is refused (NextEpochLoweredException, said as next_epoch can only go up, naming the number the file holds) — development mode included, nothing written — the JSON editor’s save and a form’s alike; the same floor, a whole double of it or a higher one saves, and a file holding no floor saves without one. The writer’s own operations raise it through the same save. A plug-in’s run writes its versions throughwritePluginRun, which this rule does not reach. On every other typenext_epochis an ordinary field. -
declareEpoch(label, {year, viewer})— appends an epoch allocated under the viewer’s owner to the project version in use,selectedProjectafterforViewer, and returns its token. A missing selected version isNoSelectedProjectException; a missing viewer, a read-only source, a blank label or an unverifiable base refuses before writing. The entry has its label and optional year, existing epoch fields are preserved and order is renumbered from 1. The top-levelnext_epochis raised past the appended id without lowering the base’s floor on a save or the forker’s floor on a fork. When the viewer is that version’s author the writer saves it withexpectedText; otherwise it forks that exact version and picks the fork. The project file of record and an unused own version are never substituted. A successful fork path means its pick completed. If the fork exists but selecting it failed,EpochDeclaredPickFailedExceptioncarries the allocated token and created stem; the caller refreshes and recovers that existing fork instead of blindly repeating declaration. There is no rollback or second pick attempt. Publicforkretains its best-effort pick behavior. Each declaration is logged asepoch.declaredwith the id, the stem written, the label and the year. The caller refreshes after success. -
relabelEpoch(tag, label, {year, viewer})— gives a declared epoch of that same version a new label and year and returns its token; the token, the order, the floor and the entry’s other fields stay, and no year leaves none. The base, the refusals and the save or fork aredeclareEpoch’s; loggedepoch.relabelled {epoch, stem, label, year, was}. Which epoch may be relabelled is the caller’s decision (§7.3,builtin/declare-epoch). -
createAtEpoch(group, targetEpoch, {sourceEpoch, viewer, clear: false, draftLabel})— creates a character or location’s missing epoch from the explicitly chosen source epoch’s exact version in use, resolved bypickAton the canonical indexed group. A missing group, source epoch or viewer resolution isEpochSourceUnavailableException; no other epoch or version is substituted. A stale timestamp pick still resolves and may be copied. The target must be a declared epoch, and any indexed target version, in either origin or by any author, refuses withEntityExistsException. The complete independent copy preserves content, media, voice bindings and unknown fields, has fresh filename-owned identity and timestamps, no version label, andforked_fromnaming the source — except a character’sappearance.age(Project Specification §9.1), filled numerically byageFromBirthDatefrom the copy’s ownbirth_dateor legacy birth year and the target epoch year. If the copy has no birth data, the viewer’s default-epoch pick supplies the second birth source; its appearance shape never replaces the chosen copy’s. Birth year 1999 gives age41at 2040. The copied age stays on refusal, and anappearancecopied as a sentence or a list is never replaced. The decision is logged before anything is written, the trace’s stagefillwhile it decides, asentity.age.startwith the copy’s birth date, target year, prior age and default-source data, thenentity.age.fill {decision: filled|kept, reason: noBirthDate|badBirthDate|noEpochYear|yearAfterEpoch|appearanceShape|noDefaultPick where kept, target, born, birth_date, year, before, after, via: copy|defaultPick, ms}, and repeated asage {decision, reason, from, to}onentity.created. A null sourcerawText, failed read or changed source isStaleFileException— the failed read logged first aswriter.failed {stage: read, code: sourceUnreadable}with its type, the changed source ascode: sourceChanged: the writer rereads the actual source origin immediately before writing the target into projectdata/withcreate: true. The library and source file are untouched. After creation the writer attempts the target pick; a failed pick is logged and leaves a usable version, as withcreateEntity. There is no retry or index mutation, and the caller refreshes after success.With
clear: true, the shared pureseedAtEpochreturns the document and the samecleared,keptandagevalues the planner and writer log. It removesdefault_outfit,description,summary, appearance hair, facial hair, glasses, jewellery, marks and weight;media.reference_images,media.character_sheets,media.motion_clips;picks.referenceandpicks.sheet; andprompts.referenceandprompts.sheet. A preview naming a removed entry goes too. Voice, voice samples, the voice pick and prompt, inspirations and all other data stay. Age is filled from the copied birth date at the target year or kept as copied; this route skips the manual route’s default-pick age fallback. Source selection is the default era’s pick, else the sole era with retained versions; no pick, no retained version or several candidate eras is an honest refusal, never a nearest-year guess. AdraftLabellabels the created version and skips its pick; the manual card keepsclear: false.writer.startcarries both options andentity.createdcarries the source stem and JSON, written document, cleared values, kept paths, age decision and elapsed time; failures retain the source and seed decisions in the existing trail. -
createOutfit(character, epoch, name, {viewer, draftLabel})— withdraftLabel, the assistant’s draft, labelled and not picked, ascreateEntity’s; allocates an outfit id under the viewer’s owner within the complete character tag’s namespace, then createsof_<character>.<id>_<epoch>_<viewer>_v1.jsonin projectdata/with the trimmed name and fresh timestamps. The character group must exist in the project or library, at any epoch; a missing parent isOutfitParentUnavailableException. No parent content is copied and no project version is forked. This dedicated route accepts only its internally constructed dotted outfit tag; genericcreateEntityretains its ordinary short-name check. Every other create guard remains: viewer present, writable project source, valid epoch segment and declaration where the project declares any, absent group across both origins, valid content, and a create-only final write. The live refusal is the file race,StaleFileException, an id taken since the folder was read;EntityExistsExceptionstays as a guard should the inventory ever miss an indexed group, which it cannot on one snapshot. Both are logged asoutfit.createRefusedwith the group and the epoch as they are and a reason code, without the name. Allocation never retries from its snapshot; the caller refreshes before another request and after success. The subsequent pick retains entity creation’s best-effort behavior. -
createEntity(type:, tag:, epoch:, name:, kind:, viewer:)— a new character or location at an epoch, or a new folder or scene (childTypes), whose filename carries no epoch and for which none is given (one given is anArgumentError, as is any other type):fo_<tag>_<author>_v1.jsonorsc_<tag>_<author>_v1.json, a folder with itskindwhen it has one, and the pick as a plain stem, as for every entity without epochs (the format’s §13.4). Nothing lists the new folder or scene: adding it to its parent’schildrenis a second write, the controller’s (addChild, §7), so one that fails leaves the file beside the script and never half made. For a library entity: 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 intodata/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: 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, the target’s stem and the error’s type and message) and leaves a usable version. Logged asentity.created. -
setDiscarded(entity, discarded:, viewer:)— Project Specification §7, §6.1: sets the version aside withdiscarded, an RFC 3339 UTC instant, or brings it back by removing the key; nothing else of the file changes, and a version already as asked writes nothing (answers false). Only the viewer’s own file in the project, written against its text as read: no viewer isNoViewerException, the library’s fileProjectReadOnlyException, another author’sNotYourFileException, a changed fileStaleFileException, and discarding a version official names — by an indexed pointer, or one the app is writing or has written and not yet published —DiscardOfficialException(official-first: the director moves or clears the star first). The project file of record (FilmProject.projectOfRecord, which the folder opens with) that no pointer names — the sole author’s highest — is discarded where the same author handle has another retained version of the same project tag, which the folder then opens with (loggedversion.discard.record {stem, reason: falls-to-next, pointer: none, before, after, ms}); where none is left it is refusedofficial-firstthe same way (version.discard.refusedwithafter: null), so the folder never opens with no project file. Loggedversion.discard.written {stem, path, on, discarded?, ms}orversion.discard.unchanged; a refusal’swriter.refusedcarries its reason (official-first,not-yours,read-only). The pick that named the version is the controller’s (§7). -
setOfficial(entity, viewer:)/clearOfficial(entity, viewer:)— writes<versions key>_official.jsonnaming the entity withset_byandset_at(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 itsforked_fromchain (§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:pickandpicked_atin 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. -
visit(entity, viewer:, now:)— the viewer’s last look at the entity’s media (Project Specification §13.4), asked by the media card when the person opens one of its media, never on a page’s opening:visitedin the viewer’s owncm_file, assetPickwritespick— the same file andexpectedText, made where there is none, the pick and the entries kept — per epoch inpicked_at’s shapes, a plain value giving way to the object at an epoch; nothing is written, and false answered, where the instant is already recorded. Refuses a read-only source and a missing handle; a session copy keeps it where it keeps every other write — the extracted sample in its folder, the web’s in memory. -
deleteTake(reference, viewer:)— deletes the file of the viewer’s own take — its renderer the viewer, or a workspace of theirs such as a plug-in’s<viewer>.<tag>(isViewersTake, §5.6) — and its thumbnail, once no document names it in an object ofmediathat is not deleted, whatever its key (documentsNamingTake, §9.0): another author’s take isNotYourTakeException, aNotYourFileException; a take a document still names isTakeStillNamedException, with the documents; a reference that is no take deletes nothing and answers false. A render’s inputs and a batch’s inputs keep nothing; the batch record stays. The check and the delete are two steps under the source’s serial writes, not one: a reference arriving between them dangles, drawn as a missing file.media.deletedis logged.
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) — not setDiscarded’s, which refuses another author’s file and the library’s in either mode, discarding being the author’s own act. Guards.enforce, the default, refuses as above, logging guard.refused first (a warning, with the rule’s code, the viewer, the stem and path, or the stem and versions key, the file’s author or the library where the rule names one, and reason: enforced); 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 and path or versions key, the viewer and reason: developmentMode) — 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 forked_from 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, which stays in use as before and is no longer stale. 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); a take rendered under the viewer’s own workspace, as a plug-in’s is, is the viewer’s (isViewersTake, §9.0).
ProjectWriter.writePluginRun(run:, sources:, outputs:, viewer:) (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, a discarded version included (version.allocated {…, from: folder|this-run}), with by the plug-in’s stem, forked_from its source, fresh timestamps and no version label, written into data/ 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.writeSet(record, viewer:) writes a pending set’s record — a comp cp, an assistant session as or a render queue rd with kind: "queue" (Project Specification §12.7) — one door for the three: created where the record has no text, else written over its file against the text it was read with (StaleFileException); only the viewer’s own (NotYourFileException). The document is in the format’s key order — the header the filename owns, kind, created, status, items, next_item, the type’s own members — then every other member as the record holds it; it answers the record as written, its text the new rawText. Logged set.written {type, stem, path, created, status, items, next_item, ms}, the items as written. The caller refreshes.
ProjectWriter.writeImportBatch(stem:, items:, viewer:, run:, status:) writes the record of one import action (§12.3) — or, with run a PluginRun, of a plug-in’s render: a kind: "render" record, authored by the run’s derived handle <viewer>.<tag> and by nothing else, the one author other than the viewer a batch may have. A render’s run carries what was asked (PluginRun.item), from which the writer builds the record’s request (§12.2) — the place the render was sent among its members where the run names one (destination, absent for this computer) —, refusing the write where a required member is missing or null, the lacking members named; it lifts stack, seed_applied, and the plug-in’s shared container metadata, with an older run’s metadata_refused paths where it carries them, to the record’s top, and the items carry nothing of it. A render’s items come from the insertion’s replay as it builds them: each output’s n — its place in the plug-in’s list, so a listed preview or an output whose take was not written leaves a gap and nothing is renumbered — with what the plug-in said of it, reported and its own metadata, taken from the output’s provenance in memory and never through the job’s journal; and, for an output the render did not fetch (the carrier’s delivered), an item with its n, fetched: false and error: { code } and no output, reported or metadata, the items in n order. delivered itself is not written. When the record is checked again after a restart, only its fetched items are compared. An import’s record is rd_<id>_<author>.json beside the project’s files, kind: "import", one item per file with its n, source (the entity stem), deliverable, output, original_name, sha256, bytes, mime_type 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. Traced as the writer’s importBatch (writer.start, then writer.refused or writer.failed with the exception’s message and ms): writer.start with the stem, kind, status, items, viewer and what the run carried whole, then batch.written with the stem, path, kind, item count, the request written whole and ms.
A record’s status is done (the default), failed — an ingest problem, with the items that were written — or cancelled — a stop, with the items that were written; anything else, and a tier or a partial, is refused. The same author, guards and create-only checks apply. It does not add an in-place update path.
6.1b The folder watch — filmopen_platform/folder_watch*.dart, filmopen_state/project_provider.dart
Section titled “6.1b The folder watch — filmopen_platform/folder_watch*.dart, filmopen_state/project_provider.dart”A document changed in the open film’s folder by something other than FilmOpen — a teammate’s sync client, another tool, a person’s editor — shows without Re-read files. While a folder on disk is open, watchFolder watches two places, each without recursion: the project root, where the manifest is, and data/, where every document lies flat (Project Specification §4.1). FolderWatch collects the root-relative paths of .json events — created, modified, moved (the path left and the path reached) or deleted; a write’s temporary .tmp sibling is not a document — and once the folder has been quiet for 400 ms hands them to the controller at once.
The own-write test, at settle. Each path is read once. It is the app’s own where its text equals a write the record holds for it (§7.5) — in flight, entered before the source was asked, or landed — or the document the view holds; an absent file is the app’s own where neither names the path (a cleared star). What the app knew before the read stays known after it, so that a write of the app’s own that lands and publishes while the file is read does not make the text it replaced an outside change. Anything else is outside, and one or more outside paths read the film again once: refresh(reason: 'watch'), a publication of kind reread. There is no event history.
Its lifetime. An open — the first, another folder, a new or imported project, a session copy put in a folder — starts the watch of the folder it published and stops the one of the film left; Close project and the controller’s end stop it. A watched folder that goes away, or a watch that fails, stops both watches and the timer, and what was pending goes with them. A source without a folder — the sample’s session copy, the web — has none; a platform that cannot watch, and a folder that is not there, answer null.
Logged: project.watch.started (folder, watched); project.watch.settled (folder, paths — each with ours, its reason inFlight | landed | index | absent | differs | new | gone, the text read, and where it was compared every text it was compared with, each with from, the record’s inFlight or landed entry or the index —, read, outside, settleMs, ms); project.refresh with reason: watch where any was outside; project.watch.stopped (folder, reason closed | another | disposed | gone | failed, message, pending); project.watch.unavailable (folder, reason platform | missing). The person’s data is logged whole: the text read and every text it was compared with.
Not watched, by design. media/ — a medium placed there by hand is seen at the next gesture that lists the folder, or the next open. The shared library, which is held once (§7.5) — a library changed outside while the app runs is seen at the next start, or when another library folder is chosen. A document a sync brings is written by the sync without its text in the record, so the watch may judge it outside and read the film once more. A change made outside between a write of the app’s and the settle is seen at the settle, not before. A project folder moved or renamed while it is open is watched where it went; the film is opened again from its new place.
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 — and what a recording forces in memory while it runs (AppLog.forceVerbose, §6.8), the setting untouched —, 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, written synchronously through one handle kept open, each line at the end of the file the pathname names as it stands (a second FilmOpen window may share the file: the pathname’s length decides the rotation, and a handle left on the file another window rolled is reopened on the pathname, so neither window’s lines are lost to the other’s rotation), so an app crash loses no completed line; the disk is forced only on an error or a warning entry and at close, so a power loss may lose the lines since the last of them; a failure to open or write is said once on stderr, after which the sink writes nothing; rotated to <name>.1.jsonl past 1 GiB (on Windows, which renames no file another window holds open, copied there and emptied in place, so logging pauses while the copy runs) — the two files take at most about 2 GiB; the fresh file’s first line is log.rotated {from, to, bytes, ms, mode: copy | rename}, and a rotation that fails names its stage (copy, truncate, rename) in its stderr line), 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. A line writes its values as they are (§13); the loader logs, at debug, project.load.start with its source, folder and library before the listing; a project listing that fails as project.load.failed {stage: list, source, type, error, ms} at warning, the failure then reaching the caller; a document skipped as project.document.skipped {stage: read|decode, path, type, error} at debug, beside its warning; project.loaded with its source’s and library’s folders, ms and its counts of files, entities, takes and warnings; library.unavailable with the folder and the exception’s type and message; and, at trace, project.warning with its kind, its values and its path. AppLog writes every line as it is given — the event’s data at any depth, keys included, its error and its stack, a URL’s user, password and query among them: nothing is masked, cut or scanned, and no call site cuts; the developer’s page and file (DevLog, DevLogFile, §6.11) keep every string whole too.
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, 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: 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 |
The reports, the job journal and the sync journal share one base (reportsDirectory, jobsDirectory, syncDirectory): the agent sandbox where one is set, else %LOCALAPPDATA%\FilmOpen\ on Windows, $XDG_STATE_HOME/filmopen/ (~/.local/state/…) on Linux and the app-support folder elsewhere; reports are created on lookup, the two journals never are. Each lookup logs app.folder {leaf, folder, reason, create, ms}, create saying whether the lookup makes the folder, at info, or, where the folder cannot be made, app.folder.failed with the same and the error’s type and message at warning before the failure goes on to the caller, the reason the variable that decided (LOCALAPPDATA, XDG_STATE_HOME, HOME), sandbox or applicationSupport.
The sample’s folders are in the system’s temporary folder, filmopen-sample/<time>-<suffix>/ (newSampleFolder), since a session copy has nothing to outlive: each opening of the sample makes a new one and first removes every earlier one but the sample still open, so that the app’s first opening of a session removes what earlier sessions left. The cleanup is housekeeping no person asked for, so nothing of it is shown, and all of it is logged — sample.folders.clean {parent, kept}, per folder sample.folder.kept {folder, reason: in use} or sample.folder.remove {folder} then sample.folder.removed {folder, outcome, ms} (a folder the system refuses to remove at warning, with its error’s type and message, and left: the start goes on), sample.folders.cleaned {parent, removed, failed, ms}, then sample.folder {folder, kept, ms}. Under flutter test (its FLUTTER_TEST variable) the folder of sample folders is the test process’s own, in the temporary folder too, since test files run at once and beside an app whose sample in use they must never remove. The web has 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, §6.11), never in a project or the library.
Projects on a phone. 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 in the project folder, 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…’s Choose a folder… (the dialog itself is on every platform, §9.2), Copy to a folder…, the library card’s Choose folder… and the parent-folder Browse button 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. A computer’s documents folder is a fourth seam, kept apart from the phone’s projects folder: documentsDirectory answers the person’s own documents folder as the system names it, through path_provider — never created, null on a phone and the web, an error where the system will not say — and is where New project proposes a project when it knows no better place (§9.2, documentsDirectoryProvider).
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, 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) — 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/fo-openai/openai (§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.16 Screenplay import
Section titled “6.16 Screenplay import”filmopen_fdx converts Final Draft XML into a complete in-memory project through the store’s
writeImportedProject. The writer validates all filenames, authors, versions, required content and the
creator’s directorship before writing any file, refuses a nonempty source and writes with create-only
semantics. On failure it removes only files this operation wrote whose text has not changed. It logs
import.write.start {target, author, documents} (each document’s type, tag, epoch and version), then
.done {project, files, ms} or .failed {type, error, written, ms}, and each removal that fails as
import.cleanupFailed {path, error}.
copyImportedProjectTo reserves a fresh directory under the chosen parent using an operating-system
unique suffix. It copies only flat JSON documents and the manifest through DirectoryProjectSource and
creates the media and thumbnails folders. Concurrent imports cannot claim the same destination. A
failed copy removes only its unchanged files and empty directories, each removal that fails logged
project.copy.cleanupFailed {path, error}; existing neighboring projects stay untouched. A successfully
written folder remains recoverable if opening it later fails.
The file reader accepts only regular files (including links to regular files), refusing pipes and devices before a potentially blocking read. It accepts UTF-8 and BOM-marked UTF-16, with at most 16 MiB of input; a read the platform refuses is logged as import.readFailed at warning with
the path, the platform’s message and the stage before it is refused as read_failed. Parsing is isolated
from the UI, refuses DTDs and limits XML nesting to 128 and parsing events to 500,000. Only plain data
crosses the isolate boundary; the in-memory source is constructed in the calling isolate. Conversion
rules and structured source provenance are Project Specification §16.6, among them the refusal of a
PDF with no scene heading, at least 20 cues followed by dialogue and more than half as many speakers as those
speeches, as not_screenplay / unreadable_text. No source content is executed,
no media attachment is fetched and no AI inference is performed.
The converter logs each decision it makes about the script as an import.decision line at info — a character, location or other resource created and what created it, a scene or folder started, every block emitted with its words, a synopsis attached and where, a declaration promoted (with its route) or left latent or merged, a source ID remapped or duplicated, a tag reference left unresolved, a name made a mention, a cue extension folded, furniture dropped, a cue absorbed into its speech or kept as an orphan, a blank paragraph skipped, production tags transferred, a transition kept literal, an ambiguous name kept literal, scene properties kept — with the source position as the reader gave it where the decision has one, the input words, the output (block id, tag) and the reason; a heading’s time reading (import.time) and a scene’s opening row (import.scene.source) are logged at info with their position, and import.converted counts the decisions by kind beside the lines and names the warning codes. The layout logs a numbered row refused as a heading (heading_refused, with what it became) and a cut scene’s row skipped (omitted_skipped), and recognition each speaker it counts in a headingless PDF (speaker_counted), as import.decision lines of the same shape. The script’s words are logged as they are. Prose mentions are matched against one pattern of every character’s names and aliases, built once the cues are read — the last phase to add either — and logged as import.mentions with the aliases, the ambiguous ones and its time. The converter does not load the in-memory project it wrote: the writer validates each document, and the open of the copied folder is the film’s one parse. The reader passes over the XML once: the parser’s events are checked as the document is built from them — a DTD, a nesting or an event count over its limit, and a character XML 1.0 forbids in any decoded value (text, attributes, comments, CDATA, processing instructions, so a numeric character reference counts as the character it names) are refused there —, and a refusal is logged as import.fdx.refused with its code, its reason (doctype, forbidden_character, too_deep, too_many_events, too_large, malformed with the parser’s message, and after the parse not_fdx with the root and its DocumentType, content_count, no_text), the events and depth reached and the offending value; a read logs its events and its deepest nesting on import.fdx.