19. The timeline engine
A scene plays and exports through one interface, TimelineEngine, in the pure-Dart package filmopen_engine. A native engine (a Flutter plug-in of its own) implements it; FakeEngine implements it in pure Dart for the web and for every widget test. Every engine is wrapped in LoggingEngine, so no engine can skip its log. No product package imports a native engine.
19.1 The interface
Section titled “19.1 The interface”TimelineEngine has a name (fake, mlt, mpv, android), its capabilities (whether it exports, exportAvailable; the export menu reads it while it builds, and the MLT and mpv engines answer it without probing, each choosing its encoder where an export starts), and these members:
| Member | What it guarantees |
|---|---|
probe(path) |
the file’s measured duration, rate, size, audio rate and codec |
prepare(timeline) |
loads the timeline and presents its first frame; refuses a timeline that validate() faults, with the first fault’s code |
replaceTimeline(timeline) |
after an edit, at a pause; the playhead stays where the new timeline still reaches it, else it goes to the new timeline’s last frame |
play(), pause() |
the clock runs from the native audio clock where there is sound; every engine stops by itself at the timeline’s last frame, its clock’s next reading saying it stopped, and a play after the end starts again from the first frame |
seek(to) |
completes with the presentation record of frameAt(to) |
scrub(to) |
returns its request number at once; only the latest request is kept; no sound |
endScrub(to) |
an exact seek to to |
nowUs() |
the engine’s monotonic clock now, in microseconds: the basis of every presentation instant |
presented |
one record per frame made available to Flutter’s texture (§19.5) |
clock |
about ten times a second: position, playing, totals of frames presented and dropped and of audio underruns |
events |
the native side’s stages (§19.7) |
textureId |
the Flutter texture; null before prepare and on the fake. Frames never pass through Dart. |
export(timeline, settings) |
an export in a session of its own, pinned to that timeline (§19.8) |
ownedProcesses |
the ids of the child processes the engine runs now |
dispose() |
ends everything the engine owns |
Every stream is a broadcast stream. The one error type is EngineException (a code, the stage, and the native cause where there is one); a native error is never dropped and never passed upward as anything else.
19.2 The timeline
Section titled “19.2 The timeline”EngineTimeline is one flat, fully resolved timeline, with no project, stem or draft in it: an opaque id, a revision raised by every change (an engine drops work for an older one), the frame rate as a numerator and a denominator (24/1, 24000/1001), the picture size, the sample rate and channel count of the mix, the duration (the end of the last picture item), its tracks, its markers, its subtitles and their subtitleStyle. Its JSON uses snake_case keys (fps_num, sample_rate, duration_ms, gain_db, at_ms, length_ms, source_in_ms, with_own_sound, subtitles, subtitle_font, subtitle_size; a timeline without the last three has no subtitles, in Noto Sans at 48 px); times are whole milliseconds; a wrong or missing required key is refused with bad_key, naming the key’s path.
A track has a kind — picture, speech, music, effects —, a gain in dB and its items. An item has a kind — video, image, black, audio —, a source (an absolute local path; none only for black), where in the source it starts, where on the timeline it starts and how long it lasts there, and, for a video item, whether its own sound is mixed in at the speech level. A marker is a position and a label: a beat, drawn, never played. A subtitle (EngineSubtitle) is a text burnt over the picture from a frame’s start (at) for a whole number of frames (length), the text as the person typed it, a newline a line break; EngineSubtitleStyle is the one font family and size, in pixels at the picture’s height, of them all. Not a track: nothing of it has a source.
The rules every engine keeps:
- One picture track, whose items neither overlap nor leave a hole (black is an item) and end at the duration.
- A video item whose source is shorter than its length holds its last frame; an item is cut at its end (
at + length). - Audio items on one lane do not overlap; concurrent sound of one kind is a second lane of that kind (two music entries whose spans overlap are two music tracks). Every engine mixes any number of lanes.
- Each lane plays at its gain, with no normalisation (§19.4). Markers are positions only.
- Subtitles are burnt on top of the picture, in playback and in the export alike, from one ASS file:
assOf(timeline), pure, which both engines hand libass. ItsPlayResX/PlayResYare the picture’s size; one style,Default— the font and size, white with a black outline a sixteenth of the size, bottom centre, side margins 5 % of the width and a bottom margin 5 % of the height (a comma or line break in the font’s name a space). The fonts: Noto Sans and Noto Serif, regular and bold, are bundled as a folder of assets,assets/fonts/(not the app’s own fonts, so the web build fetches none of them; their OFL on the licence page), for the engines to hand libass, so that the default looks the same everywhere. Times: eachatandat + lengthis read as its nearest frame, and written as that frame’s exact time less 1 ms, floored to the centisecond, never below zero (frame zero’s −1 cs is0:00:00.00: ASS has no negative time, and-0:00:00.01reads as +10 ms); libass shows an event whileStart <= now < Endand FFmpeg hands it a frame’s time truncated to whole milliseconds (a frame on an exact millisecond can arrive as the one before), so exactly the subtitle’s frames show it, under the export’sconcat(microsecond time base) and MLT’savfilter(the frame’s time base) alike, at any rate under 90 fps. Text, verbatim: a backslash beforen,N,h,{or}is followed by a word joiner (U+2060), so libass reads no command; a brace is\{or\};\r\n,\rand\nare each one\N. The engine that writes the file logsengine.subtitles.write.startwith its inputs (the timeline and revision, the count, the font, the size, the fonts folder) and the file’s text, then.ok {path, lines, bytes, ms}or.failed {path, stage, code, cause, ms}.
validate() returns one issue per broken rule, each a code with the track and item where it was found: bad_rate (a frame rate, size, sample rate or channel count not above zero), no_picture_track, overlap (two items of one lane, or a second picture track), hole (the picture does not run from zero to the duration without a gap, or the timeline is empty), missing_source (an item other than black without a source).
19.3 Time
Section titled “19.3 Time”Frame n of the timeline starts at n × fps_den / fps_num seconds exactly. A time t falls in frame floor(t × fps_num / fps_den + 1e-9) — frameAt(t) —, and its inverse frameStart(n) is rounded up to the next whole microsecond (frame 2 at 24/1 starts at 83,333⅓ µs, which is 83,334 µs), so that frameAt(frameStart(n)) == n for every n. A cut, a seek, a still’s half and a scrub all use these two functions; the fixture script computes the same in Python.
19.4 The mix
Section titled “19.4 The mix”Each lane’s gain is the default level of its kind: speech 0 dB, music −12 dB, effects −6 dB; a video item’s own sound plays at the speech level. Nothing is normalised: a scene with nothing touched sounds as these levels say.
19.5 The presentation record
Section titled “19.5 The presentation record”EnginePresented is the one observation every measure is computed from: the timeline revision it belongs to; the seek or scrub request it answers (0 during playback); the timeline frame shown; presentedUs, the instant the engine made the frame available to Flutter’s texture registrar, on the engine’s monotonic clock (CLOCK_MONOTONIC on Linux, QueryPerformanceCounter on Windows); on the record that completes a displaced seek or endScrub, the displaced request’s number; and the audio clock’s position at that instant, where sound plays. That instant is the last point an engine controls; Flutter’s own compositing after it is common to every engine and is not measured. On Android the record observes the frame as Media3 hands it to its final drawing step (onVideoFrameAboutToBeRendered), before that step’s GPU work, stamped with System.nanoTime() — the same monotonic clock — inside the callback: an earlier point than the desktop engines’, which do not wait for the texture’s output either; its audio position is absent, since Media3 gives no sample of the audio clock at that instant, so Android’s A/V offset is not measured. The Dart half reads that clock through clock_gettime(CLOCK_MONOTONIC); on a Windows test host, which exports no clock_gettime and never runs the engine, it reads a stopwatch, and the clock bracket of the first prepare names the source, the platform and the reason. A native frame timestamp, truncated to microseconds, is read as the nearest timeline frame; a time a person asks for is read by frameAt, and a seek is sent as the last whole millisecond at or before the frame’s native timestamp, since Media3 presents the first frame at or after the position asked.
19.6 Requests
Section titled “19.6 Requests”On Android a seek to the frame already shown, while paused with nothing waiting, sends nothing to Media3 (which would present nothing) and completes with the record of that frame restated under the new request number: its presentedUs is the earlier instant, so a measure of seek latency does not count it.
seek and endScrub complete with the record whose request is their own. A seek or endScrub displaced by a later one completes with the record of the later one’s frame, naming itself as the displaced request, so no future is left pending and the caller learns what was shown. A scrub request superseded before any frame answered it gets the event superseded with its number. A record for an older revision is never emitted; a request still waiting when the timeline is replaced is answered within the new one.
19.7 Events and codes
Section titled “19.7 Events and codes”EngineEvent carries a stage — open, load, decode, present, audio, seek, export, encode, mux, close, superseded, playback —, an outcome (start, ok, refused, failed, info), a code, the elapsed milliseconds, the native library’s own message as its cause, and data. The refusal codes — what was asked cannot be done — are bad_key, bad_rate, no_picture_track, overlap, hole, missing_source, unsupported, unavailable, busy (an engine may refuse playback during an export) and not_prepared; any other code is a failure.
playback failed means that playback has stopped because of a failure — a decoder or an audio output that gave out while playing: the one asynchronous failure a player shows. Both players — the media place’s and the timeline dock’s — listen for it, show Playback stopped because of a failure inline with its code and the engine’s own words, show Play again, and log it (player.failed and timeline.player.failed, {stage: playback, code, cause, ms, path or scene, engine, shown}); nothing is retried, and the person presses Play again. Every other failed event — an encoder’s choice, a subtitle’s font — is logged and not shown, since the engine goes on after it; a call the engine refuses or fails keeps its own display, its cause beside its code in the dock. FakeEngine.failPlayback says it on demand for the tests.
19.8 Export
Section titled “19.8 Export”ExportSettings names the output path, the container (mp4), the video and audio codecs (h264, aac), the bitrates and the encoder: auto, software (the engine’s software encoder, no hardware encoder tried) or a name the engine’s automatic choice lists. An ExportJob has a monotonic progress from 0 to 1, a done that gives the file or a code and its stage, and a cancel that completes only when no process, thread or file the job owns remains. FakeEngine does not export: its job ends at once with unavailable, never a pretended file; it opens no media either, so its probe is refused with unavailable.
The encoder is chosen by what works on this machine, once per engine and policy (auto, software), each at its first need (an export asking auto or naming an encoder needs the automatic choice, an export asking software the software one; neither engine’s capabilities needs one — the MLT engine exports wherever its shim is, and the mpv engine always offers export, its first export ending no_native where its FFmpeg is missing and no_encoder where no encoder opens), so that switching between them probes each at most once: each GPU encoder the build has — h264_nvenc, h264_qsv, h264_amf, in this order (not h264_vaapi, which takes only frames on a VA-API device, never a test frame from memory) — is given one test frame (256×256 black, the encoder’s first software pixel format — NV12 for h264_mf —, its packets discarded; NVENC refuses anything under 145×49), on a native thread of its own that the call joins — the caller’s may be in COM’s single-threaded apartment, as the app’s UI thread is, where FFmpeg’s Media Foundation refuses to start —, and the first that opens is the one auto uses; else the software encoder, given the same test: on Windows h264_mf without hw_encoding (Microsoft’s software H.264 MFT, part of the system; its test frame and the export’s consumer give it NV12), on Linux libopenh264 (Cisco’s OpenH264, BSD-2-Clause, built from its source beside FFmpeg). Nothing after the first that opens is tried. The choice’s available is the chosen one first, then every other candidate the build has (under software only, the software encoder alone), hardware_encoders those of them that are the GPU’s; an export naming one of them uses it as named; none opening leaves no encoder to name and an export no_encoder, its cause listing each candidate and why it did not open. An export asking software tries no GPU encoder: the software encoder alone, the only one it may name; where it does not open, the export is no_encoder, its cause saying the software encoder was asked and why it did not open, and no hardware encoder is tried instead. The environment variable FILMOPEN_ENCODER=software does the same for every choice (for checking an export without a GPU on a machine that has one, and for tests), winning over an auto asked; any other value is ignored, and the log says so. Where the software encoder comes from the setting, the choice’s reason says the setting asked for the software encoder, distinct from the variable’s FILMOPEN_ENCODER=software. The MLT engine says it all as encoder events (logged engine.native.encoder): {op: probe.start, candidates, policy, override, software_only, software_by, override_read} (software_by: FILMOPEN_ENCODER, setting or null); for each candidate the build has, {op: candidate.start, encoder, width, height, rate} before the native call; then one {op: candidate, encoder, width, height, rate, opened, ms, stage, status, pixel_format, packets, message, ffmpeg_log, cause} each — stage how far the frame got (find for one not in this build, alloc, open, frame, send, flush, receive, done; args for a size or rate refused, thread for the test’s thread not starting), status FFmpeg’s return code there (negative; 0 when none failed) — at thread the error number pthread_create answered (a positive errno) —, message FFmpeg’s text for it (at thread: could not start the test’s thread: <strerror> (errno N), on Windows with the system’s last error and its text), ffmpeg_log the encoder’s own warnings and errors during the test (still printed by FFmpeg as before) —, then {op: chosen, encoder, hardware, reason, tried, available, hardware_encoders} or its failure no_encoder; an export’s start names its encoder, what was asked, hardware, chosen_because (the choice’s reason for auto and software, named by the caller for a named encoder) and the automatic choice beside it, auto_choice and auto_reason; its started event’s report names the consumer’s vcodec and pix_fmt (for h264_mf also hw_encoding, 0).
19.9 The log
Section titled “19.9 The log”LoggingEngine logs through AppLog: engine.<op>.start with the engine, the timeline’s id and revision and the arguments — for prepare, replace and export the whole timeline given, every item’s kind, source, place, length, lane gain and own sound, once per call —, then engine.<op>.ok with the elapsed milliseconds and the result, or .refused (at warning) with the elapsed milliseconds, the code, the stage and the reason, or .failed (at error) with the elapsed milliseconds, the code, the stage and the cause, for probe, prepare, replace, play, pause, seek, endScrub, scrub (its refusal or failure only), export (its end logged when the job ends) and dispose. An error that is not an EngineException leaves as one with the code unexpected, logged with its stack. Besides: engine.scrub once per 100 ms of requests, each line listing every request since the last — {request, to_ms, frame} — and every answer since the last, its frame and presentation time ({request, frame, presented_us}) or its supersession ({request, superseded_by}), with the requests still waiting and why it was said (window, or endScrub and dispose, which say the rest once the drag’s exact seek has answered or the engine has gone, so a drag under 100 ms is logged whole); a scrub that throws logs its to_ms and its real time; engine.frame.first after a prepare or a play; engine.clock every 5 s while playing, with the position and playing beside the counters; the end the engine reaches by itself as MLT’s and libmpv’s native lines, the fake having none (§19.16, The end in MLT); engine.stall for any gap over 100 ms between presentations while playing; engine.play.summary once when a play ends — why (pause, seek, scrub, endScrub, prepare or replace once it succeeds, dispose, failed where the engine says playback stopped because of a failure, or ended where the engine’s clock, having said it played, says it stopped on its own), the frames presented, the frames dropped and the sound’s underruns by the engine’s clock, the position, the longest gap between frames and the play’s milliseconds —, a refused prepare or replace ending no play; where the engine plays on after a seek asked during a play (an exact libmpv seek does, the fake and MLT stop there), that seek’s summary ends the play before it, and the engine’s own word that it plays on — an audio event whose data says playing: true, which the libmpv adapter sends as it answers such a seek, before the answer — starts the play after it with engine.play.continued {after: seek, request, frame, reason}, counted from then to its own summary, so a play that reaches the end before the clock’s next reading still ends in its ended summary (such a word with no seek of a play waiting, or a play already counted, is engine.play.not_continued at debug, with its reason); every event as engine.native.<stage> (at error when failed, at warning when refused); engine.export.progress at every tenth, and engine.export.progress.failed; engine.export.cancel, then engine.export.cancelled with the milliseconds the cancel took and the processes the engine still owns, or engine.export.cancel.failed. Paths and the native messages are logged whole; the presentation records are not logged one by one, and no engine sends an event per frame, per native wakeup, per observed property or per clock tick. One sound at a time: a play on one engine first pauses the engine that played last where it is another and still plays — the dock’s and the pop-up player’s alike —, logged engine.play.yielded {from, to, outcome: ok, ms} with each engine’s name, timeline and revision, and engine.play.no_yield {from, reason: not_playing} where the one that played last has stopped already; the paused one says so by its clock, which the dock’s player follows. Where that pause fails, the play fails with yield-failed, said once as engine.play.failed, its cause naming the engine that did not pause, its timeline and revision, the time it took and the pause’s own code, stage and cause; the new engine does not start, and the engine that played last stays the one remembered. A native event is logged under the timeline of the engine that read it from the native layer, which is not always the one it happened in: on a machine without a sound device, a refused device’s engine.native.audio line may name the other engine’s timeline.
The fixture script prints one JSON line per event: fixture.<command>.start; fixture.command with every FFmpeg and FFprobe command line; fixture.file and fixture.manifest for what it writes; fixture.check with each check’s result; fixture.probe.failed with the command, its exit code and its error lines wherever a file cannot be probed; fixture.<command>.ok with the elapsed milliseconds, or .failed with the stage, the command, its exit code and its last error lines (or, for anything unexpected, the cause and where). The lock helper logs lock.wait with the holder’s pid, lock.held with the milliseconds waited, lock.released with the milliseconds held and in all and the command’s exit code, and lock.refused and lock.failed. The bench logs bench.start, bench.lock.held and bench.lock.released, bench.refused (the lock held, the fixture missing, the manifest invalid, an unknown engine), bench.stage, one bench.measure per measure, bench.stress.rate with the rate a play case is judged at and where it came from, bench.stress.probe.failed when the probe of a prepared file throws (that case’s fail), bench.stress.played with a play case’s frames, how far they advanced and its captured codes, bench.verify with the verifier’s exit code and the end of its output, bench.cancel.start with the last progress, whether the job was done and whether the cancel is measured, bench.cancel with its outcome, bench.cycle.failed, bench.cycle.stale, bench.window.saved, bench.report, bench.failed and bench.done.
19.10 The fixture
Section titled “19.10 The fixture”scripts/make_timeline_fixture.py make writes the fixture scene’s media with the system’s FFmpeg into a folder outside the repository ($FILMOPEN_FIXTURES/timeline-35s, else ~/filmopen-fixtures/timeline-35s) and the committed manifests packages/filmopen_engine/fixtures/timeline-35s.json and timeline-stress.json: an EngineTimeline whose sources are relative to that folder, beside an expect object. The scene is 32 s at 1080p, 24/1, 48 kHz stereo, in six blocks: an H.264 clip; an HEVC clip with its own sound; an H.264 clip at 23.976 fps; two stills, the first and the last frame, two seconds each; three seconds of black; a five-second clip in a seven-second block, its last frame held. Five voice lines, each a tone of its own frequency at a block’s cut (WAV at 48 kHz and MP3 at 44.1 kHz); two music lanes whose spans overlap; two noise bursts in separate bands; three markers. Every rendered clip has a keyframe each second.
Every frame carries its number burned in and a strip of 16 squares at the top left (24 px at 1080 lines, white is 1): four bits of clip id, then twelve bits of the source frame, least significant first; black reads as clip 0, frame 0. check re-probes every file against the manifest and reads each clip’s keyframes; verify <mp4> decodes every frame of an export and checks the sequence of codes against the manifest — every cut, the 23.976 conversion, the stills’ halves, the black, the held frame —, each tone’s onset against its cut within 20 ms, and each sound’s level in its band within 1 dB of the manifest, which holds each source’s own level plus its lane’s gain — an effect in both halves of its span, the one across the 18 s cut on each side of it. Times are the export’s own: each frame’s presentation time and the audio’s start are read from the file, every frame must sit within half a frame of its place at the manifest’s rate, and a cut’s time is its first frame’s presentation time. The stress set — a variable-rate phone clip stored on its side with a rotation, a 4K clip, a missing, a corrupt and an unsupported file — says for each whether an engine must play it or refuse it, and with which code.
19.11 The bench
Section titled “19.11 The bench”packages/filmopen_engine_bench is a Flutter app for Linux and Windows that drives any engine — fake, mlt and mpv —, wrapped in LoggingEngine, through the fixture: a cold prepare and play; twenty prepared plays; two hundred seeded exact seeks, twenty of them read back from the window’s picture where the platform’s capture holds the texture; two hundred scrub requests at 60 Hz and an endScrub; a ten-minute play of the scene repeated along one timeline; the stress set — a refusal checked by its code, a file that should play played for two seconds, its frames in order and advancing by at least half the interval’s frames, its picture’s code read where the capture holds it (upright and at full size, since the code is read at its 1080-line places), a later capture showing a later source frame by the same measure at the clip’s rate (the probe’s, or the timeline’s where the probe gives none; a probe that throws fails the case); five exports and one cancelled once half way — not measured, never a pass, unless the job was still running with its last progress at or past one half and below one —, two of them verified by the fixture script; fifty cycles of prepare, play, twenty scrubs, replace, export and dispose, a record of the old revision presented anywhere from the replace to the disposal counting against them. Every measure is computed from the presentation records by pure functions (measures.dart): first frame; seek latency; the scrub’s request-to-picture latency and its longest interval without a new frame; frames on time, over every frame from the play’s start to its pause, where the deadline of frame n is the first record’s origin plus frameStart(n) plus one frame interval; the longest stall; the software A/V offset. Memory is the bench process’s high-water mark and the sampled peak of the processes the engine owns; the engine’s size is every file of its release bundle beyond the bench built without any engine plug-in. A capture whose code squares are all one colour shows no code and is not measured. It writes bench-report.json and bench-report.md, each measure with its n, p50, p95 and max, its gate and pass, fail or not measured.
With the overlay: --dart-define=SUBTITLES=<file> lays a JSON object of subtitles, subtitle_font and subtitle_size — as an EngineTimeline’s JSON has them, and nothing else, a file with another key refused as the manifest is — over the fixture’s own timeline (withSubtitles), so that every measure is taken on the same scene with and without subtitles; bench.subtitles {file, count, font, size, fonts, subtitles} says what it laid. An engine is given the run’s log and the fonts folder, --dart-define=FONTS=<folder>, else the repository’s assets/fonts (the bench’s own bundle holds no fonts).
The bench and the builds share one lock: the operating system’s byte-range lock on /tmp/filmopen-bench.lock (%TEMP%\filmopen-bench.lock on Windows), taken by the bench for its whole run and by scripts/with_bench_lock.py -- <command> around any other command. A bench that cannot take it refuses, naming the holder where the system lets the lock file be read (Linux; on Windows the holder’s range cannot be read); the helper waits for it instead (lock.wait).
19.12 The scene’s rules
Section titled “19.12 The scene’s rules”A scene’s timeline is derived from its file, never stored whole, by filmopen_timeline. Three rules decide it, each one function in rules.dart, so that a change of mind is a change of one function; they are pure, and the scene’s build logs what each decided (§19.13).
- The entry in use in a block’s media group (
entryInUse, Project Specification §9.0): the first usable entry flaggedfavorite, else the first usable entry, in file order; else none;MediaEntry.usable, the rule a prompt’s inputs read too, means neither deleted nor archived, whether kept or unreviewed. - A block’s output duration (
blockOutputDuration): itsoutput_duration_secwhere set; else its clip in use’s measured length; else its voice line in use’s; else itsduration_sec; else 3 s. A value not above zero says nothing. The reason travels with the seconds:set,clip,speech,asked,default. - A block’s picture (
blockPicture): its clip in use; else its first frame for the whole block, or its first frame for the first half and its last frame for the second where both exist; else black. A last frame alone is black.
What runs past a block’s output duration is cut at the block’s end — a clip, a voice line —, and a clip shorter than its block holds its last frame (§19.2).
19.13 The scene’s timeline and its compile
Section titled “19.13 The scene’s timeline and its compile”SceneTimeline.build(project, scene, resolve:, probe:, revision:) derives a scene’s timeline from its file:
- The blocks in script order, each starting where the one before it ends, the first at frame 0. For each block, the entry in use of
clips,first_frames,last_framesandvoice_lines(§19.12), its picture, and its output duration and why. The measured lengths come from the media’s record (the batch item’sduration_ms, throughProjectMedia), else the entry’s ownduration_ms, else the engine’sprobefor a clip or a voice line. - Every block boundary becomes a frame once, here: the cumulative seconds from the scene’s start rounded to the nearest frame, ties toward the later (Project Specification §11.3,
nearestFrame). Everything after counts frames, and theEngineTimelineis given each frame’s exact start (frameStart), so the engine’sframeAtlands on the same frame and playback and the OTIO export cut on the same frame: a boundary at 1.0625 s at 24 fps is frame 26 in both. - Music and sound effects: each block’s entry in use of
musicandsound_effects, from its block’s start to where itsspanssay (Project Specification §10.3) — absent, its block’s end;blocks, the last named block’s end;until, the scene’s end, a later block’s end or the seconds given — never past the scene’s end, and never longer than its own measured length. Aspansnaming no later block is the owning block alone, said byunknown_block. Where two of one kind overlap, the later goes on a second lane, lanes in the order their first entry appears; a missing file or an empty span takes no lane. - What a log carries: every reference and every cause whole — a media reference as the file wrote it, an exception’s message, a native cause — in a diagnostic, a report and a log line alike; a missing file is named by its whole reference.
- A clip that spans blocks (Project Specification §10.3): a clip whose
spans.blocksnames a later block of the script does not decide its block’s duration (the block falls to its speech,duration_secor the default) and plays from its block’s start to the end of the last block named, never past the scene’s end, kept inclipSpanswith a lane of its own wherever two overlap (the music rule’s lanes).coverOf(block)is the spanning clip a later block shows — the nearest one started before it and not yet ended —ownClipEnd(block)where the block’s own clip stops showing: its start without a clip, its end where it spans or nothing measured it, else where it ends; andresumeOf(block)where the covering clip shows in it and from which frame of its source — the frames since its own block’s start, no later thanlastStartOf(source),heldwhere that held it.lastStartOfis a frame of the slower of the source’s own rate (where the probe measured the source) and the project’s before its measured end, 100 ms where the source’s rate is unknown: a source’s frames need not fall on the project’s, and an export trims the source before converting its rate, so a piece started at the project’s frame nearest the end could find no decoded frame to hold. The build logs each astimeline.sound(group: clips,placement: span, its reference and file, its lane,audio;skipped: missingorsilent), and every other clip’s sound — a covered block’s own included, which plays in its picture from the block’s start to where its clip stops showing — astimeline.soundwithplacement: pictureand no lane; and each covered block astimeline.block.covered(the covering reference and its block,own_until_ms,source_in_ms,held_last_frame, and the clamp’s inputs:source_ms,source_fps,last_start_ms); the owning block’stimeline.blockcarries itsspans. - A voice line starts with its block and stops at its measured end, cut at the block’s end.
- Subtitles are laid by the beats’ one rule,
layTimed(§19.15), over each block’s output duration in seconds — so the dock draws them where the export burns them — and each endpoint becomes a frame as a block boundary does: the block’s start in the scene’s seconds plus the endpoint, to the nearest frame, ties later; an endpoint at the block’s end is the block’s end frame; both clipped to the block’s frames. A 1.0625 s block’s whole-block subtitle at 24 fps ends at frame 26, with the block; of two 1.1 s blocks, the second’s spans frames 26 to 53. Not burnt, each logged with its reason: one past the block’s end (past_block_end), one the others left no time (no_time_left, the beats’ zero-length mark at the block’s end), one without text (no_text), one that keeps no frame (no_frames); one burnt islaid, orcut_at_block_endwhere itsduration_secran past.positionis a label only. - A missing source: the resolver gives each entry a local path.
SceneTimeline.storeResolvergoes throughProjectMedia.openandMediaStore.open; a URL, a reference no file answers and media held only as bytes are not materialised — the item plays as black or silence, said bymissing_source. An item of no frames is left out: a one-frame block’s first still, a voice line under half a frame. - The format is the project’s
format(§8.5):fps(an integer, ornum/den),widthandheightorresolution,sample_rate,subtitles(font,sizein pixels at the output’s height); each value it does not give is the default — 24 fps, 1920×1080, 48 kHz, subtitles in Noto Sans at 48 px — said bydefault_format(itsfieldfps,resolution,sample_rate,subtitles.fontorsubtitles.size).
toEngineTimeline() compiles it: one picture track without holes — a clip as a video item carrying its own sound unless its entry’s audio is false or it spans; a covered block’s own clip up to ownClipEnd, then the covering clip as a video item resumed at the frames since its block’s start, never later than lastStartOf its source (whose last frame it then holds), stills and black under it not shown; first and last frames as two image items split at the block’s middle frame, black (and a missing picture) as black items —, one speech lane at 0 dB, each spanning clip’s sound — unless its audio is false or its file is missing — as an audio item from its start to its span’s end or its measured end, whichever is sooner, on lanes of their own at the speech level after it, and the music lanes at −12 dB and the effects lanes at −6 dB; the subtitles at their frames’ exact starts, in the format’s font and size. Its id is the scene’s version stem; its revision the one the build was given, the provider’s counter.
filmopen_state holds the two providers: timelineEngineProvider, a factory of engines — the app’s main overrides it with one making the platform’s engine; otherwise it makes LoggingEngine(FakeEngine()) — so that each opened scene owns an engine of its own and whoever makes one disposes it; and sceneTimelineProvider, a family by the scene’s version stem, built again only where what the timeline is made of changed — SceneTimeline.inputsOf: the scene’s script blocks (their ids, text, durations, cues and media), its own media and its epoch — never its name, synopsis or notes, so that typing into them while the scene plays stops nothing —, the project file of record’s format, the file and recorded length of each entry in use, and whether the media folder holds each of those files, so that a file arriving there (a download a media tile asked for) plays — and otherwise answering the timeline it holds for the open source — another source opened builds afresh, since it resolves the same files to other paths —, so that a re-read for another file or another field neither reloads the engine nor stops a play; each build takes the next revision (a counter raised on every build, never a hash, so that an undo still raises it). The media store is made again only when another source opens. The build walks the scene’s document once for every block’s six groups (MediaPlaces, given to entriesIn) and looks each entry’s media up once. A failed build is not retried: it is logged once and the provider answers the error. mediaAudioProbeProvider answers through the same engine whether a clip has sound — its probe’s audio stream, or the engine’s code and message and no answer: the fake’s unavailable on the web, which the importer records as no_probe, and any other code a failure it logs at warning (failed:<code>, §6.13) — for the insertion’s binding. sceneTimelineProbeProvider measures — the duration and, for a picture, the rate (MediaProbe) — through the probe of an engine of its own, made once from the factory and disposed with the provider — so the probes kept and the revisions counted hang on no dock’s engine —, once per file for that engine’s life; a probe the engine refuses (EngineException.refusal: unavailable, unsupported …) leaves the length to the rule, and a probe that fails fails the build, nothing kept of it.
The log: timeline.build.start {scene, blocks, revision}; per block timeline.block {scene, block, index, in_use: {group: favorite | first_kept | none}, picture: clip | stills | black, seconds, reason: set | clip | speech | asked | default, start_ms, frames, subtitles} — subtitles, where the block has some, one entry each: {index, text, duration_sec, start_sec, end_sec, start_frame, end_frame, shown, reason}, what it asked, where it fell and why —; per music or effect timeline.sound {scene, block, group, in_use, spans, start_ms, end_ms, lane}, or {scene, block, group, skipped: missing | empty}; per clip timeline.sound {scene, block, group: clips, placement: span | picture, reference, file, in_use, spans, audio, start_ms, end_ms, lane} (spans and lane on a span only), or with skipped: missing | silent; per resolved entry timeline.resolve {reference, outcome: file | bytes | url | no_name | no_file, path}; where a probed length decided a block, timeline.probe.used {scene, block, file, duration_ms}; each diagnostic as it arises timeline.diagnostic {scene, code, block, group, file, field}; then timeline.build.ok {scene, revision, blocks, lanes, sounds, clip_spans, clip_sounds: {picture, span}, clip_lanes, subtitles, diagnostics, duration_ms, ms} — sounds the music and effects placed, clip_sounds the clips’ sounds placed, clip_lanes the lanes the spanning ones are mixed on — or timeline.build.failed {scene, stage: format | blocks | resolve | probe | place, block, group, reference, file, code, engine_stage, reason, cause, ms} — the source being resolved or probed, and an engine’s code, stage and reason where the failure is one, the failure thrown as it came. The provider logs timeline.built {scene, revision, blocks, entries: {reference: [file, recorded_ms]}, present: {file: held}, reason: [first | blocks | scene_media | epoch | format | media | present], ms} as it builds — the parts that changed — and timeline.reused {scene, revision, reason: inputs_unchanged, ms} where a re-read changed none; before either, the inputs are read and logged: timeline.inputs.start {scene, blocks}, per entry in use before the media folder is asked of it timeline.inputs.medium {scene, block, group, reference, file, recorded_ms, checked: store | url | no_name, unreviewed, favorite, skipped: [{reference, reason}]}, then timeline.inputs.ok {scene, media, present: {file: held}, ms} or timeline.inputs.failed {scene, block, group, reference, file, cause, ms} — the medium it was at —, the failure thrown as it came; with nothing to build, timeline.provider.none {scene, why: no_project | no_store | no_scene}; a probe logs through the engine (engine.probe.start, .ok, .failed). The rules themselves log nothing.
19.14 OTIO
Section titled “19.14 OTIO”writeOtio(timeline) writes a SceneTimeline as the scene’s export of the Project Specification §15.5, in Dart with no library: a SceneExport holding the .otio file’s JSON text, its manifest (ManifestItem: file, block, lane, timeline and source frames) and its report (ReportEntry: missing_source, held_last_frame, default_format, still, and the build’s own diagnostics). Its frames are the build’s, so the OTIO cuts where the EngineTimeline does; a clip short of its block, by a frame or more, is its real frames then a gap on the picture and on its own sound; a clip covering a block is resumed after the block’s own clip at the frames of its file before the piece (resumeOf), its frames past its file a gap reported held_last_frame; its tracks are the picture, then one audio track per lane — the clips’ own sound (none for a clip whose audio is false, nor for a spanning one), each spanning clip’s sound on Clip sound 2, Clip sound 3 … by its lane, the speech, each music lane, each effects lane. The manifest and the report are the objects the MP4 export will write beside its file.
The log: otio.write.start {scene, revision}; one otio.write.item {scene, lane, item: clip | gap, block, file, timeline_in, timeline_out, source_in, source_out, available, held} per placement written; then otio.write.ok {scene, tracks, clips, gaps, markers, report (every entry whole), ms} or otio.write.failed {scene, stage: tracks | encode, cause, ms}, the failure thrown as it came. Reading OTIO back is not built.
19.15 The timeline dock
Section titled “19.15 The timeline dock”filmopen_timeline_ui’s TimelineDockFrame lays out every entity’s page (filmopen_browser/entity_detail.dart), with a TimelineDock beneath a scene’s tabs: the page keeps its place in the tree whether a dock stands beneath it or not, so the reader’s tab is kept from page to page, and the dock alone is keyed by the film and the scene’s version, so that another scene or version is another dock (§9.1 for its place and its height). It reads the scene’s timeline from sceneTimelineProvider for the version on screen, built again where what it is made of changed.
-
A scene with no blocks has no frame to show: the player asks nothing of the engine (
timeline.player.prepare.skipped {why: no_blocks}) — and a scene that becomes empty while it plays, or while a play is still in flight, is paused, the pause queued behind that play —, play and the exports are disabled, play’s tooltip and each export item saying that blocks written in the script fill the timeline, and the dock slid up shows that as its empty state. -
Narrow by default: the handle and one row holding the scene’s time (
m:ss / m:ss, the playhead and the scene’s length); a timeline that failed to build says so in that row with its code (an engine’s code, elseunexpected), the log holding the rest. -
Its height: dragged on the handle, or a tap on it — open to half the page from the narrow row, back to the narrow row from any other height. Held within the page’s bounds as the drag goes, so that turning back moves the dock at once; kept at the drag’s end (
Settings.timelineHeight), laid out between the narrow row and half the page. A drag the system cancels keeps nothing — heard by aListenerbefore the recognizer turns the cancel into an end —, and a height that did not change is not written. The handle’s tooltip stands above it, so that it never covers the dock’s row. -
The tracks, slid up, beneath the row, on one time scale (below, The scale; a tall set of lanes scrolls up and down), each lane marked at the left by an icon naming it: the ruler, a time every second or every few, and the scene’s end at its end — a time too near it, as the theme draws the two, giving way; the picture, a box per block (
timeline.block.<n>, a button) as long as its output duration — painted never under 3 px, in order with its 1 px gap —, named after its clip’s file, Stills or Black, striped and outlined in the error colour where its source is missing, its tooltip giving its number, its seconds, why (set on the timeline, its clip’s length, its voice line’s length, the script’s duration, the default) and what the build noticed about it (a missing file by its name, anything else by its code); speech, music and sound effects, a lane per laneSceneTimelinehas, each sound named by its file — a lane with nothing is not drawn, and a missing sound is no lane but a line in its block’s tooltip; and beats, where a block has some. The playhead is a line across them. -
The scale: the scene fits the room by default — the room’s width over the scene’s seconds, unless a block’s box would be painted under 3 px (its frames’ interval less the 1 px gap), in which case the scale is the one that paints the shortest box 3 px and the scene scrolls sideways. The ruler zooms it, from 1 (fit) to
max(1, 8 × fps / fit)— a frame 8 px at most, enough to drag an edge by the frame; a scene whose frame is already wider does not zoom: a horizontal drag on the ruler zooms in to the right and out to the left, 160 px doubling or halving the scale, the second under the pointer as it went down kept under it, and held within the bounds as it goes; a double tap returns to fit; its semantics (timeline.ruler, its value the zoom) zoom ×2 and ÷2 about the playhead. The bounds are applied again at every layout, so a widening room that raises the fit brings the zoom down with it. The zoom is the open dock’s and nothing remembers it: another scene or version starts at fit. -
A frame moves the playhead alone: the lanes are laid once per timeline, scale, zoom and block open, and a presented frame redraws only the playhead, the transport’s time and the picture.
-
The view follows the playhead: where a presented frame — playing, a scrub — or a seek puts the playhead outside the visible span, the view jumps to put it a quarter of the span in, clamped at the ends: a page jump, so that the ruler stands still between jumps. A scroll the person makes — the bar, the wheel, a drag of the tracks — is not fought: the view stays where they put it until their next play, seek or tap on the ruler, a box or its edge — and such a tap brings the time it asks for into view even where it is the time already shown.
-
The block open in the script (
scriptFocusProvider, §7, for this scene’s version) is the current box: the theme’s primary fill with a 2 px primary border and its ink, its label ending Open in the script; as it becomes current the view slides it to the middle of the span — a box wider than half the span with its start, or the playhead where it holds it, a quarter in —, unless the person’s own scroll holds the view. With no block open none is highlighted, and opening a block in the script moves no playhead. -
The timeline in charge: a pointer down anywhere in the dock puts the timeline in charge of which block is open (
timelineChargeProvider:dockPressfrom the press’s down to its up or cancel, so that the editor condenses nothing for it, andfollow(true)), and the block under the playhead is asked to open at once (scriptFocusRequestProvider.ask, bytimeline); from then on, each time the playhead — playing, scrubbed, sought from the ruler or a box — moves into another block, that block is asked, once. A tap on a box — or on its edge, the block’s own end — takes the charge itself (a tap through the semantics comes with no press), asks its block (bydock) and seeks its start. Only a dock the person pressed asks: another scene’s or version’s dock, opened while the timeline was in charge, asks nothing until pressed itself. The person takes the charge back in the editor, or by a press elsewhere on the page (§9.3); nothing is asked after. -
The beats are laid by the timeline’s build, from the scene version it is built from (
ScriptBlock.beats, each block’s own), and kept on it (SceneTimeline.beatLayouts, by the block’s index), by the one rule of beats and subtitles,layTimedinfilmopen_timeline: in order from the block’s start, each from where the one before ends by itsduration_sec; those without one share what is left of the block’s output duration equally, a share of zero or less drawing them at the block’s end; one cut at the block’s end where it runs past; one that would start at or past the end is not drawn in the next block — the block shows a+Nwhose tooltip gives the beats’ total against the block’s seconds and names each beat not drawn. A mark shows its position as a glyph (◂ before, • during, ▸ after the dialogue) and its description’s first four words; its tooltip, the whole beat. Nothing is resized or rewritten. -
The subtitles (
ScriptBlock.subtitles) are a lane of their own under the beats’, drawn exactly as the beats are, from the layout the build burns them from (SceneTimeline.subtitleLayouts), one lane and one box for both kinds: a box per subtitle with its position’s glyph and its text’s first four words, a line break read as a space, the whole subtitle — its text, its position, its seconds or its share — in its tooltip; the+Nwhere some start past the block’s end, its tooltip giving the subtitles’ total against the block’s seconds and naming each one not drawn; the lane (Icons.subtitles_outlined, Subtitles) only where a block of the scene has a subtitle. Those too short to draw at the scale shown are counted in the block’s tooltip. -
The player (
ScenePlayer): the dock makes its engine fromtimelineEngineProvideras it opens on a scene version, prepares it with the first timeline (and, where a prepare failed, with the next revision: a replace needs a prepared engine), replaces the timeline on every later revision (pausing first where it plays; the engine keeps the playhead where still valid), and disposes it when it goes — another scene or version is another dock, so another engine. An engine’s dispose waits for its exports: one running is cancelled, one already ending — or cancelled by the person — is waited for, and one whose native start ends after the dispose began is closed there and answeredunavailable, its partial file deleted, so that every export’s end is said and no encoder writes on for an engine that is gone. Another rail page (navIndexProviderleaving Project) hides the page without leaving it: the player pauses, keeps its engine and resumes nothing by itself — a play still in flight included, the pause queued behind it so that it applies as the play settles. Every engine call goes through the player one after another (a replace never overtakes the prepare before it), through the engine’sLoggingEngine; a call the engine refuses or fails is logged with its code and said in the dock — an icon with its tooltip in the narrow row, a line beneath it when slid up — never a dialog, and nothing is retried. The engine’s informationalaudio,info,no_audio_deviceevent (libmpv found no audio device, or MLT’s SDL counts no sound output, §19.16: it plays silent) keeps a silent-playback notice for the player’s life, through every later call: a volume-off icon and No audio device is available. Playback is silent. in the same places, in the neutral text colour, transport still enabled; the build’s, the edit’s and the player’s failures are said before it. The event is logged once, as it comes, astimeline.player.audio.silentwith the scene, the engine’s stage, outcome, code, time, cause and data whole, its reason and the decisionsilentNotice. -
The transport, the dock’s own (
timeline.play/timeline.pause,timeline.scrub): play or pause; a scrubber whose drag asks for the picture withscruband whose release ends withendScrub’s exact seek — while the first prepare still runs a drag asks nothing (timeline.player.scrub.skipped {why: not_prepared}), its release queued behind the prepare; the time,m:ss / m:ss, the playhead and the scene’s length; a tap on the ruler seeks, on the tap’s up — so a drag’s start seeks nothing, and a single tap waits out a double tap’s window. Where the platform cannot play — the web and iOS, which have no engine that plays a scene (platformPlayLimit) — play is disabled, its tooltip saying why; Android plays. The dock logs the limit it read once,timeline.dock.play_limit {scene, limit, platform, reason}. The picture: the engine’sTexturewhere it gives one; the fake gives none, so the frame number and its block’s number stand in. Narrow, it is a thumbnail beside the transport (timeline.thumbnail); slid up, a 16:9 picture at the dock’s top left beside the tracks (timeline.picture), as tall as the dock’s room below its row (less 8 px) and at most half the dock wide (TimelineDock.pictureIn); the tracks take the rest. -
The one edit: on a version the viewer may change, each picture box has its right edge (
timeline.block.<n>.edge). Dragged, it sets the block’soutput_duration_secto the frames it asks for — snapped to whole frames by the engine’s arithmetic (frameStart, §19.3), never under one; a double tap clears the key, so the rule decides again (§19.12). Without a pointer, the edge’s semantic increase and decrease make the block a frame longer or shorter. A drag that ends where it began asks the draft for nothing (timeline.edit.gesture {outcome: unchanged}), and one the system cancels sets nothing (outcome: cancelled). The change goes through the scene’s open draft — theDraftSaverthe scene’s form writes through, found withOpenDrafts.of(stem, projectKey:)— never to a file: the block is found in the draft by its id (a reorder not yet saved does not move the edit), changed bywithBlockChanged, and written by the draft at once, after which the re-read re-derives the timeline. With no form open on the scene’s file — its View/Edit tab not built — the edit is refused and the dock’s row says to open the Edit tab; a block the draft no longer holds, or a write the draft refuses, is said there too with its code. The refusal stays in the row until the next edit, or until a form opens on the film. -
A scene’s media in the cloud (§6.14): play and both exports first make every medium of the scene that the sync holds on its target only local, together (
ScenePlayer.fetchMedia, throughSceneMedia.ready); meanwhile a ring beside the pop-out (timeline.fetching, turning until the first file is here, then filling) says Downloading the scene’s media: {done} of {total}, both exports are disabled with that sentence beneath them, and nothing plays or is exported. When they are all here the scene’s timeline is built again at once and the play or the export waits until the engine holds it — behind any call already queued —, so the first play has them and the export holds them; a medium no scene played uses stays on the target. A file that could not be downloaded is named in the dock’s notice ({file} could not be downloaded ({code}), so the scene does not play or export.), and an engine that could not take the rebuilt timeline is said with its code; either way nothing starts. A second press while the download runs asks for nothing more, and a pause or the page hidden meanwhile leaves the scene paused. Loggedtimeline.player.media {scene, reason: play | export, outcome: ready | missing | failed | busy, file, code, revision, ms}, a play left pausedtimeline.player.play.skipped {why: paused_while_fetching}, the download itselfscene.media.*. -
The exports, in the dock’s menu (
timeline.export, beside the transport). Export MP4… (timeline.export.mp4) asks for a place (file_selector‘sgetSaveLocation,.mp4added where it was left off), then runsexporton the dock’s engine with the defaultExportSettings(H.264 and AAC in MP4) and the encoder Settings’ Export encoder names (autoorsoftware, §9.4; read as the export starts, the dock’stimeline.export.startsaying it asaskedwithasked_from: setting) in a small dialog: a progress bar, Cancel (timeline.export.cancel, which waits for the job’s own cancel), then Saved. under the file’s path, Cancelled: nothing was saved., or the failure’s code with the engine’s own words beneath it (its cause as it came: the encoder that ran, a path it could not open, a library’s message); the dialog’s content scrolls, so a cause of any length keeps Close in reach. It is disabled, its reason beneath it, where the engine cannot export (capabilities.exportAvailablefalse: the fake). Export OTIO… (timeline.export.otio) asks for a place (.otio) and writes, through the platform’s file writer, the filewriteOtiomakes (§19.14), its manifest beside it (<name>.manifest.json:{scene, revision, items}) and its report (<name>.report.json:{scene, revision, entries}), then lists the report’s codes — missing sources, held last frames, stills — in the dialog. On the web and on Android and iOS neither is offered, each saying that exports are saved from the desktop app: the web’s save gives no folder for three files and Android’s picker has no save location. Choosing no place exports nothing; a save dialog that fails is not a choice of nothing: its cause, as it came, is said in the dock’s row. Nothing the person did not choose is overwritten: the picker approves the one path it returns, so where an OTIO’s manifest or report — or the file at an extension the export added — is already there, the export is refused before anything is written, the dialog naming the file. Cancel waits for the engine’s cancel: the dialog says Cancelling… until it completes, then Cancelled: nothing was saved.; a cancel that fails is said with its code as soon as it fails, while the export goes on; an export that made its file before the cancel took says Saved.; Close only once nothing asked for still runs. What plays is paused before an export (pause {why: export}). Beats are drawn only where they fit inside their block’s box at the scale shown; where they do not, the block’s tooltip counts them. -
A tap on a block opens it in the script, through the one door from outside the editor,
scriptFocusRequestProvider: while the timeline is in charge, the block under the playhead is the block open in the script. -
The pop-out (§9.1): the picture in a window of its own, through the embedders’ own second view — one engine, one player, one texture.
filmopen_platform’sMonitorWindowis the door (open,close,isOpen,fullScreen) over the method channelfilmopen/monitor; nothing is asked of the runner until the dock’s button first builds (check, the runner’savailable), and the web’s stub answersplatform, a runner that does not answerno_runner, a runner that refuses its own reason. The Linux runner makes aGtkWindowholdingfl_view_new_for_engineon the main view’s engine; the Windows runner a secondWin32Windowhosting a view controller fromFlutterDesktopEngineCreateViewController, which it reaches past the published API — the function looked up by name and the wrapper’s controller handle read under a size guard, both checked at start-up, the pop-out refused where either fails. Each answersopen(titled by the app),fullscreenandclosewith the window’s state — each refusing afullscreenwith no window open asbad_args— and tells the app of the keyboard moving between the two windows and of the window closed (view_id−1), with its reason:asked,window(its own close) ormain_closed. Full screen differs: on Linux the window manager makes the change, which arrives later as its own event (fullscreen,windowed); on Windows it is borderless on the window’s monitor, made at once, and its state is the call’s answer alone. The window’s view leaves its window before the window goes, so that the engine stops drawing into it first; the main window’s close takes the pop-out first, so that the engine’s last window asks the app to exit and the drafts are written. The app’s root (MonitorViews,runWidget) gives the implicit view the app and any other view aMonitorView: the open dock’sScenePlayer—monitorPlayerProvider, set by the dock opened last and cleared as it goes, each after its build — as aPicturePaneon black, the player’s last failure beneath it in the dock’s words (monitor.notice) — a medium that could not be downloaded included —, theTransport, the ring of a download of the scene’s media and the full-screen button beneath, or, with no dock open, a line saying what would fill it; an open the runner fails is said byshowMessagein the main window (the reason inmonitor.call.failed); rebuilt as the engine adds and removes views. Focus is the window’s: Flutter’s focus is the app’s, so the runner’s word that a window took the keyboard is handed to the views (onViewFocusChange), the pop-out’s keys focused only then — or as it is built, where the word came first — and the main view’s when the pop-out closes. Escape’s close is sent after its key’s answer, never from inside it. An exit asked of the app closes the pop-out before the app’s own exit listener runs.
The log: the engine’s own through LoggingEngine (engine.<op>.*, an export’s progress every tenth among them); the exports’ timeline.export.chosen {kind, scene}, timeline.export.pick {kind, scene} then .picked {path, ms}, .cancelled {stage: pick, ms} or .failed {stage: pick, code, cause, ms}; timeline.export.refused {kind, code: exists, file, chosen}; timeline.export.start {kind, scene, revision, path}, an OTIO’s timeline.export.file.start {path} and .file.ok {path, chars, ms} or .file.failed {path, cause, ms} per file, then timeline.export.done {…, file, encoder | files, report, ms}, .cancelled {code, stage, cause, ms} or .failed {stage, code, cause, ms}; a cancel’s timeline.export.cancel.start, then .cancel.ok {ms} or .cancel.failed {code, cause, ms}; the player’s timeline.player.create {scene, engine}, timeline.player.<op>.start {scene, …} then .ok {ms}, .refused or .failed {code, stage, cause, ms} for prepare {revision}, replace {from, to, playing, position_ms}, play, pause {why: person | hidden}, seek {to_ms}, endScrub {to_ms}; timeline.player.scrub.refused {code, stage}; timeline.dock.hidden {scene, page (by name)} and the player’s timeline.player.hidden {scene, playing, play_pending}; timeline.player.stopped {scene, playing, position_ms, duration_ms, at_end, why: engine} where the engine stops by itself — at the end (at_end: true), or answering a seek or a scrub during a play by stopping (at_end: false), as MLT does; a prepare after a failed one carries after_failed (the revision that failed); timeline.player.dispose.start {scene, playing} then timeline.player.dispose {ms} or .failed; timeline.tracks.beats {scene, revision, px_per_second, blocks: {block: {block_id, drawn (the beats drawn, by place), hidden, why: too_narrow, block_px, badge: drawn | hidden | none}}} and timeline.tracks.subtitles of the same shape for the subtitles — the fit decision at the scale shown, logged again where the revision or a decision changes, not at every scale a resize passes through; an edge operation’s timeline.edit.gesture.start {scene, block, block_id, how: drag | double_tap | semantic, frames} and its end timeline.edit.gesture {…, outcome: set | cleared | unchanged | cancelled, frames, ms}; timeline.edit.start {scene, block, reason: drag | semantic | clear, output_duration_sec}, then timeline.edit {scene, block, reason, output_duration_sec: old→new (rule where unset), changed, ms} or timeline.edit.refused {scene, block, reason, code: no_draft | block_gone | refused, problem | cause, ms} — the draft’s write logging itself; timeline.tracks.built {scene, revision, blocks, lanes: {speech, music, effects}, beats: {block: {block_id, laid, past, at_end, total_sec, block_sec, marks: [{beat, description, position, start_sec, length_sec}], not_drawn: [{beat, description, position, start_sec}]}}, subtitles: {block: {block_id, laid, past, at_end, total_sec, block_sec, marks: [{subtitle, text, position, start_sec, length_sec}], not_drawn: [{subtitle, text, position, start_sec, why: past_block_end}]}}, diagnostics, fit_px_per_second, shortest_block_sec, room_px, fit_by: room | shortest_block, ms} once per revision the tracks are drawn for; timeline.zoom.start {scene, how: drag, zoom, max_zoom, px_per_second, anchor_ms} and timeline.zoom {scene, how: drag | double_tap | semantic | layout, from_px_per_second, to_px_per_second, zoom, max_zoom, anchor_ms, ms} at a zoom’s end or a clamp; timeline.view.jump {scene, why: playhead | seek | block, playhead_ms, from_px, to_px} on each jump, timeline.view.kept {scene, why, reason: in_place | in_view | manual, …} where a jump, a tap’s or a seek’s look, or a centring moves nothing, timeline.view.manual {scene, manual, offset_px | how: play | seek | ruler | box} as the person’s own scroll begins and ends, and every scroll they make, timeline.view.scroll.start {scene, from_px, manual} then timeline.view.scroll {scene, from_px, to_px, ms, manual} where it leaves the view; timeline.follow {scene, following, reason: dock_press | block_tap | charge_off, asked} as this dock begins or stops asking (asked the block last asked, where it stops), timeline.follow.block {scene, block, block_id, index, frame, playhead_ms, playing, was} for each block asked, and timeline.block.tap {scene, block, block_id, index, start_ms, playhead_ms}, the asks themselves logged by the seam (script.focus.ask {…, by: timeline | dock}, script.follow); timeline.dock.open {scene, height, editable} as it opens; timeline.dock.resize.start {scene, from, how} as a drag or a tap begins, timeline.dock.resize {scene, from, to, how: drag | tap, clamped, picture_px, ms} at its end (picture_px the picture the new height gives, [w, h], null where narrow), clamped saying that the page’s bounds cut what was asked, or timeline.dock.resize.unchanged {scene, height, how, ms} where nothing changed, or timeline.dock.resize.cancelled {scene, ms}; a change is followed by the setting’s own settings.timelineHeight.start {from, to} and settings.timelineHeight {outcome, code, cause, ms}; timeline.dock.close {scene, ms} as it leaves, ms how long it was open. The pop-out’s: monitor.available.answer {…} or monitor.available.failed {code, message}, then monitor.available {platform, available, reason, ms}; each call’s monitor.call.start {call, args, how} then monitor.call {call, how, ms, …the window's state} or monitor.call.failed {call, how, code, message, ms}, and monitor.open.refused {how, why}, monitor.open.unchanged, monitor.close.unchanged, monitor.fullscreen.unchanged where nothing is asked; every runner event as monitor.window {event: focus | close.start | closed — and on Linux fullscreen | windowed —, platform, backend (Linux: x11 | wayland), view_id, size, fullscreen, focused_view, window, reason, how, stage, ms} — a close logged by the runner itself, whoever asked it: close.start {how: asked | popout_x | main_x, view_id} before its first destructive step, then closed {reason, how, stage: window_destroyed (Linux) | view_destroyed (Windows) | destroyed_unasked, ms}, ms the close’s own time — a destroy no close began how: unasked, ms: -1; monitor.views {views, was} as views come and go; monitor.exit {open, view_id}; the dock’s timeline.monitor.player {scene, event: set | cleared, was}, timeline.monitor.open {scene, how}, timeline.monitor.opened {scene, opened, view_id} and timeline.monitor.close.ask {scene, how}; the window’s timeline.monitor.view {scene, how, view_id, focused_view}, timeline.monitor.view.scene {scene, was, view_id}, timeline.monitor.focus {view_id, how: built | event}, timeline.monitor.key {key, action: play | pause | none | fullscreen | windowed | close, view_id, scene} and timeline.monitor.close {scene, view_id, how: escape | button | exit | window | main_closed, open_ms}, open_ms how long the window was open.
19.16 The engine in the app
Section titled “19.16 The engine in the app”Which engine plays: the build names it. The app’s main installs one factory as timelineEngineProvider (lib/timeline_engine_selection.dart, selectTimelineEngine), so that every engine an opened scene makes is wrapped in its log (LoggingEngine). The engines a build may name are a registry of EngineAdapters, engineAdapters, each with its name, the folder and per-platform file name of its library (libraryDir, libraryFileName, none where the engine has no library there), its factory and its noticeEntries: mlt — MltEngine(texture: const MltTexture()), its frames to the plug-in’s pixel-buffer texture, its library filmopen_mlt.dll beside the executable on Windows and libfilmopen_mlt.so in the bundle’s lib/ on Linux —, mpv — MpvEngine (filmopen_engine_mpv, below), its library libmpv-2.dll beside the executable on Windows and libmpv.so.2 in the bundle’s lib/ on Linux — and fake, FakeEngine, with no library. The build names one in the define FILMOPEN_ENGINE (the wrappers’ --engine, below); unset, it is the platform’s default engine, one build switch, platformDefaultEngines: mlt on Linux and on Windows, android on Android (on Media3, no library file to check), none on macOS, iOS and the web. So a plain flutter run plays through MLT where its library is; the other engine is chosen at build time with the define, never at run time. The selection takes the named adapter only — never the first one installed, never another native engine — and validates its bundle: the library present where the engine opens it, then loadable, opened once and closed (DynamicLibrary.open … close). It is said once at start: engine.select.start {platform, requested} (requested the define’s value, or unset), then engine.selected {engine, requested, platform, library, library_present, reason, ms} (library and library_present only where a library was looked for: never for fake, an unknown name or a platform with no native engine). The outcomes, each with its reason:
- the named engine’s library present and loading: that engine (requested mlt, its library present and loading, or FILMOPEN_ENGINE unset: linux’s default engine mlt, …, the platform named);
fakenamed: the fake, reason requested, no library looked for and no failure;- the web, iOS and macOS, which have no default engine: the fake (no native engine for this platform: scenes play on the fake), nothing looked for, whatever was named;
- the named engine’s library missing, or a check of it that throws:
engine.select.failed {stage: library_check, requested, library, cause, ms}(causenot found, or the error whole) and the fake, its reason <name>‘s library failed its library_check: <cause>, followed where the library is not found by ; this build’s bundle lacks it (the wrappers’ –engine <name> builds it); - present but not loading — a dependency missing, a library of the wrong architecture:
engine.select.failed {stage: library_load, requested, library, cause, ms}, the loader’s error whole (on Windows The specified module could not be found … (error code: 126)), and the fake; - an unknown name:
engine.select.failed {stage: unknown_engine, requested, known, ms}and the fake.
A failed load is not retried, and nothing falls back to another engine. This is the one place that chooses an engine’s backend: the dock, the state and the interface know none. The scene timeline’s probe keeps an engine of its own from the same factory (§19.13). A second engine is its package (the engine, its native build, its notices) and one row of engineAdapters: the selection, the licence page and the agent launches take it as they take MLT, unchanged; scripts/desktop_flutter.py names the engines the wrappers accept and the native build each prepares, its one edit. libmpv is that second engine.
The Android engine (packages/filmopen_engine_android, android) plays through AndroidX Media3, Google’s Jetpack player, on the platform’s own decoders (MediaCodec) and audio output (AudioTrack), with no codec of its own. The clip in a media place and the scene in the dock are the same interface, one engine each, as on the desktop. It does not export: the dock offers no export on Android (§19.15). A video item shorter than its length holds its last frame, as on the desktop (§19.2). Subtitles are drawn by Android’s own text in the bundled Noto fonts, close to libass’s look on the desktop but not identical to the pixel. A scene compiles to a picture sequence without sound — a video item’s own sound, where it is mixed, is a speech lane of its own — and every lane runs the timeline’s whole duration, silent where it has no item; a held last frame is the source’s last frame extracted as a still, and a hold shorter than one frame is none: the item then ends with its file, that fraction of a frame early, since Media3 refuses a clipping end past the duration it is given. Those dropped holds are summed in the picture’s order, and the item at which the sum reaches one frame holds the whole sum from its last frame, so the pictures, which run short, never lead their sound and subtitles by a frame or more, however many such items a scene has; a sum still under a frame at the scene’s end is not held. Each subtitle shows from the nearest frame of its at to the frame before the nearest of at + length, through one overlay on frames scaled to the timeline’s size, the text in the family’s bundled regular face (Noto Sans for a family the bundle lacks), white with a black outline a sixteenth of the size, bottom centre in the same margins as the desktop’s; a missing font file fails the prepare, and a timeline none of whose subtitles has a frame loads no font and puts no overlay on its frames. A failure that stops playback — Media3’s PlaybackException — is the playback failed event (§19.7), which both players show. Its notice (Apache-2.0) is on the licence page.
libmpv, the second engine (packages/filmopen_engine_mpv), plays one explicit-lane EDL through one libmpv player and its texture (the vendored media_kit_video, with FilmOpen’s presentation hook, selected by the root’s dependency_overrides, and media_kit_libs_windows_video on Windows) and exports through its own FFmpeg. It is built from source by scripts/build-mpv-linux.sh and, on Windows, scripts/build-mpv-windows.ps1 with build-mpv-windows.sh under MSYS2 (whose pacman line installs meson, the ffnvcodec headers and libplacebo), into packages/filmopen_engine_mpv/native/<platform>/, never committed; each logs its stages with their times, every source or tool it fetches (git sources pinned by tag; on Linux the release archives of gperf, zlib, libpng and the font libraries checked by SHA-256, NASM’s and FFmpeg’s taken from their versioned URLs without a checksum; on Windows each pinned archive checked by SHA-256) and a failure with its stage, line and exit code, and keeps its own completion stamp (native_stamp.py … --engine mpv: any change to its builders or patches rebuilds its whole native stage). The wrappers take --engine mpv, or FILMOPEN_ENGINE=mpv in the environment where no flag is given. The adapter logs its open, its readiness, each state change and every failure with its stage and the native message as it is, mpv’s own log lines included; its handoffs, wakeups, observed properties and clock ticks are not logged, the play’s summary being the wrapper’s (§19.9). The player keeps the file open at its end and pauses there, so the end of a play is mpv’s eof-reached flag, observed beside the position and the dropped frames: the adapter stops its clock’s playing state, says engine.native.audio {operation: ended, position_us, presented, dropped, underruns}, and its next reading carries the stop to the wrapper’s ended summary and to the dock; a play asked there starts from the beginning, as the fake’s does (engine.native.audio {operation: play, from_us: 0}). MLT is the default on both desktops; libmpv, on Linux and on Windows, is built from source on request and not shipped. macOS has no default engine yet.
A build carries its engine’s libraries. libmpv’s five CMake entries (its plug-in on Linux and Windows, media_kit_video on both, media_kit_libs_windows_video) bundle, link and install libmpv only when the build’s environment names FILMOPEN_ENGINE=mpv — the wrappers pass the engine they chose to every command they run, and the agent launches pass theirs —; any other build, the default included, takes the texture stub and holds none of it, said as a CMake STATUS line. MLT’s two plug-in entries bundle and install MLT only where the environment names mlt or names nothing (the default); a build naming another engine carries none of MLT’s libraries, modules or data, said as not mlt, the build bundles none of MLT. So a checkout with both native layers built makes an MLT bundle without libmpv and an mpv bundle without MLT, and the libraries of the same name — FFmpeg’s, libass, FreeType, HarfBuzz, FriBidi, Fontconfig, libpng, zlib — are the chosen engine’s own copies. After a build the wrapper lists the bundle’s library folder, each file with the engine prefix holding the same bytes, the chosen engine’s first where both hold them (bundle.library {name, bytes, engine, from}, bundle.other for a library of the engine not chosen, bundle.done with the counts). On Windows the runner’s folder keeps what earlier builds put there, so each install begins by removing the DLLs and FFmpeg’s programs beside the executable and MLT’s lib/mlt and share/mlt (bundle.cleared), before any plug-in installs.
The web. filmopen_engine_mlt’s barrel exports the native engine where dart.library.ffi is and, elsewhere, mlt_engine_stub.dart: an MltEngine and MltTexture with the same surface that refuse every operation with unsupported and own nothing, so that the one construction line compiles for the web; the web never chooses it.
The native libraries are built per machine, never committed (packages/filmopen_engine_mlt/native/<platform>/): scripts/build-mlt-linux.sh and scripts/build-mlt-windows.ps1 — an LGPL FFmpeg 7.1.5 (no --enable-gpl, no --enable-nonfree, refused otherwise; on Linux with libopenh264, OpenH264 v2.6.0 built from Cisco’s source, BSD-2-Clause, and with libass for the subtitles filter: libass 0.17.4 (ISC) with Fontconfig as its font provider, over FreeType 2.14.2 (FTL; with PNG and zlib — libpng 1.6.59 and zlib 1.3.1 built and shipped too —, HarfBuzz off), HarfBuzz 12.3.2 (MIT, with its FreeType backend), FriBidi 1.0.16 (LGPL-2.1-or-later) and Fontconfig 2.17.1 (MIT; the system’s expat and /etc/fonts, its install staged so nothing is written there), all shared, from release archives checked by SHA-256 — the releases libmpv’s build ships; gperf, a build tool for Fontconfig, is built from its release where it is missing and never shipped. They ship in the bundle’s lib/, and the plug-in links the app’s executable with --disable-new-dtags, so its $ORIGIN/lib is an ELF RPATH and transitive: GTK’s Pango, which loads FreeType, HarfBuzz and FriBidi before the engine, takes the bundled copies too, not the system’s beside them; Pango needs HarfBuzz’s hb_ft_face_create (without it the app did not start), and colour-emoji glyphs need FreeType’s PNG (without it GTK draws them blank); on every build the build checks each hb_, FT_, Fc, fribidi_, png_ and zlib symbol the system’s Pango, PangoFT2, PangoCairo, Cairo, GTK, GDK, gdk-pixbuf and its loaders, Xft and libxml2 import against the shipped libraries’ exports, at its version where the definer versions its symbols (scripts/font_abi_check.py, font_abi.consumer {path, imports, versioned, missing} — a consumer this system lacks, such as the other Ubuntu’s libxml2 soname, skipped as font_abi.consumer.absent — then .ok or .refused naming each missing symbol), and each shipped library’s own run path, MLT’s modules included, read from the file: one that needs a library of the prefix has $ORIGIN, and none has a relative path that is not $ORIGIN (scripts/runpath_check.py, runpath.library {library, run_path, needs, decision, reason}; libpng is built with CMake, since through configure and libtool $ORIGIN did not survive). The build refuses an FFmpeg without CONFIG_LIBASS, a libass without Fontconfig, an MLT whose melt -query filters lists no avfilter.subtitles, and any of these libraries resolving outside its prefix, judged under the bundle’s search path as the app’s transitive RPATH gives it), on Windows with libass too: MSYS2’s libass 0.17.5 and its DLL closure — FreeType, HarfBuzz, FriBidi, Fontconfig, libpng, libunibreak, Brotli, bzip2, Expat, GLib, libintl, PCRE2 and Graphite2, which all ship, since Windows has no GTK holding them —, the build refusing an FFmpeg without CONFIG_LIBASS or CONFIG_SUBTITLES_FILTER, a melt whose -query filters lists no avfilter.subtitles, a prefix missing one of those DLLs, and any import of the prefix’s DLLs and programs, MLT’s modules included, that is neither in the prefix nor an API set nor in Windows’ System32 (mlt.build.runtime_dlls.checked), MLT v7.42.0 with GPL and GPL3 off and the core, avformat, xml and sdl2 modules, the five patches of packages/filmopen_engine_mlt/patches/, the C shim, and SDL2. The Windows builder’s smoke encode follows the export’s rule: each GPU encoder its FFmpeg has, then h264_mf in software with NV12 (FILMOPEN_ENCODER=software tries no GPU encoder), the first that runs a 5 s 1080p encode being its encoder; each attempt is logged (mlt.build.smoke.attempt {encoder, mode, arguments, ran, probe, cause, ms, mft, hardware_mft}), then smoke.chosen {encoder, mode, reason, tried}; its file must be 120 H.264 frames, melt’s export smoke uses the same encoder, and the build fails only when no encoder runs (smoke.no_encoder) or the one that ran made something else. melt’s play smoke goes to SDL’s sound; where SDL cannot open audio or start its driver (SDL failed to open audio, Failed to initialize SDL — a machine without a sound device), it plays again on SDL_AUDIODRIVER=dummy, which paces the play without a device, and says so: mlt.build.melt.audio_dummy {said, driver_before, first_play, first_play_file}, each play said as its exit code, milliseconds and melt’s last position, and melt.played {play, audio_driver} for the last, whether the smoke passed or failed. The plug-in’s CMake installs them into the app’s bundle (Linux: lib/ with mlt-7/ and mlt-7-data/; Windows: beside the executable with lib/mlt and share/mlt); a build of the app without them still builds, the CMake warning run scripts/build-mlt-linux.sh (on Windows, run scripts/build-mlt-windows.ps1), and where it names mlt — or names nothing — plays scenes on the fake, said as engine.select.failed {stage: library_check, …} and engine.selected {engine: fake, …, library_present: false, reason: "mlt's library failed its library_check: not found; this build's bundle lacks it (…)", ms}. The plug-in’s CMake bundles MLT where its native libraries are built and the build names mlt or nothing; a build that names fake or mpv holds none of MLT (above). A fresh checkout holds no native layer: its first build or run through a wrapper builds the chosen engine’s once, from the network (the builders fetch their pinned sources; some minutes), and each later one skips it by the stamp (below); a plain flutter build bundles what the checkout has and, where it has none, warns and builds without the engine (above). A build machine’s setup proves its toolchain once: on Linux scripts/agent-vps.sh, whose stage engines readies both engines’ native layers (native_stamp.py ready linux, then with --engine mpv), each command logged with its exit code and a failure stopping the setup with the stage’s name; on Windows docs/FilmOpen-Windows-Setup.md, which ends with one wrapper build per engine.
The end in MLT. The shim sees the timeline’s last frame shown by the play under way (never one kept from the play before) and queues the play’s stop for its worker, which stops it as a pause does — the consumer is never stopped from its own thread: engine.native.present {code: end_reached, frame, frames, decision: stop} as it is seen, then engine.native.audio {op: ended, frame, position_ms, playing: false, reason, ms} once the play stopped, one per play; a stop that a seek or a pause made first is engine.native.present {code: end_not_playing} and does nothing. libmpv says its end as engine.native.audio {operation: ended}, the fake stops without a native line; on each, the clock’s next reading ends the play’s summary ended and the player returns to Play (timeline.player.stopped {at_end: true, why: engine}).
No sound output in MLT: the picture plays at once, silent. Before an engine’s first play the shim asks SDL whether a sound output exists — SDL_InitSubSystem(SDL_INIT_AUDIO), SDL_GetNumAudioDevices(0), SDL_QuitSubSystem — once per process, the answer kept: after a play on the dummy driver MLT has set SDL_AUDIODRIVER=dummy for the process, and SDL asked again would count the dummy’s one output. Where SDL counts none, the consumer is given audio_driver: dummy, SDL’s dummy driver paces the picture from Play on and nothing is heard; on SDL’s default driver such a machine would wait for one (on Windows WASAPI’s 8 s, twice: MLT’s fallback tries it again) and play late. Each engine says it at its first play: engine.native.audio {op: device, outcome: start, driver_asked, source} (source sdl, kept, or test under the shim’s test seam), then {outcome: info, code: no_audio_device, driver: dummy, probed, devices: 0, source, reason, ms} — the event the dock’s silent-playback notice follows (§19.15) — or {outcome: info, code: audio_device, driver, probed, devices, source, reason, ms} where one is there (devices: -1, a driver that cannot count, leaves SDL’s own choice). Neither answers the play: only the consumer’s own start does, {op: start, audio_driver} before it. SDL’s audio that does not initialise is {op: device, outcome: failed, code: audio_init_failed, cause, driver_asked, reason, ms}, which fails the play with nothing started; the next play asks again. The shim links SDL2, which MLT’s sdl2 module already ships.
Subtitles in MLT. Where a compile — a prepare, a replace, an export — has subtitles, the engine writes assOf(timeline) to a file of its own, r<revision>-<n>.ass, in a folder of the engine’s — on Linux in the user’s runtime folder (XDG_RUNTIME_DIR: the user’s alone, emptied at logout) where the session has one, else in the system’s temporary folder — (made at its first file, made 700 on Linux and macOS before any text is in it — Dart makes it by the umask —, engine.subtitles.folder {dir, base, mode}; deleted at the dispose, after every export has ended, engine.subtitles.delete.start {dir, files} then .ok {dir, ms} or .failed {dir, cause, ms}, which the dispose survives; the folder’s name carries the process’s id, filmopen-mlt-subtitles-<pid>-…, and since an app that quits disposes no engine, on Linux and Windows an engine making its first folder deletes those whose process has ended — no /proc/<pid> on Linux, not among the processes tasklist lists on Windows (on Windows beside the compile, since tasklist takes some 400 ms; what it answered is engine.subtitles.sweep.processes {source, exit, stdout, stderr, pids, count, ms}); a name whose number is no pid is kept —: engine.subtitles.sweep.start {base, processes}, each folder kept .sweep.kept {dir, pid, reason} (no pid, this process, or its process running), each deleted .sweep.deleted {dir, pid, reason}, each that would not delete .sweep.failed {stage: delete, dir, pid, cause, ms}, then .sweep.ok {base, scanned, deleted, kept, ms}; any other failure — a tasklist that does not start or fails (stage: tasklist), an answer that does not list this process (parse), a base that cannot be listed (list), anything else while the folders are judged (folders) — deletes nothing more and ends the sweep in .sweep.failed {base, stage, cause, ms}, since nothing awaits it; in the runtime folder every such folder is this user’s, and a process in another PID namespace sharing it would look ended), and the tractor carries one filter after the mix: avfilter.subtitles, FFmpeg’s subtitles filter through avformat’s wrapper, its av.filename the file and av.fontsdir the fonts folder, handed to FFmpeg’s options as they are (no filtergraph parses them, so a path’s spaces, apostrophes, commas and brackets need nothing but XML’s escaping). Playback and export take the picture from the same tractor, so both burn the same frames, the wrapper reading the frame’s original position, which on the tractor is the timeline’s frame. A timeline without subtitles writes no file and has no filter; the compile’s event says subtitle_filter, subtitles and ass. The fonts are the built app’s data/flutter_assets/assets/fonts, beside the executable on Linux and Windows (MltEngine.fontsDir; a harness names its own), read at the first timeline with subtitles and said once, engine.subtitles.fonts {dir, files}; a folder that is missing or holds no .ttf or .otf fails the compile with no_fonts: the bundled fonts are the project’s default, and an app without them is broken. Each write says subtitle.font {asked, bundled, reason} — bundled where the family is one of the app’s, Noto Sans or Noto Serif, compared regardless of case as libass compares; another is left to libass, which looks it up through Fontconfig among the system’s fonts (an unknown family takes Fontconfig’s default face); on Windows MSYS2’s libass takes DirectWrite as its provider before the Fontconfig it links (Using font provider directwrite (with GDI)), and an unknown family takes the system’s default face there (Arial), the event’s reason naming the provider of the platform. On Windows the fonts folder is a drive-letter path with backslashes, handed to av.fontsdir as it is, as is the ASS file’s: a folder named with an apostrophe, a comma or a space needs nothing but XML’s escaping there too. libass’s own word — the files it loaded, the face it chose per family (fontselect: (Noto Serif, 400, 0) -> NotoSerif-Regular, …), a family it found nothing for (fontselect: failed to find any fallback …), a file it could not read as a font (the folder’s OFL.txt) — reaches the log through the shim’s FFmpeg log callback: every line of the subtitles filter at FFmpeg’s info level or above is an event engine.native.subtitles {service: avfilter.subtitles, level, cause} (code native_info, native_warning or native_error by its level), FFmpeg still printing it as before.
Readiness by a completion stamp (scripts/native_stamp.py; --engine mpv reads and writes libmpv’s own, above): native/<platform>/stamp.json holds a SHA-256 of each input — the builder script (its versions and configuration flags are in it), the patches by name and content, the shim’s sources. A builder clears the stamp before its first stage and writes it as its last act, after every stage succeeded, so a build that fails anywhere — after the shim exists included — leaves none. On Linux what changed since the stamp is rebuilt from clean: the script, everything; a patch, MLT and the shim; the shim’s sources, the shim (compiled every run, a second); no stamp, everything. On Windows the builder’s own per-stage keys choose what rebuilds. The app’s wrappers — scripts/build-linux.sh, run-linux.sh, and on Windows build-windows.cmd/.ps1/.sh and run-windows.cmd/.ps1/.sh — find the keys’ file (dev-keys, handed on as FILMOPEN_KEYS_FLAG) and pass every argument, as given, to one command, scripts/desktop_flutter.py <linux|windows> <build|run>. It takes --engine mlt|mpv|fake (also --engine=<name>; where none is given, FILMOPEN_ENGINE from the environment, else mlt) and, on linux run, --trace <folder> (below), and no other argument, so a --dart-define=KEY=VALUE reaches Flutter whole; it stages the plug-ins, readies the chosen engine’s native build (native_stamp.py ready <platform>, with --engine mpv for libmpv; fake builds none), and runs flutter build <platform> or flutter run -d <platform> with the build’s moment and commit, the keys’ file, --dart-define=FILMOPEN_ENGINE=<name> and the other arguments, every command with FILMOPEN_ENGINE=<name> in its environment, which the plug-ins’ CMake reads (above); after a build it lists the bundle (bundle.library, bundle.other, bundle.done, above). Each step is said on stderr under the wrapper’s name (a staging that fails as stage exit <code>): first engine.route requested=<value|unset> chosen=<name> source=<flag|env|default> native=<prepared|skipped> reason="…", then native start and native exit <code> after <seconds>s, keys found|none, command <the Flutter command, every argument quoted as a shell would> — the keys as the path of their file (--dart-define-from-file=…), never its contents — and flutter exit <code>, and last engine.route.done chosen=<name> code=<exit> seconds=<elapsed> — after an interrupt (Ctrl+C) code=130 … outcome=interrupted stage=<stage|native|flutter>, exit 130; another engine is refused before anything runs, engine.route.refused value=<value> allowed=mlt|mpv|fake code=2 seconds=<elapsed> and a usage line, exit 2. The Android wrappers take no --engine: Android plays on its own engine, android. The agent launches — filmopen_mcp’s launch, from its command line or as the MCP tool, and so agent_walk.py and replay_tape.py — take the engine from FILMOPEN_ENGINE in their environment, passed as the define when set and nothing when not (AppLauncher.commandFor, said on stderr as launch.engine {platform, requested, source: env|unset, define, ms}); scripts/agent-run.sh does the same, says engine.route … source=<env|unset> native=skipped, and builds no native layer. The native build is native_stamp.py ready <platform>: it logs native.start, native.stamp {engine, platform, path, present, matches, rebuilds, current, stored, changed, reason, exit, ms} — for either engine the stamp’s path, the inputs it holds and the tree’s, the inputs that differ and why it rebuilds, MLT’s rebuilds naming the components — and native.decision {build|skip, reason}, runs the builder only where the stamp is absent or differs, and ends native.done {built, ms} or native.failed {stage, exit, ms}, the builder’s own lines naming the stage that failed; a failed native build stops the command before Flutter starts, with the builder’s exit code. A plain flutter build linux or flutter run -d linux does not ready the native build.
A run’s threads, traced (scripts/run-linux.sh --trace <folder>): on linux run alone, desktop_flutter.py makes the folder (a relative one is the repository’s, where the wrappers run) and starts scripts/thread_watch.py --exe <the bundle's binary for the run's mode and the machine's architecture> <folder>/threads.jsonl beside flutter run. The watcher waits for the newest process running that binary, then appends once a second one JSON line per thread of it — {at, pid, tid, name, main, state, cpu}, cpu the percent of one core since the last reading over the time that really passed, a thread born since counted from nothing — read from /proc/<pid>/task/*/stat with no privilege and no threshold, and ends with the process, or on Ctrl+C with reason=interrupted code=130, its lines kept. When the run ends the wrapper gives the watcher 3 s to end with the app, then ends it, and says trace end folder=<folder> watcher=ended|terminated code=<its exit>; it says trace start folder=<folder> file=<file> exe=<exe> period=1s before. The watcher says thread_watch: wait|found|start|end … on stderr, and ends with exit 1 for a process that does not exist (refused … reason=noSuchProcess) or a file it cannot open or write (failed pid stage=open|write file error ms code=1). A watcher that does not end 0 — one that failed, or was ended still waiting — fails the command: the wrapper says trace.failed stage=watch file watcher watcherCode ms code=1 reason (appNotFound for a watcher ended still waiting) after Flutter’s exit and answers 1 where Flutter answered 0. --trace elsewhere, without its folder, followed by a flag, or with a folder that cannot be made is trace.refused platform=<p> verb=<v> folder=<folder|missing> code=2, nothing run. --trace never reaches Flutter. On Linux the GTK main loop and the Dart UI isolate share the main thread (main: true), so a main thread busy while the VM’s CPU sampler finds Dart idle is native work.
Licences. FFmpeg and MLT are LGPL-2.1-or-later and linked dynamically from the application’s folder, so each can be replaced; SDL2 is zlib; on Linux OpenH264 is BSD-2-Clause, its notice verbatim, and the subtitles’ libass is ISC, FreeType the FTL (its BDF and PCF drivers and hash code under X-style licences, each verbatim), HarfBuzz MIT, FriBidi LGPL-2.1-or-later (linked dynamically), Fontconfig MIT, libpng the PNG Reference Library License and zlib the zlib licence; on Windows the UCRT64 runtime libraries beside them carry their own (the GCC Runtime Library Exception, LGPL, MIT, zlib, BSD), and so do MSYS2’s subtitles’ libraries (libass ISC, FreeType the FTL with the same X-style parts, HarfBuzz MIT, FriBidi, GLib, libintl and Graphite2 LGPL-2.1-or-later, Fontconfig, Brotli and Expat MIT, libpng its own, libunibreak zlib, bzip2 its own, PCRE2 BSD-3-Clause with its exception). Their notices — the versions, the configuration, the patches and the offer of the complete corresponding source — are third_party/mlt-engine/, shown under FFmpeg, MLT and SDL2 (on Linux also OpenH264, libass, FreeType, HarfBuzz, GNU FriBidi, Fontconfig, libpng and zlib, on Windows GCC runtime libraries and the subtitles’ libraries above by name, from their *-windows.txt, each from its MSYS2 package’s licence files) in the licence page where MLT’s library is in the bundle — the notices follow what ships, not what the build selected, so a build naming fake beside MLT’s libraries lists them too —, and summarised in THIRD_PARTY_NOTICES.md. The page asks every adapter whose library is present (shippedEngineAdapters, the selection’s presence check) for its noticeEntries, each a notice’s packages, its text’s asset and, for the LGPL ones, the licence appended; an adapter may group them per platform. Reading them is said: licences.engine.start {adapters_present, platform}, then per adapter licences.engine {adapter, entries, packages, ms}, and at the end licences.engine.done {adapters, entries, ms} — with reason: no engine library in this bundle where none ships —, and a notice that does not load licences.engine.failed {adapter, asset, cause, ms} before the page’s error goes on as before.
In a bundle that carries libmpv, the licence page lists its notices (third_party/mpv-engine/): mpv and libplacebo (and on Linux its FFmpeg) under the LGPL-2.1, libass, on Linux the subtitles’ font libraries (SUBTITLE-LICENSES.txt), OpenH264 (packages/filmopen_engine_mpv/OPENH264-LICENSE.txt), and on Windows its FFmpeg under the LGPL-3.0, the GPL-3.0’s text under FFmpeg and GCC’s runtime libraries alone (the LGPL-3.0’s incorporated terms and the runtime exception it carries), and the runtime DLLs’ inventory (WINDOWS-NOTICES.txt); a bundle without it lists none of them.