Commit Graph
3483 Commits
Author SHA1 Message Date
Dylan M. Taylor f0bc6d6fcf engine: hold each clip once in Frame Hold Options and stop Project Manager folder walks following links 2026-11-03 23:48:26 +00:00
Dylan M. Taylor 57dc82ea20 project: bound the speaker index a transcript word may carry (#4140)
Transcript::normalize sized the speaker label table from the largest word
speaker index, so one word with a u32::MAX index made transcript.set allocate
billions of labels before check() could run. Indices at or past MAX_SPEAKERS
(1024) now lose their speaker instead.
2026-11-03 06:02:57 -05:00
Dylan M. Taylor b37811ec53 edit: look up higher-track transcript coverage by binary search (#4123) 2026-11-03 06:02:49 -05:00
Dylan M. Taylor 3a2ef6fe90 dng: budget SubIFD references before reading and queueing them (#4108) 2026-11-03 06:02:41 -05:00
32749e34f6 macOS: keep AppKit's Touch Bar observers when AccessKit drops (no crash on quit) (#4002)
* macOS: keep AppKit's Touch Bar observers when AccessKit drops (no crash on quit)

accesskit_macos 0.27.1 reset the window's view to WinitView when its adapter
dropped, discarding the key-value observation AppKit's Touch Bar finder had put
on the view's nextResponder. On quit, AppKit's removal of that observer raised
and _crashOnException: ended FilmCraft with SIGILL on Touch Bar Macs. 0.27.2
resets the class only while it is still AccessKit's own subclass.

Lockfile-only bump (accesskit 0.25.2, accesskit_consumer 0.39.2, accesskit_macos
0.27.2), with a test that fails below 0.27.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5nRpBjuSq2YBDMKU2Spqs

* filmcraft: accesskit lock test in the it binary; leave tempfile's getrandom as it was

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:10:12 -05:00
19fb3366ce fix(timeline): the Mix row's level sets the Mix fader (#3765) (#3954)
* fix(timeline): the Mix row's level sets the Mix fader (#3765)

The Timeline's Mix track header painted the master level as hot text but
nothing could change it, so the Mix volume seemed to do nothing. It is now a
hot number, as on an audio track: drag or type to set the Mix fader. It
shows the fader at the playhead (its automation, or a control held in the
Audio Track Mixer) and changes it through mixer.setValue, which writes a
keyframe where the Mix fader is automated.

mixer.setValue takes `merge` / `begin`, as clipMixer.set does, so a drag
is one undo step; a typed value is its own. Without `merge` nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mixer): a merged Mix drag stays in its own sequence (#3765)

The Mix strip has the same id in every sequence, so the merge key of
mixer.setValue now names the active sequence too. Otherwise one undo
could revert Mix drags in two sequences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(timeline): drag the Mix level as a held gesture (#3765)

During playback an automation pass runs, and mixer.setValue touches and
releases the control on every call, so each drag update of the Timeline's
Mix level was its own undo step (and in Touch the return started from the
last update). The field now does what the Audio Track Mixer's fader does:
mixer.touch while dragging and mixer.release when the drag ends; a typed
value still goes through mixer.setValue. Outside playback the release
commits the value once, so the merge/begin params of mixer.setValue are
no longer needed and are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(timeline): the Touch return starts at the Mix drag's release (#3765)

The drag test helper calls `between` once more before the release, so the
playhead moves after the last drag update; the Touch test checks that the
keyframe at the release time holds the released value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* timeline: empty_state keeps its doc comment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:10:07 -05:00
95758658bb gpu: the off-screen instance reads wgpu's flag variables (#4084) (#4087)
* gpu: the off-screen instance reads wgpu's flag variables (#4084)

native_instance() applies with_env() to its flags and backend options, as the window's instance does, so
WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1 lets GPU exports use Mesa's Dozen on WSL2, and
WGPU_VALIDATION, WGPU_DX12_COMPILER and the others apply to them. Its backends are unchanged: no GL on
Windows (#3418), and ExportRenderer::new still narrows the adapters by WGPU_BACKEND.

The playback bench example reads the same variables now, and WGPU_POWER_PREF when set.

* bench: keep the default adapter options, not WGPU_POWER_PREF

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:10:01 -05:00
Tex JerniganandClaude Opus 5.5 d781e09cfd fix(ui): shortcuts need exactly their modifiers (#4016)
The key loop matched bindings with egui's consume_key / matches_logically,
which ignores extra Shift and Alt, and on a Mac also an extra Control, so
Ctrl+Opt+Cmd+N ran File > New > Project (Cmd+Opt+N) and Cmd+Shift+N ran
New Sequence. The native macOS menu bar matches key equivalents exactly
(checked against NSMenu), so these presses reached the egui bindings.

menus::chord_held now requires the held modifiers to be exactly the
chord's: Cmd and Control are two keys on a Mac and one elsewhere (#245);
Shift and Alt are exact on letters and named keys, while punctuation and
digits keep the Shift/Alt a layout needs to type them (#2253, Shift+; as
`:`). Most-specific-first ordering, panel scoping and key-repeat handling
are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:09:56 -05:00
JohnandClaude Opus 5.5 26bcdbb9d7 fix(ui): Remove Attributes says that ticked attributes are removed (#3789) (#4003)
* fix(ui): Remove Attributes says that ticked attributes are removed (#3789)

The report's effect stayed because it was unticked in the dialog to turn
it off; a ticked box means "remove". The dialog now says so above the
attributes. The test covers the hint, an unticked effect being kept, and
an effect offered ticked and removed with the defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): Paste / Remove Attributes offer only the effects they act on (#3789)

The dialogs listed Essential Sound's effects (Dialogue's noise reduction,
EQ…), which the commands keep, so a ticked box promised a removal that
did not happen. The engine's rule is now public as
clip_ops::is_attribute_effect and the dialogs use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:09:51 -05:00
Kathir DevandClaude Opus 5.5 281b3f4c1d fix(#3653): Timeline clicking it brings the Program monitor to the front of its group (#4012)
* Timeline: clicking it brings the Program monitor to the front of its group (#3653)

When the Program monitor shares a tab group with other panels and another
tab is in front, a click on the Timeline (its body or its header) brings
Program forward, as in Premiere; the focus stays on the Timeline. Program
is left alone when it is floating, closed, already in front, in a collapsed
group, accordion or icon-only dock column, or in the Timeline's own group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timeline: test that Program is not fronted from a collapsed group (#3653)

A unit test covers tab_group's skips (minimized group, icon-only dock
column, accordion), so a Timeline click never expands what the user
collapsed. Node is imported with the other docking types, and the doc
comment names the collapsed cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:09:47 -05:00
IvanovichandClaude Opus 5.5 d5b2e787b1 fix(timeline): a marquee takes only the track rows its own pane shows (#2978) (#4118)
#2533 made a press hit only the rows of its own pane, but the marquee still
tested each row's full rect. A video row scrolled down past the divider lies
under the audio tracks, so a box drawn there also selected the hidden video
clips. Layout::shown gives the part of a row its pane shows; row_at and
marquee_clips both use it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:09:41 -05:00
IvanovichandClaude Opus 5.5 eec7df93b3 gpu: export draws Push transitions on the GPU (#4122)
plan_frame plans a Push as a GPU composite (#308) and is_cpu_frame gives
such a frame a pooled renderer, but render_export_prepared and
ExportRenderer::render both refused every composite, so each frame inside
a Push went back to the CPU renderer. plan_frame composites only
transitions the GPU draws, so the export path now runs composites like
layer plans.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:09:35 -05:00
Jonathan Cardoso 6fde41a816 audio-dsp: the full-rate sync peak sets the polarity, not the coarse one (#4119) (#4120)
find_offset took `inverted` from the sign of the coarse peak, and the
refine then searched only peaks of that sign. When one recording's
phase is turned (a band-limited, echoey scratch track), the coarse
peak and the opposite-signed lobe beside it come out nearly equal; when
the lobe won, sync reported inverted: true and the lag of the lobe,
about 20 samples early. The refine now takes the highest full-rate peak
of either sign, which sets both the lag and the polarity.
2026-11-03 00:09:28 -05:00
Jonathan Cardoso aedb582eb9 fix(render): an opaque 8-bit still becomes the canvas, not a region over a zero-filled one (#4070)
An RGB PNG or a JPEG decodes to RGBA8 with alpha 255 everywhere, but only a frame with no alpha
plane counted as opaque, so each frame of a still was converted to a full-size region and mixed
over a zero-filled canvas: two 33 MB float images at 1080p instead of one, for every frame an
export renders at once. A region that is the whole picture with every alpha exactly 1 is now an
opaque full layer, which the compositor takes as the canvas. The output is the same bits.
2026-11-03 00:09:23 -05:00
Jonathan CardosoandBrandon Thomas afdb8019c1 media: say which colour values no tag gave (#4086) (#4089)
With no colour tags, FilmCraft decodes with BT.709 transfer and
primaries and the matrix by frame size, and probe and media.colorInfo
showed those values with nothing saying they were defaults, so an
untagged file looked the same as one tagged BT.709.

VideoStreamInfo gets color_assumed (optional, left out when unknown):
which of the matrix, transfer and primaries no tag in the file gave.
MP4/MOV and Matroska take it from the same sources as the colour, APV
from its colour description, and AVI, MXF and Y4M mark all three.
probe prints it as video.color_assumed and media.colorInfo as assumed,
next to the unchanged metadata.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:04:13 -05:00
Jonathan Cardoso 93ee912c30 #626: unsupported H.264 fails as unsupported at any slice QP (#3959)
The slice QP check uses the 8-bit range (#2846), so a 10-bit slice with a
QP below 0 failed SliceHeader::parse as invalid before handle_slice got to
Sps::check_supported. A frame of a stream we can't decode then came out as
a decode error, the unreadable slate from #762 never showed, and render and
export drew black and exited 0. The support check now runs in the PPS/SPS
lookup, before any header field that depends on the SPS.
2026-11-03 00:03:46 -05:00
Jonathan Cardoso 92939f71bf #3948: Time interpolation applies when a clip's frame rate differs from the sequence's (#3949)
* #3948: time interpolation applies when a clip's frame rate differs from the sequence's

A 24 fps clip in a 29.97 sequence at 100 % rendered the same with Optical
Flow or Frame Blending as with Frame Sampling, repeating one frame in five:
interpolation_blend gave up whenever the speed was 100 %. It now gives up
at 100 % only when the media's frame rate matches the sequence's, so it
takes the sequence rate too. Clips at the sequence's own rate still never
fetch a second frame.

* #3948: a still image gets no in-between frame

A still reports 29.97 fps and returns the same picture at every time, so
in a sequence at another rate it now passed the frame rate check: Optical
Flow ran on two copies of one frame, and the GPU plan fell back to the CPU
for nothing. media_interpolation skips still sources, and the item layer,
media_image and the GPU plan all ask it.

* #3948: an in-between frame never comes from past the clip's source out

A reversed clip starts 1 tick before its source out, so on a source frame
boundary interpolation_blend paired its last frame with the frame at the
source out, at a weight near 1: the first frame showed media the trim had
cut. A forward clip could end the same way (23.976 on 29.97, 5 frames
ending on a source frame blended in 20 % of the next one). Speed changes
had this already; rate conforms at 100 % now take the same path. When the
later frame starts at or past the source out, the clip shows its last
frame alone. The later frame always starts after the source in, so the
other end needs nothing.
2026-11-03 00:03:41 -05:00
Jonathan Cardoso f4f3dd5eff engine: timeline.move reads a time object and names an entry it cannot use (#4113) (#4116)
A moves[] entry gave its time only as integer ticks. Any other form, or
a missing clip or track, dropped the entry: alone it failed with "need
`moves`", next to other entries only those moved and the call said ok.
`time` now goes through tick_or_time_p like timeline.place's sourceIn,
and an entry without a usable clip, track or time is refused by its
index before anything moves.
2026-11-03 00:03:35 -05:00
Jonathan Cardoso 96d8e22c2d Image-sequence export reports the stills it wrote, not the path it was given (#4018)
* fix(export): an image-sequence export reports the stills it wrote

An export to `a.png` as an image sequence writes `a000.png`, `a001.png` and so on, but the
job's result, `file.exportMedia`'s `path` and the CLI's "exported" line all named `a.png`,
which does not exist.

The report now names the first still, the name `file.import` and `probe --image-sequence`
take for a sequence. `file.exportMedia` and `export.quick` name it as `path` up front, from
the frame count `export.resolve` uses, and add `lastPath`. The CLI prints both. A single
file keeps its path.

Fixes #4009

* fix(automation): a cancelled image-sequence export is cleaned up by its stills' name

Since `file.exportMedia` names an image sequence's first still as `path`, the MCP cancel
clean-up took `a000` as the stem: it left `a001.png` and every later still, and the `a.srt`
sidecar, behind. The result now also carries `basePath`, the name the stills are numbered
from, and the clean-up goes by it.

* fix(automation): an image-sequence cancel clean-up keeps the base name and finds .jpeg stills

An image-sequence export numbers every still, a single frame included, so a file under its base
name (`a.png`) is never its own: cancelling one deleted a user's `a.png` if it was saved during
the export. The stills of a `.jpeg` sequence were left behind instead, since the extension was
read as a format name, and `jpeg` names Motion-JPEG there; `file.exportMedia` keeps `.jpeg`
for a JPEG sequence.

* fix(automation): a cancelled image sequence removes only the stills it numbered

The clean-up matched any `<stem><3+ digits>.<ext>` written during the export, so cancelling an
export to `a.jpeg` could delete `a1000.jpeg`, the first still of an export to `a1.jpeg` in the
same folder. It now takes the digits and the last index from the result's `lastPath` and removes
only those names: `a000.jpeg` to `a120.jpeg` for a 121-frame export.
2026-11-03 00:03:30 -05:00
Jonathan Cardoso 258e4c45fe engine: the Metadata panel's Out Point is the last frame kept (#4114) (#4117)
metadata_fields subtracted a frame from mark_out, so the Out Point read
one frame before the Out mark. Everywhere else mark_out is the start of
the last frame kept: source_range adds a frame to get the end, and the
Project panel's Video Out Point shows it as is.
2026-11-03 00:03:26 -05:00
Jonathan Cardoso 81f6bac4c6 project: ducking.against takes the type names setType takes (#4039) (#4041)
essentialSound.setType takes dialogue, music, sfx and ambience, but
ducking.against went through serde, which knows only the labels
(Dialogue, SFX...), so the name setType had just taken was refused.
ducking.against now reads either spelling with AudioType::parse and
still refuses names that are not a type.
2026-11-03 00:03:21 -05:00
Jonathan Cardoso 1a864d1eae Import by a relative path: '..' climbs out of the document folder, and media paths are resolved once (#3998, #4001) (#4004)
* interchange: '..' climbs out of a relative document folder (#3998)

join popped a '..' only while the base had more than one part, which kept
an absolute path at its root but also kept a relative folder's first part:
`edit` + `../media/a.mov` gave `edit/media/a.mov`. A relative base now loses
any folder to '..' and keeps a leading '..' past its start; an absolute base
still stops at its root.

* engine: a document given by a relative path has its media resolved once (#4001)

interchange::import passed the document's folder as given, so a document
opened by a relative path gave media paths relative to the working directory,
and MediaPool::follow then took them from the document's folder a second time
(edit/edit/sub/a.mov). The folder now comes from the document's absolute path,
as doc_path::resolve already takes it.

* interchange: a '..' after a base that ends in '..' is kept (#3998)

std::path::absolute keeps '..' on POSIX, so a document opened as
`edit/sub/../cut.xml` (or `../cut.xml`) gives a base that ends in '..'.
join treated that as the root of an absolute path and dropped the media
path's own '..', looking for `edit/sub/../media/b.mov`. Now only a real
root (`/`, `C:`) absorbs a '..'.

* interchange: a document folder's own '..' is resolved before its media paths (#3998)

Keeping a '..' after a base that ends in '..' let a media path climb above
the root: importing ../cut.xml from /work gave /work/../../media/b.mov,
which craft-docpath refuses. join now resolves the base's own '.' and '..'
first with the same rule as the media path's (take off the last folder,
keep it past the start of a relative path, drop it at an absolute root), so
both /work/edit/.. and /work/.. with ../media/b.mov give /media/b.mov.

* interchange: join reads a folder's root as craft-docpath does, and drops empty names (#3998)

join counted one root part, so `..` could take the share off
`\\srv\share\..\edit`, and it kept empty names, so `./` + `clip.mov` gave
`/clip.mov` and a repeated separator gave `..` an empty name to remove.
The folder's root is now read as craft-docpath reads it (none, `/`, `C:\`,
drive-relative `C:`, `\\host\share`, `//host/share`, `\\?\C:`,
`\\?\UNC\host\share`). Below it, empty names and `.` are dropped and `..`
takes off the last name, is dropped at an absolute root and kept past the
start of a relative folder. One table-driven test covers the path forms.
2026-11-03 00:03:06 -05:00
Jonathan Cardoso 8841e02b2a cli: a missing project stops a save in place before it opens (#3946)
* cli: a missing project stops a save in place before it opens (#3944)

The save lock was taken only when --project was already a file. A project
created after that check but before Backend::open read it was opened and
saved with no lock, so a concurrent --save run could lose its edits.

A save in place on a missing project now stops at the check, with the same
error Backend::open gives. Runs that don't save over --project still leave
that error to Backend::open, and --bridge with --project stays a usage
error.

* cli: a missing project spelt two ways is still saved in place (#3944)

`--project p.fcproj --save-as ./p.fcproj` names one file, but when it
doesn't exist yet `canonicalize` fails on both spellings, so the run wasn't
seen as saving in place and skipped the missing-file check. Paths that
don't exist are now compared by their resolved folder and file name.
Existing files are compared as before. The error names `--project`, as
`Backend::open`'s does.

* cli: check that --project exists before comparing save paths (#3944)

Two findings on #3946 were the same race reached through other spellings
of a missing path: `./p.fcproj`, then `newdir/./p.fcproj` in a folder that
doesn't exist either. Each was a run that compared the paths, found no
match, and went on to open a project that wasn't there.

`saved_in_place` now checks `--project` first. A missing one stops the run
with the error `Backend::open` gave, whatever the run saves. Only existing
paths are compared after that, where `canonicalize` is exact, so the folder
fallback from the last commit goes.
2026-11-03 00:03:01 -05:00
scks1andClaude Opus 5.5 8e81d41567 Name Apple Intermediate Codec and show why a clip is unreadable (part of #3646) (#4010)
* media: name Apple Intermediate Codec and say why a clip is unreadable

Part of #3646. A MOV in Apple Intermediate Codec (fourcc icod, the
editing codec of iMovie and Final Cut) imported with its codec named
"unknown", and its frames failed with "no decoder for unknown video".
Only the log showed that error: the Project panel's badge said
"Unreadable" and nothing more.

- codecs: codec_label names icod "Apple Intermediate Codec". The
  "no decoder" errors of make_video_decoder and software_video_decoder
  use that label for sample entries the parser keeps as Unknown, so
  the error reads "no decoder for Apple Intermediate Codec video".
  Other codecs keep their short names in that error.
- ui-egui: an Unreadable badge shows on hover the error the media pool
  recorded, on cards (Icon and Freeform view) and in List view, which
  now registers its badge as project.item.<id>.offline as cards do.
  This covers every cause the pool records, such as "H.264: field
  pictures", not only this codec.
- docs: an Apple Intermediate Codec row in codec-parity.md, and the
  hover in project-files.md.

FilmCraft still has no AIC decoder. Apple publishes no specification
for it, so a clean-room decoder (AGENTS.md section 2) needs a
maintainer ruling, and #3646 stays open for that.

Tests: apple_intermediate_codec_is_named_though_nothing_decodes_it in
filmcraft-codecs (the label and all three errors), and
unreadable_badge_says_why_on_hover in the ui-egui it suite (both views).

Developed with an AI assistant (Árbol, running Muse).

* ui: keep the List view's badges beside a long name (#3646)

In List view, a name that left less than 40 px of its column hid the
item's offline icon and proxy chip, and with them the Unreadable
badge's automation id and the reason it shows on hover. The badges now
keep their room at the end of the Name column, and a long name is cut
short before them. Only a column too narrow for the badges leaves them
out. Rows that showed their badges before, and rows without badges,
draw as they did.

unreadable_badge_says_why_on_hover imports a file whose name is wider
than the Name column, so its List view pass covers this case. Found by
CodeRabbit in its review of the pull request.

Developed with an AI assistant (Árbol, running Muse).

* codecs, ui: doc comments for codec_label and media_badge (#3646)

Both functions gained a role with the Apple Intermediate Codec change:
codec_label also names the codec in the no-decoder errors, and
media_badge records why an Unreadable file cannot be read. Their doc
comments say so.

Developed with an AI assistant (Árbol, running Muse).

* ui: end a List-view name cut short with "…" and show it in full on hover (#3646)

Keeping the badges' room at the end of the Name column can cut short a name
that used to fit, and the cut left no sign of it. The List view now draws the
name with the cards' elided_name, so a cut name ends in "…", and hovering it
shows the whole name, as the cards already do. Names that fit draw as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:02:55 -05:00
842d3ca567 lumetri.presetThumbnails: write through the host and keep the grid to the presets (#4030)
* lumetri.presetThumbnails: write through the host and keep the grid to the presets

The PNG was written with std::fs::write instead of the session's Services,
the path every other export takes (#379), so on hosts without a filesystem
it trapped and elsewhere it ignored the host's file handling. The grid was
also as wide as `columns` asked (up to 32) whatever the number of presets:
there are ten, and 32 columns of 1920 px cells made a 61 572 x 1 084 image,
over a gigabyte of f32 pixels and a 267 MB PNG for one query. Columns now
stop at the number of presets shown. Found by fuzzing commands with hostile
parameters (it took over 10 s per call in a debug build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* render: the preset grid copies thumbnails with checked slices

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:02:50 -05:00
Agapenor fdfb6f2182 codecs: an MP4 edit rounded to the movie timescale keeps the last frame (#3994)
* codecs: an MP4 edit rounded to the movie timescale keeps the last frame

Edit durations are whole movie-timescale units (milliseconds in ffmpeg,
x264 and FilmCraft's own exports), so a clip whose length is not a whole
number of milliseconds imported one frame short: 320 frames at 24 fps
(13.3333 s, written as 13 333) read as 319. When the last edit plays the
media to the end of its last picture (within one unit), the clip now lasts
exactly that long.

* codecs: an edit exactly one movie-timescale unit short of the media is a trim

Rounding to the movie timescale is always less than one unit off, so a
difference of exactly one unit (a one-frame trim when the movie and track
timescales are equal) keeps the edit's own length.

* codecs: compare the edit with the media exactly, and never across a whole picture

The comparison now runs in the two timescales themselves (Tick flooring
could put a one-unit trim at 23/23 under the bound), and a difference of a
whole last picture or more is a trim even when a coarse movie unit (1/24 s
against 1/30 s pictures) is longer than that.
2026-11-03 00:02:45 -05:00
Rahulandrahul05ranjan 8e4211a693 i18n(zh-cn): translate the 41 strings the report listed as missing (#3964)
The Simplified Chinese catalog lacked 41 interface strings: the update
prompts, the audio-output failure messages, the trim/roll-edit and
work-area commands, the dock-panel labels, theme names, the gradient
and fill labels, the responsive-design-time note and the hardware
decoding explanation.

Adds them with the Premiere-style vocabulary the catalog already uses
(轨道, 修剪, 过渡). After this the report lists nothing missing for
zh-cn.

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-03 00:02:40 -05:00
0ce3864cbe i18n(ru): translate the 64 strings the missing-translations report listed (#1674) (#3960)
* i18n(ru): translate the 64 strings the missing-translations report listed (#1674)

The Russian catalog covered 3,660 of the 3,719 interface strings, so
parts of the interface still fell back to English (#1674): the update
prompts, the hardware-encoder failure messages, the clip-mismatch
dialog, the work-area bar commands, marker and track colour, audio and
video names, the responsive-design-time note and the search-graphics
statuses.

Adds the 64 missing entries with the Premiere-style vocabulary the
catalog already uses (последовательность, дорожка, маркер). After this
the report lists nothing missing for ru.

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>

* i18n(ru): drop the entry for the old hardware-decoding text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:02:35 -05:00
Jonathan CardosoandBrandon Thomas 8ce79afd23 captions: SCC and MCC export name the characters they drop or replace (#4023) (#4036)
CEA-608 has no code for some characters (the ellipsis, CJK) and the SCC
and MCC writers dropped them, or sent a close basic-set one (’ as '),
without saying so. The CEA-708 service in MCC sent ? the same way, for
instance for an em dash.

charset_warnings lists each such character once, with how often and
the first caption that has it, using the writers' own tables, and
captions.export returns the list as warnings. The list is capped at
20 characters per stream plus one summary line.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:02:10 -05:00
wweimao f3f1afd160 M10.7: fix per-character Stroke and Stroke 2 controls (#3938) 2026-11-03 00:01:43 -05:00
NetterSilverandClaude Haiku 5.5 cc5756711d Read TTS fractions with denominator 1 as "over one" (#3983)
Found while testing crates/tts-text/src/normalize.rs:~600 (parse_fraction, `_` arm of the denominator match)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 00:01:39 -05:00
NetterSilverandClaude Haiku 5.5 ae7f7ec1f3 Strip leading BOM in WebVTT parse even without a signature (#3961)
Found while testing crates/captions/src/vtt.rs:58-122 (parse; the BOM is only stripped for the signature check at line 63)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 00:01:34 -05:00
NetterSilverandClaude Haiku 5.5 8bc28377e2 Only decode percent escapes made of hex digits in file URLs (#3936)
Found while testing crates/interchange/src/common.rs:36 (percent_decode, used by file_url_to_path and resolve_url)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 00:01:29 -05:00
NetterSilverandClaude Haiku 5.5 c2dbf2761c Reject '+'-signed audio channel numbers in EDL import (#3935)
Found while testing crates/interchange/src/edl.rs:~90 (parse_channels, the `p.starts_with('A')` arm)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-03 00:01:24 -05:00
Jonathan Cardoso a4bc8edd50 captions: TTML export writes the track language, import keeps it (#4025) (#4038)
TTML and DFXP exports always wrote xml:lang="en", whatever the caption
track's language. The document now carries the language: export takes
the track's, TTML import reads xml:lang (a language tag of at most 35
characters, else a warning), and captions.import takes an optional
language that wins over both.
2026-11-03 00:01:18 -05:00
0535926e87 engine: AAF/OMF export takes bitDepth as "24" too, and refuses anything but 16 or 24 (#4019) (#4020)
* engine: AAF/OMF export takes bitDepth as "24" too, and refuses anything but 16 or 24 (#4019)

plan_from read bitDepth with u64_p, which takes only numbers, so "24" (the
form describe lists, "16|24") fell back to 16 without a word, and `as u16`
let 65560 wrap to 24. It now takes 16 or 24 as a number or a numeric string
and refuses any other value.

* engine: the bitDepth refusal test covers OMF export too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:01:01 -05:00
6e877e26b6 interchange: EDL and OMF export reports name the video tracks they leave out (#4132) (#4133)
* interchange: EDL and OMF export reports name the video tracks they leave out (#4132)

* edl: report no video exported when the chosen track is empty

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* edl: rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:00:56 -05:00
Jonathan Cardoso a754635de0 interchange: an OTIO item at a sample rate no longer sets the sequence rate (#4111) (#4112)
The OTIO importer took the sequence rate from global_start_time or the
first item with a source_range, whatever its rate. A gap or clip at rate
48000 is valid OTIO, so the sequence came out at 48000 fps and the
engine refused the import. Both now skip a rate no sequence can run at.
2026-11-03 00:00:50 -05:00
Jonathan Cardoso baddebdc30 interchange: EDL media relinked with Align Timecode lands on the file's own time (#3990) (#3996)
* interchange: EDL media relinked with Align Timecode lands on the file's own time (#3990)

An EDL's source times are timecodes, but its media items had no start
timecode, so Align Timecode had nothing to align from and a clip relinked
after an offline import kept its timecode as media time (the file's last
frame). EDL media now starts at timecode 0, and the relink shift reads each
timecode at its own file's rate, as the import-time rebase does.

* engine: EDL relink test checks the placeholder is listed missing, not its offline flag (#3990)

#4000 stops flagging EDL clips named only by clip name offline, so the setup asks media.findMissing instead, which lists the item either way.

* engine: the EDL relink test also runs with 25 fps media (#3990)

make_movie_at takes the frame rate. Under a 24 fps EDL a 25 fps file's timecode must be read at 25 fps: with the old shift (old_rate.tick_of(b - a)) the clip lands at -149 s instead of 1 s.
2026-11-03 00:00:45 -05:00
Jonathan Cardoso 351a713835 interchange: EDL clips named only by clip name are missing, not made offline (#3997) (#4000)
An EDL line with no `* SOURCE FILE:` guesses its file from the clip name in
the EDL's folder. When that file wasn't there the importer flagged the clip
offline, which Link Media reads as "made offline": the folder search, Relink
Others and the prompt on open all skip it. It is now a missing file, as an
unfound `* SOURCE FILE:` is.
2026-11-03 00:00:40 -05:00
Lightningbulb2andClaude Opus 5.5 65871f2300 Interchange export: saturate timecodes and PCM fields on hostile project values (#4029)
A project file can hold any i64 sequence start timecode and any u32 audio
channel count and sample rate (load-time validation checks neither). The EDL
writer added the start timecode to every record frame unchecked; the AAF and
OMF writers multiplied channels by bytes per sample and by the sample rate,
and wav_header did the same in u16. Channel counts, frame counts and block
sizes were then cut down with `as` (65 536 channels became 0 in OMF and in
the embedded WAVE essence), and aiff_header's chunk sizes wrapped. Debug
builds panicked; release builds wrote wrapped values. All of it saturates
now, as does frames_round's next frame. Regression tests for EDL, AAF and
OMF; found by mutation-fuzzing project files.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:00:34 -05:00
Jonathan Cardoso 23dcced09c interchange: AAF top-level composition carries the registered Usage_TopLevel (#3986) (#3987)
* interchange: AAF top-level composition carries the registered Usage_TopLevel (#3986)

The usage code was 060e2b34.04010105.0d011201.02030000, which is in no
SMPTE register. Usage_TopLevel is 060e2b34.04010101.0d010102.01010700
in the Labels register. LibAAF looks for it and finds no sequence
without it. Files with the old code still import through the reader's
fallback to the composition no other one uses.

* interchange: AAF import reads the old FilmCraft top-level label as top-level too (#3986)

With the registered Usage_TopLevel recognised, a composition tagged with
the label FilmCraft 0.16 and earlier wrote was dropped whenever another
composition in the file carried the registered one. Both now count.
2026-11-03 00:00:29 -05:00
Dylan M. Taylor 8b59e853b6 captions: escape a TTML cue's unclosed '<' tail in one pass (#4105)
The TTML inline writer searched the rest of the cue for '>' at every '<'.
When none remained it consumed one character and searched again, so a cue
of many unclosed '<' cost quadratic time and stalled TTML/DFXP export.
Once no '>' remains no later '<' can start a tag, so stop searching.
2026-11-02 22:51:12 -05:00
Dylan M. Taylor 4a23548b7f web: load only a same-origin http(s) app in the WebCodecs read-error harness (#4103)
* web: load only a same-origin http(s) app in the WebCodecs read-error harness

* web: keep the app URL's own query flags in the WebCodecs read-error harness
2026-11-02 22:50:53 -05:00
Dylan M. Taylor 5163f259b5 apv: size the auxiliary alpha frame only after its tiles decode (#4109) 2026-11-02 21:17:11 -05:00
Dylan M. Taylor e90bb50ff7 dnx: cap decoded frame memory and reject repeated scan line indices (#4101) 2026-11-02 21:17:07 -05:00
Dylan M. Taylor 3ef97d85e2 avi: hold child chunks to their container so lists cannot overlap (#4106) 2026-11-02 21:16:44 -05:00
Dylan M. Taylor 326b8625c6 web: check MP4 sample extent before allocating its buffer (#4102) 2026-11-02 21:16:39 -05:00
Brandon ThomasandClaude Opus 5.5 eb59ec09da Release 0.17.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.17.0
2026-11-02 19:25:46 -05:00
68919ed87a Read decades like 1990s as plural years in TTS normalizer (#3930)
* Read decades like 1990s as plural years in TTS normalizer

Found while testing crates/tts-text/src/normalize.rs:~654 (cardinal/year branch) and parse_continuation ~line 680

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* tts-text: decade tests in the it binary, with boundary cases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 00:38:18 -05:00